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/__init__.py +28 -0
- dfm_python/config.py +435 -0
- dfm_python/data_loader.py +500 -0
- dfm_python/dfm.py +2356 -0
- dfm_python/kalman.py +403 -0
- dfm_python/news.py +557 -0
- dfm_python-0.1.0.dist-info/METADATA +271 -0
- dfm_python-0.1.0.dist-info/RECORD +9 -0
- dfm_python-0.1.0.dist-info/WHEEL +4 -0
dfm_python/news.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""News decomposition for nowcast updates."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.linalg import pinv, inv
|
|
5
|
+
from typing import Tuple, Optional, Dict, Union, List, Any, Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .kalman import skf, fis, miss_data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def para_const(X: np.ndarray, P, lag: int) -> Dict:
|
|
13
|
+
"""Implement Kalman filter for news calculation with fixed parameters.
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
-----------
|
|
17
|
+
X : np.ndarray
|
|
18
|
+
Data matrix (T x N)
|
|
19
|
+
P : DFMResult
|
|
20
|
+
DFM parameters
|
|
21
|
+
lag : int
|
|
22
|
+
Number of lags
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
--------
|
|
26
|
+
Dict with keys: Plag, P, X_sm, F
|
|
27
|
+
"""
|
|
28
|
+
# Set model parameters
|
|
29
|
+
Z_0 = P.Z_0
|
|
30
|
+
V_0 = P.V_0
|
|
31
|
+
A = P.A
|
|
32
|
+
C = P.C
|
|
33
|
+
Q = P.Q
|
|
34
|
+
R = P.R
|
|
35
|
+
Mx = P.Mx
|
|
36
|
+
Wx = P.Wx
|
|
37
|
+
|
|
38
|
+
# Prepare data
|
|
39
|
+
T, _ = X.shape
|
|
40
|
+
|
|
41
|
+
# Standardize
|
|
42
|
+
Y = ((X - Mx) / Wx).T # n x T
|
|
43
|
+
|
|
44
|
+
# Apply Kalman filter and smoother
|
|
45
|
+
Sf = skf(Y, A, C, Q, R, Z_0, V_0)
|
|
46
|
+
Ss = fis(A, Sf)
|
|
47
|
+
|
|
48
|
+
# Calculate parameter output
|
|
49
|
+
Vs = Ss.VmT[:, :, 1:] # Smoothed factor covariance
|
|
50
|
+
Vf = Sf.VmU[:, :, 1:] # Filtered factor posterior covariance
|
|
51
|
+
Zsmooth = Ss.ZmT
|
|
52
|
+
Vsmooth = Ss.VmT
|
|
53
|
+
|
|
54
|
+
Plag = [Vs]
|
|
55
|
+
|
|
56
|
+
# Calculate lagged covariance matrices
|
|
57
|
+
# MATLAB loops backwards: for jt = size(Plag{1},3):-1:lag+1
|
|
58
|
+
for jk in range(1, lag + 1):
|
|
59
|
+
Plag_jk = np.zeros_like(Plag[0])
|
|
60
|
+
# Loop backwards from last time index down to lag+1 (MATLAB: size(Plag{1},3):-1:lag+1)
|
|
61
|
+
# Note: Python uses 0-based indexing, so range(N-1, lag, -1) gives N-1 down to lag+1
|
|
62
|
+
for jt in range(Plag[0].shape[2] - 1, lag, -1): # Backward iteration to match MATLAB
|
|
63
|
+
As = Vf[:, :, jt - jk] @ A.T @ pinv(A @ Vf[:, :, jt - jk] @ A.T + Q)
|
|
64
|
+
Plag_jk[:, :, jt] = As @ Plag[jk - 1][:, :, jt]
|
|
65
|
+
Plag.append(Plag_jk)
|
|
66
|
+
|
|
67
|
+
# Prepare data for output
|
|
68
|
+
Zsmooth = Zsmooth.T # T x m
|
|
69
|
+
x_sm = Zsmooth[1:, :] @ C.T # T x N
|
|
70
|
+
X_sm = Wx * x_sm + Mx # Unstandardized
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
'Plag': Plag,
|
|
74
|
+
'P': Vsmooth,
|
|
75
|
+
'X_sm': X_sm,
|
|
76
|
+
'F': Zsmooth[1:, :]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def news_dfm(X_old: np.ndarray, X_new: np.ndarray, Res, t_fcst: int,
|
|
81
|
+
v_news: Union[int, np.ndarray, List[int]]) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray], np.ndarray, np.ndarray,
|
|
82
|
+
np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
83
|
+
"""Calculate changes in news from data releases.
|
|
84
|
+
|
|
85
|
+
Parameters:
|
|
86
|
+
-----------
|
|
87
|
+
X_old : np.ndarray
|
|
88
|
+
Old data matrix (T x N)
|
|
89
|
+
X_new : np.ndarray
|
|
90
|
+
New data matrix (T x N)
|
|
91
|
+
Res : DFMResult
|
|
92
|
+
DFM estimation results
|
|
93
|
+
t_fcst : int
|
|
94
|
+
Target time index (0-based)
|
|
95
|
+
v_news : int or array-like
|
|
96
|
+
Target variable index(s) (0-based). Can be:
|
|
97
|
+
- int: Single target variable (backward compatible)
|
|
98
|
+
- np.ndarray or List[int]: Multiple target variables (MATLAB-compatible)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
--------
|
|
102
|
+
y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
|
|
103
|
+
|
|
104
|
+
If v_news is scalar:
|
|
105
|
+
- y_old, y_new: float
|
|
106
|
+
- singlenews: (N,) array
|
|
107
|
+
If v_news is array:
|
|
108
|
+
- y_old, y_new: (n_targets,) array
|
|
109
|
+
- singlenews: (N, n_targets) array where each column is news for one target
|
|
110
|
+
|
|
111
|
+
Note:
|
|
112
|
+
-----
|
|
113
|
+
Now supports both single and multiple target variables, matching MATLAB functionality.
|
|
114
|
+
"""
|
|
115
|
+
# Normalize v_news to array for consistent handling
|
|
116
|
+
v_news_arr = np.atleast_1d(v_news)
|
|
117
|
+
is_scalar = isinstance(v_news, (int, np.integer)) or (isinstance(v_news, np.ndarray) and v_news.ndim == 0)
|
|
118
|
+
n_targets = len(v_news_arr)
|
|
119
|
+
|
|
120
|
+
r = Res.C.shape[1]
|
|
121
|
+
_, N = X_new.shape
|
|
122
|
+
|
|
123
|
+
# Check if targets are already observed
|
|
124
|
+
targets_observed = np.array([not np.isnan(X_new[t_fcst, v]) for v in v_news_arr])
|
|
125
|
+
|
|
126
|
+
if np.all(targets_observed):
|
|
127
|
+
# NO FORECAST CASE: Already values for all target variables at time t_fcst
|
|
128
|
+
Res_old = para_const(X_old, Res, 0)
|
|
129
|
+
|
|
130
|
+
# Initialize output arrays
|
|
131
|
+
if is_scalar:
|
|
132
|
+
singlenews = np.zeros(N)
|
|
133
|
+
singlenews[v_news_arr[0]] = X_new[t_fcst, v_news_arr[0]] - Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
134
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
135
|
+
y_new = X_new[t_fcst, v_news_arr[0]]
|
|
136
|
+
else:
|
|
137
|
+
singlenews = np.zeros((N, n_targets))
|
|
138
|
+
for i, v in enumerate(v_news_arr):
|
|
139
|
+
singlenews[v, i] = X_new[t_fcst, v] - Res_old['X_sm'][t_fcst, v]
|
|
140
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
141
|
+
y_new = np.array([X_new[t_fcst, v] for v in v_news_arr])
|
|
142
|
+
|
|
143
|
+
actual = np.array([])
|
|
144
|
+
forecast = np.array([])
|
|
145
|
+
weight = np.array([])
|
|
146
|
+
t_miss = np.array([])
|
|
147
|
+
v_miss = np.array([])
|
|
148
|
+
innov = np.array([])
|
|
149
|
+
else:
|
|
150
|
+
# FORECAST CASE
|
|
151
|
+
Mx = Res.Mx
|
|
152
|
+
Wx = Res.Wx
|
|
153
|
+
|
|
154
|
+
# Calculate indicators for missing values
|
|
155
|
+
miss_old = np.isnan(X_old)
|
|
156
|
+
miss_new = np.isnan(X_new)
|
|
157
|
+
|
|
158
|
+
# Indicator for missing: 1 = new data available, -1 = old data available but not new
|
|
159
|
+
i_miss = miss_old.astype(int) - miss_new.astype(int)
|
|
160
|
+
|
|
161
|
+
# Time/variable indices where new data is available
|
|
162
|
+
t_miss, v_miss = np.where(i_miss == 1)
|
|
163
|
+
t_miss = t_miss.flatten()
|
|
164
|
+
v_miss = v_miss.flatten()
|
|
165
|
+
|
|
166
|
+
if len(v_miss) == 0:
|
|
167
|
+
# NO NEW INFORMATION
|
|
168
|
+
Res_old = para_const(X_old, Res, 0)
|
|
169
|
+
Res_new = para_const(X_new, Res, 0)
|
|
170
|
+
|
|
171
|
+
if is_scalar:
|
|
172
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
173
|
+
y_new = y_old
|
|
174
|
+
singlenews = np.array([])
|
|
175
|
+
else:
|
|
176
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
177
|
+
y_new = y_old
|
|
178
|
+
singlenews = np.array([]).reshape(0, n_targets)
|
|
179
|
+
|
|
180
|
+
actual = np.array([])
|
|
181
|
+
forecast = np.array([])
|
|
182
|
+
weight = np.array([])
|
|
183
|
+
t_miss = np.array([])
|
|
184
|
+
v_miss = np.array([])
|
|
185
|
+
innov = np.array([])
|
|
186
|
+
else:
|
|
187
|
+
# NEW INFORMATION
|
|
188
|
+
# Difference between forecast time and new data time
|
|
189
|
+
lag = t_fcst - t_miss
|
|
190
|
+
|
|
191
|
+
# Biggest time interval
|
|
192
|
+
k = max([np.max(np.abs(lag)), np.max(lag) - np.min(lag)])
|
|
193
|
+
k = int(k)
|
|
194
|
+
|
|
195
|
+
C = Res.C
|
|
196
|
+
R_cov = Res.R.T # Note: R in Res might need transposition
|
|
197
|
+
|
|
198
|
+
n_news = len(lag)
|
|
199
|
+
|
|
200
|
+
# Smooth old dataset
|
|
201
|
+
Res_old = para_const(X_old, Res, k)
|
|
202
|
+
Plag = Res_old['Plag']
|
|
203
|
+
|
|
204
|
+
# Smooth new dataset
|
|
205
|
+
Res_new = para_const(X_new, Res, 0)
|
|
206
|
+
|
|
207
|
+
# Get nowcasts for all target variables
|
|
208
|
+
if is_scalar:
|
|
209
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
210
|
+
y_new = Res_new['X_sm'][t_fcst, v_news_arr[0]]
|
|
211
|
+
else:
|
|
212
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
213
|
+
y_new = np.array([Res_new['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
214
|
+
|
|
215
|
+
# Calculate projection matrices (simplified)
|
|
216
|
+
P1 = []
|
|
217
|
+
for i in range(n_news):
|
|
218
|
+
h = abs(t_fcst - t_miss[i])
|
|
219
|
+
m = max(t_miss[i], t_fcst)
|
|
220
|
+
|
|
221
|
+
if t_miss[i] > t_fcst:
|
|
222
|
+
Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
|
|
223
|
+
else:
|
|
224
|
+
Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
|
|
225
|
+
|
|
226
|
+
P1.append(Pp @ C[v_miss[i], :r].T)
|
|
227
|
+
|
|
228
|
+
P1 = np.hstack(P1) if len(P1) > 0 else np.zeros((r, 1))
|
|
229
|
+
|
|
230
|
+
# Calculate innovations
|
|
231
|
+
innov = np.zeros(n_news)
|
|
232
|
+
for i in range(n_news):
|
|
233
|
+
X_new_norm = (X_new[t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
|
|
234
|
+
X_sm_norm = (Res_old['X_sm'][t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
|
|
235
|
+
innov[i] = X_new_norm - X_sm_norm
|
|
236
|
+
|
|
237
|
+
# Calculate P2 (covariance of innovations) - simplified
|
|
238
|
+
P2 = np.zeros((n_news, n_news))
|
|
239
|
+
for i in range(n_news):
|
|
240
|
+
for j in range(n_news):
|
|
241
|
+
h = abs(lag[i] - lag[j])
|
|
242
|
+
m = max(t_miss[i], t_miss[j])
|
|
243
|
+
|
|
244
|
+
if t_miss[j] > t_miss[i]:
|
|
245
|
+
Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
|
|
246
|
+
else:
|
|
247
|
+
Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
|
|
248
|
+
|
|
249
|
+
if v_miss[i] == v_miss[j] and t_miss[i] != t_miss[j]:
|
|
250
|
+
WW = 0
|
|
251
|
+
else:
|
|
252
|
+
WW = R_cov[v_miss[i], v_miss[j]]
|
|
253
|
+
|
|
254
|
+
P2[i, j] = C[v_miss[i], :r] @ Pp @ C[v_miss[j], :r].T + WW
|
|
255
|
+
|
|
256
|
+
# Calculate weights and news for each target variable
|
|
257
|
+
if n_news > 0 and P2.size > 0:
|
|
258
|
+
try:
|
|
259
|
+
P2_inv = inv(P2)
|
|
260
|
+
# Calculate gain for each target variable
|
|
261
|
+
if is_scalar:
|
|
262
|
+
v_idx = v_news_arr[0]
|
|
263
|
+
gain = (Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv).reshape(-1)
|
|
264
|
+
totnews = Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv @ innov
|
|
265
|
+
else:
|
|
266
|
+
gain = np.zeros((n_targets, n_news))
|
|
267
|
+
totnews = np.zeros(n_targets)
|
|
268
|
+
for idx, v in enumerate(v_news_arr):
|
|
269
|
+
gain[idx, :] = Wx[v] * C[v, :r] @ P1 @ P2_inv
|
|
270
|
+
totnews[idx] = Wx[v] * C[v, :r] @ P1 @ P2_inv @ innov
|
|
271
|
+
except:
|
|
272
|
+
# If inversion fails, use simpler approach
|
|
273
|
+
if is_scalar:
|
|
274
|
+
gain = np.ones(n_news) * 0.1
|
|
275
|
+
totnews = np.sum(innov) * 0.1
|
|
276
|
+
else:
|
|
277
|
+
gain = np.ones((n_targets, n_news)) * 0.1
|
|
278
|
+
totnews = np.ones(n_targets) * np.sum(innov) * 0.1
|
|
279
|
+
else:
|
|
280
|
+
if is_scalar:
|
|
281
|
+
gain = np.zeros(n_news)
|
|
282
|
+
totnews = 0
|
|
283
|
+
else:
|
|
284
|
+
gain = np.zeros((n_targets, n_news))
|
|
285
|
+
totnews = np.zeros(n_targets)
|
|
286
|
+
|
|
287
|
+
# Organize output
|
|
288
|
+
if is_scalar:
|
|
289
|
+
singlenews = np.full(N, np.nan)
|
|
290
|
+
actual = np.full(N, np.nan)
|
|
291
|
+
forecast = np.full(N, np.nan)
|
|
292
|
+
weight = np.full(N, np.nan)
|
|
293
|
+
|
|
294
|
+
for i in range(n_news):
|
|
295
|
+
actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
|
|
296
|
+
forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
|
|
297
|
+
if i < len(gain):
|
|
298
|
+
singlenews[v_miss[i]] = gain[i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
299
|
+
weight[v_miss[i]] = gain[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
300
|
+
else:
|
|
301
|
+
# Multiple targets: singlenews is (N, n_targets)
|
|
302
|
+
singlenews = np.full((N, n_targets), np.nan)
|
|
303
|
+
actual = np.full(N, np.nan)
|
|
304
|
+
forecast = np.full(N, np.nan)
|
|
305
|
+
weight = np.full((N, n_targets), np.nan)
|
|
306
|
+
|
|
307
|
+
for i in range(n_news):
|
|
308
|
+
actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
|
|
309
|
+
forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
|
|
310
|
+
for idx in range(n_targets):
|
|
311
|
+
if i < len(gain[idx]):
|
|
312
|
+
singlenews[v_miss[i], idx] = gain[idx, i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
313
|
+
weight[v_miss[i], idx] = gain[idx, i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
314
|
+
|
|
315
|
+
# Remove duplicates from v_miss
|
|
316
|
+
v_miss = np.unique(v_miss)
|
|
317
|
+
|
|
318
|
+
return y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# Database saving is application-specific and should be implemented in adapters.
|
|
322
|
+
# This keeps the DFM module generic and database-agnostic.
|
|
323
|
+
def update_nowcast(X_old: np.ndarray, X_new: np.ndarray, Time: pd.DatetimeIndex,
|
|
324
|
+
config, Res, series: str, period: str,
|
|
325
|
+
vintage_old: str, vintage_new: str,
|
|
326
|
+
model_id: Optional[int] = None,
|
|
327
|
+
save_callback: Optional[Callable] = None) -> None:
|
|
328
|
+
"""Update nowcast and decompose changes into news.
|
|
329
|
+
|
|
330
|
+
Parameters:
|
|
331
|
+
-----------
|
|
332
|
+
X_old : np.ndarray
|
|
333
|
+
Old vintage data (T_old x N)
|
|
334
|
+
X_new : np.ndarray
|
|
335
|
+
New vintage data (T_new x N)
|
|
336
|
+
Time : pd.DatetimeIndex
|
|
337
|
+
Time index
|
|
338
|
+
config : DFMConfig
|
|
339
|
+
Model configuration
|
|
340
|
+
Res : DFMResult or dict
|
|
341
|
+
DFM estimation results. If dict, must contain 'Res' and optionally 'Spec'.
|
|
342
|
+
If DFMResult and has .Spec attribute, it will be checked for consistency.
|
|
343
|
+
series : str
|
|
344
|
+
Target series ID
|
|
345
|
+
period : str
|
|
346
|
+
Target period (e.g., '2016q4')
|
|
347
|
+
vintage_old : str
|
|
348
|
+
Old vintage date
|
|
349
|
+
vintage_new : str
|
|
350
|
+
New vintage date
|
|
351
|
+
model_id : int, optional
|
|
352
|
+
Model ID for saving (passed to save_callback if provided)
|
|
353
|
+
save_callback : Callable, optional
|
|
354
|
+
Optional callback function to save nowcast results.
|
|
355
|
+
If provided, will be called with nowcast parameters.
|
|
356
|
+
Example: Implement save_callback in your application adapter
|
|
357
|
+
save_callback=lambda **kwargs: save_nowcast_to_db(**kwargs)
|
|
358
|
+
|
|
359
|
+
Notes:
|
|
360
|
+
------
|
|
361
|
+
If Res contains a saved Config and it differs from the current config, a warning
|
|
362
|
+
is issued. This matches MATLAB behavior where Res.Config is checked for consistency.
|
|
363
|
+
|
|
364
|
+
Database saving is robust with error handling - failures will log warnings
|
|
365
|
+
but not interrupt the nowcast computation.
|
|
366
|
+
"""
|
|
367
|
+
# Handle dict format (from saved results) - extract Res and check config consistency
|
|
368
|
+
if isinstance(Res, dict):
|
|
369
|
+
if 'Spec' in Res and 'Res' in Res:
|
|
370
|
+
# Legacy: Spec key for backward compatibility
|
|
371
|
+
saved_config = Res['Spec']
|
|
372
|
+
Res = Res['Res']
|
|
373
|
+
# Simple consistency check: compare SeriesID
|
|
374
|
+
try:
|
|
375
|
+
if hasattr(saved_config, 'SeriesID') and hasattr(config, 'SeriesID'):
|
|
376
|
+
if saved_config.SeriesID != config.SeriesID:
|
|
377
|
+
import warnings
|
|
378
|
+
warnings.warn(
|
|
379
|
+
"Config used in estimation differs from current config. "
|
|
380
|
+
"Results may be inconsistent. Consider re-estimating.",
|
|
381
|
+
UserWarning
|
|
382
|
+
)
|
|
383
|
+
except Exception:
|
|
384
|
+
pass # If comparison fails, continue anyway
|
|
385
|
+
elif 'Config' in Res and 'Res' in Res:
|
|
386
|
+
# New format with Config key
|
|
387
|
+
saved_config = Res['Config']
|
|
388
|
+
Res = Res['Res']
|
|
389
|
+
try:
|
|
390
|
+
if hasattr(saved_config, 'SeriesID') and hasattr(config, 'SeriesID'):
|
|
391
|
+
if saved_config.SeriesID != config.SeriesID:
|
|
392
|
+
import warnings
|
|
393
|
+
warnings.warn(
|
|
394
|
+
"Config used in estimation differs from current config. "
|
|
395
|
+
"Results may be inconsistent. Consider re-estimating.",
|
|
396
|
+
UserWarning
|
|
397
|
+
)
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
elif 'Res' in Res:
|
|
401
|
+
Res = Res['Res']
|
|
402
|
+
|
|
403
|
+
# Also check if DFMResult has a Config attribute (for future compatibility)
|
|
404
|
+
if hasattr(Res, 'Config') and hasattr(config, 'SeriesID'):
|
|
405
|
+
try:
|
|
406
|
+
if hasattr(Res.Config, 'SeriesID') and Res.Config.SeriesID != config.SeriesID:
|
|
407
|
+
import warnings
|
|
408
|
+
warnings.warn(
|
|
409
|
+
"Config in Res differs from current config. "
|
|
410
|
+
"Results may be inconsistent.",
|
|
411
|
+
UserWarning
|
|
412
|
+
)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
# Make sure datasets are same size
|
|
417
|
+
N = X_new.shape[1]
|
|
418
|
+
T_old = X_old.shape[0]
|
|
419
|
+
T_new = X_new.shape[0]
|
|
420
|
+
|
|
421
|
+
if T_new > T_old:
|
|
422
|
+
X_old = np.vstack([X_old, np.full((T_new - T_old, N), np.nan)])
|
|
423
|
+
|
|
424
|
+
# Append 1 year (12 months) for forecasting horizon
|
|
425
|
+
X_old = np.vstack([X_old, np.full((12, N), np.nan)])
|
|
426
|
+
X_new = np.vstack([X_new, np.full((12, N), np.nan)])
|
|
427
|
+
|
|
428
|
+
# Extend time index
|
|
429
|
+
last_date = Time.iloc[-1] if hasattr(Time, 'iloc') else Time[-1]
|
|
430
|
+
if hasattr(last_date, 'month'):
|
|
431
|
+
new_dates = pd.date_range(
|
|
432
|
+
start=last_date + pd.DateOffset(months=1),
|
|
433
|
+
periods=12,
|
|
434
|
+
freq='MS'
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
# Fallback if not pandas datetime
|
|
438
|
+
new_dates = pd.date_range(start='2017-01-01', periods=12, freq='MS')
|
|
439
|
+
|
|
440
|
+
# Handle both Index and Series types
|
|
441
|
+
if isinstance(Time, pd.Series):
|
|
442
|
+
Time = pd.concat([Time, pd.Series(new_dates)])
|
|
443
|
+
elif hasattr(Time, 'append'):
|
|
444
|
+
try:
|
|
445
|
+
Time = Time.append(pd.Index(new_dates))
|
|
446
|
+
except AttributeError:
|
|
447
|
+
Time = pd.concat([Time, pd.Index(new_dates)])
|
|
448
|
+
else:
|
|
449
|
+
Time = pd.concat([Time, pd.Index(new_dates)])
|
|
450
|
+
|
|
451
|
+
# Find series index
|
|
452
|
+
try:
|
|
453
|
+
i_series = config.SeriesID.index(series)
|
|
454
|
+
except ValueError:
|
|
455
|
+
raise ValueError(f"Series {series} not found in configuration")
|
|
456
|
+
|
|
457
|
+
series_name = config.SeriesName[i_series]
|
|
458
|
+
freq = config.Frequency[i_series]
|
|
459
|
+
|
|
460
|
+
# Parse period
|
|
461
|
+
if freq == 'm':
|
|
462
|
+
year_str, month_str = period.split('m')
|
|
463
|
+
year = int(year_str)
|
|
464
|
+
month = int(month_str)
|
|
465
|
+
target_date = pd.Timestamp(year, month, 1)
|
|
466
|
+
elif freq == 'q':
|
|
467
|
+
year_str, q_str = period.split('q')
|
|
468
|
+
year = int(year_str)
|
|
469
|
+
q = int(q_str)
|
|
470
|
+
month = 3 * q
|
|
471
|
+
target_date = pd.Timestamp(year, month, 1)
|
|
472
|
+
else:
|
|
473
|
+
raise ValueError(f"Unsupported frequency: {freq}")
|
|
474
|
+
|
|
475
|
+
# Find time index
|
|
476
|
+
t_nowcast = None
|
|
477
|
+
for i, t in enumerate(Time):
|
|
478
|
+
if hasattr(t, 'year') and hasattr(t, 'month'):
|
|
479
|
+
if t.year == target_date.year and t.month == target_date.month:
|
|
480
|
+
t_nowcast = i
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
if t_nowcast is None:
|
|
484
|
+
raise ValueError('Period is out of nowcasting horizon (up to one year ahead).')
|
|
485
|
+
|
|
486
|
+
# Separate revisions from new releases
|
|
487
|
+
X_rev = X_new.copy()
|
|
488
|
+
X_rev[np.isnan(X_old)] = np.nan
|
|
489
|
+
|
|
490
|
+
# Compute news
|
|
491
|
+
# Impact from data revisions
|
|
492
|
+
y_old = news_dfm(X_old, X_rev, Res, t_nowcast, i_series)[0]
|
|
493
|
+
|
|
494
|
+
# Impact from data releases
|
|
495
|
+
y_rev, y_new, _, actual, forecast, weight = news_dfm(X_rev, X_new, Res, t_nowcast, i_series)[:6]
|
|
496
|
+
|
|
497
|
+
# Display output
|
|
498
|
+
print('\n\n\n')
|
|
499
|
+
print(f'Nowcast Update: {pd.to_datetime(vintage_new).strftime("%B %d, %Y")}')
|
|
500
|
+
|
|
501
|
+
if freq == 'q':
|
|
502
|
+
period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y:Q%q')
|
|
503
|
+
else:
|
|
504
|
+
period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y-%m')
|
|
505
|
+
|
|
506
|
+
print(f'Nowcast for {series_name} ({config.UnitsTransformed[i_series]}), {period_str}')
|
|
507
|
+
|
|
508
|
+
if len(forecast) == 0 or np.all(np.isnan(forecast)):
|
|
509
|
+
print('\n No forecast was made.\n')
|
|
510
|
+
else:
|
|
511
|
+
impact_revisions = y_rev - y_old
|
|
512
|
+
news = actual - forecast
|
|
513
|
+
impact_releases = weight * news
|
|
514
|
+
|
|
515
|
+
# Create summary table
|
|
516
|
+
data_released = np.any(np.isnan(X_old) & ~np.isnan(X_new), axis=0)
|
|
517
|
+
|
|
518
|
+
print('\n Nowcast Impact Decomposition')
|
|
519
|
+
print(' Note: The displayed output is subject to rounding error\n')
|
|
520
|
+
print(f' {pd.to_datetime(vintage_old).strftime("%b %d")} nowcast: {y_old:5.2f}')
|
|
521
|
+
print(f' Impact from data revisions: {impact_revisions:5.2f}')
|
|
522
|
+
print(f' Impact from data releases: {np.nansum(impact_releases):5.2f}')
|
|
523
|
+
print(' +_________')
|
|
524
|
+
print(f' Total impact: {impact_revisions + np.nansum(impact_releases):5.2f}')
|
|
525
|
+
print(f' {pd.to_datetime(vintage_new).strftime("%b %d")} nowcast: {y_new:5.2f}\n')
|
|
526
|
+
|
|
527
|
+
# Display table for series with updates
|
|
528
|
+
print('\n Nowcast Detail Table \n')
|
|
529
|
+
for i in range(N):
|
|
530
|
+
if data_released[i] and not np.isnan(forecast[i]):
|
|
531
|
+
series_id = config.SeriesID[i]
|
|
532
|
+
print(f'{series_id:20s} Forecast: {forecast[i]:8.2f} Actual: {actual[i]:8.2f} '
|
|
533
|
+
f'Weight: {weight[i]:8.4f} Impact: {impact_releases[i]:8.2f}')
|
|
534
|
+
|
|
535
|
+
# Save nowcast using callback if provided (generic - not database-specific)
|
|
536
|
+
if save_callback is not None:
|
|
537
|
+
try:
|
|
538
|
+
save_callback(
|
|
539
|
+
model_id=model_id,
|
|
540
|
+
series=series,
|
|
541
|
+
forecast_date=target_date,
|
|
542
|
+
forecast_value=y_new,
|
|
543
|
+
old_forecast_value=y_old,
|
|
544
|
+
impact_revisions=impact_revisions,
|
|
545
|
+
impact_releases=np.nansum(impact_releases),
|
|
546
|
+
total_impact=impact_revisions + np.nansum(impact_releases),
|
|
547
|
+
vintage_old=vintage_old,
|
|
548
|
+
vintage_new=vintage_new,
|
|
549
|
+
Res=Res
|
|
550
|
+
)
|
|
551
|
+
except Exception as e:
|
|
552
|
+
import warnings
|
|
553
|
+
warnings.warn(
|
|
554
|
+
f"save_callback failed: {e}. Continuing without saving.",
|
|
555
|
+
UserWarning
|
|
556
|
+
)
|
|
557
|
+
|