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/__init__.py +60 -0
- dfm_python/config.py +482 -0
- dfm_python/data_loader.py +449 -0
- dfm_python/dfm.py +2366 -0
- dfm_python/kalman.py +455 -0
- dfm_python/news.py +732 -0
- dfm_python/utils/__init__.py +27 -0
- dfm_python/utils/aggregation.py +258 -0
- dfm_python/utils/data_utils.py +165 -0
- dfm_python-0.1.4.dist-info/METADATA +1010 -0
- dfm_python-0.1.4.dist-info/RECORD +13 -0
- dfm_python-0.1.4.dist-info/WHEEL +4 -0
- dfm_python-0.1.4.dist-info/licenses/LICENSE +21 -0
dfm_python/news.py
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""News decomposition for nowcasting updates.
|
|
2
|
+
|
|
3
|
+
This module implements the news decomposition framework for understanding how
|
|
4
|
+
new data releases affect nowcasts. The "news" is defined as the difference
|
|
5
|
+
between the new data release and the model's previous forecast, decomposed
|
|
6
|
+
into contributions from each data series.
|
|
7
|
+
|
|
8
|
+
The news decomposition provides:
|
|
9
|
+
- Forecast updates when new data arrives
|
|
10
|
+
- Attribution of forecast changes to specific data series
|
|
11
|
+
- Understanding of which data releases are most informative
|
|
12
|
+
|
|
13
|
+
This is essential for nowcasting applications where policymakers need to
|
|
14
|
+
understand the drivers of forecast revisions.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from scipy.linalg import pinv, inv
|
|
19
|
+
from typing import Tuple, Optional, Dict, Union, List, Any, Callable
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from .kalman import skf, fis, miss_data
|
|
24
|
+
from .config import DFMConfig
|
|
25
|
+
from .dfm import DFMResult
|
|
26
|
+
|
|
27
|
+
# Set up logger
|
|
28
|
+
_logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# Constants
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
34
|
+
# Number of months to append for forecasting horizon
|
|
35
|
+
# Used when extending the time index for forecast generation
|
|
36
|
+
FORECAST_HORIZON_MONTHS: int = 12
|
|
37
|
+
|
|
38
|
+
# Default fallback date for time index extension
|
|
39
|
+
# Used when the end date cannot be determined from the data
|
|
40
|
+
DEFAULT_FALLBACK_DATE: str = '2017-01-01'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_config_consistency(saved_config: Any, current_config: DFMConfig) -> None:
|
|
44
|
+
"""Check if saved config is consistent with current config.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
saved_config : Any
|
|
49
|
+
Saved configuration object (may be DFMConfig or dict-like)
|
|
50
|
+
current_config : DFMConfig
|
|
51
|
+
Current configuration object
|
|
52
|
+
|
|
53
|
+
Notes
|
|
54
|
+
-----
|
|
55
|
+
Issues a UserWarning if configs differ. Logs warnings if comparison fails.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
if hasattr(saved_config, 'SeriesID') and hasattr(current_config, 'SeriesID'):
|
|
59
|
+
if saved_config.SeriesID != current_config.SeriesID:
|
|
60
|
+
import warnings
|
|
61
|
+
warnings.warn(
|
|
62
|
+
"Config used in estimation differs from current config. "
|
|
63
|
+
"Results may be inconsistent. Consider re-estimating.",
|
|
64
|
+
UserWarning
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
_logger.warning(f"update_nowcast: Config consistency check failed: {type(e).__name__}: {str(e)}")
|
|
68
|
+
# If comparison fails, continue anyway
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def para_const(X: np.ndarray, P: Union[DFMResult, Dict[str, Any]], lag: int) -> Dict[str, Any]:
|
|
72
|
+
"""Implement Kalman filter for news calculation with fixed parameters.
|
|
73
|
+
|
|
74
|
+
Parameters:
|
|
75
|
+
-----------
|
|
76
|
+
X : np.ndarray
|
|
77
|
+
Data matrix (T x N)
|
|
78
|
+
P : DFMResult
|
|
79
|
+
DFM parameters
|
|
80
|
+
lag : int
|
|
81
|
+
Number of lags
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
--------
|
|
85
|
+
Dict with keys: Plag, P, X_sm, F
|
|
86
|
+
"""
|
|
87
|
+
# Set model parameters
|
|
88
|
+
Z_0 = P.Z_0
|
|
89
|
+
V_0 = P.V_0
|
|
90
|
+
A = P.A
|
|
91
|
+
C = P.C
|
|
92
|
+
Q = P.Q
|
|
93
|
+
R = P.R
|
|
94
|
+
Mx = P.Mx
|
|
95
|
+
Wx = P.Wx
|
|
96
|
+
|
|
97
|
+
# Prepare data
|
|
98
|
+
T, _ = X.shape
|
|
99
|
+
|
|
100
|
+
# Standardize
|
|
101
|
+
Y = ((X - Mx) / Wx).T # n x T
|
|
102
|
+
|
|
103
|
+
# Apply Kalman filter and smoother
|
|
104
|
+
Sf = skf(Y, A, C, Q, R, Z_0, V_0)
|
|
105
|
+
Ss = fis(A, Sf)
|
|
106
|
+
|
|
107
|
+
# Calculate parameter output
|
|
108
|
+
Vs = Ss.VmT[:, :, 1:] # Smoothed factor covariance
|
|
109
|
+
Vf = Sf.VmU[:, :, 1:] # Filtered factor posterior covariance
|
|
110
|
+
Zsmooth = Ss.ZmT
|
|
111
|
+
Vsmooth = Ss.VmT
|
|
112
|
+
|
|
113
|
+
Plag = [Vs]
|
|
114
|
+
|
|
115
|
+
# Calculate lagged covariance matrices
|
|
116
|
+
# MATLAB loops backwards: for jt = size(Plag{1},3):-1:lag+1
|
|
117
|
+
for jk in range(1, lag + 1):
|
|
118
|
+
Plag_jk = np.zeros_like(Plag[0])
|
|
119
|
+
# Loop backwards from last time index down to lag+1 (MATLAB: size(Plag{1},3):-1:lag+1)
|
|
120
|
+
# Note: Python uses 0-based indexing, so range(N-1, lag, -1) gives N-1 down to lag+1
|
|
121
|
+
for jt in range(Plag[0].shape[2] - 1, lag, -1): # Backward iteration to match MATLAB
|
|
122
|
+
As = Vf[:, :, jt - jk] @ A.T @ pinv(A @ Vf[:, :, jt - jk] @ A.T + Q)
|
|
123
|
+
Plag_jk[:, :, jt] = As @ Plag[jk - 1][:, :, jt]
|
|
124
|
+
Plag.append(Plag_jk)
|
|
125
|
+
|
|
126
|
+
# Prepare data for output
|
|
127
|
+
Zsmooth = Zsmooth.T # T x m
|
|
128
|
+
x_sm = Zsmooth[1:, :] @ C.T # T x N
|
|
129
|
+
X_sm = Wx * x_sm + Mx # Unstandardized
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
'Plag': Plag,
|
|
133
|
+
'P': Vsmooth,
|
|
134
|
+
'X_sm': X_sm,
|
|
135
|
+
'F': Zsmooth[1:, :]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def news_dfm(X_old: np.ndarray, X_new: np.ndarray, Res: Union[DFMResult, Dict[str, Any]],
|
|
140
|
+
t_fcst: int, v_news: Union[int, np.ndarray, List[int]]) -> Tuple[
|
|
141
|
+
Union[float, np.ndarray], Union[float, np.ndarray], np.ndarray, np.ndarray,
|
|
142
|
+
np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
143
|
+
"""Calculate changes in news from data releases.
|
|
144
|
+
|
|
145
|
+
This function implements the news decomposition algorithm for dynamic factor models.
|
|
146
|
+
It computes how new data releases affect forecasts of target variables by decomposing
|
|
147
|
+
the forecast update into contributions from each new data point (news).
|
|
148
|
+
|
|
149
|
+
The algorithm:
|
|
150
|
+
1. Identifies new data points (available in X_new but not in X_old)
|
|
151
|
+
2. Runs Kalman filter/smoother on both old and new datasets
|
|
152
|
+
3. Computes forecast updates (y_new - y_old) for target variables
|
|
153
|
+
4. Decomposes the update into individual news contributions from each new data point
|
|
154
|
+
|
|
155
|
+
Parameters:
|
|
156
|
+
-----------
|
|
157
|
+
X_old : np.ndarray
|
|
158
|
+
Old data matrix (T x N), where T is time periods and N is number of series.
|
|
159
|
+
Contains data available before the new release.
|
|
160
|
+
X_new : np.ndarray
|
|
161
|
+
New data matrix (T x N), same dimensions as X_old.
|
|
162
|
+
Contains data after the new release. Must have same number of series as X_old.
|
|
163
|
+
Res : Union[DFMResult, Dict[str, Any]]
|
|
164
|
+
DFM estimation results. Can be:
|
|
165
|
+
- DFMResult: Direct result object from dfm() function
|
|
166
|
+
- Dict: Dictionary containing 'Res' key with DFMResult, and optionally 'Config' or 'Spec'
|
|
167
|
+
Must have attributes: C, Mx, Wx, X_sm (or equivalent keys if dict)
|
|
168
|
+
t_fcst : int
|
|
169
|
+
Target time index (0-based) for the forecast period.
|
|
170
|
+
Must satisfy 0 <= t_fcst < T (number of time periods in X_new).
|
|
171
|
+
v_news : Union[int, np.ndarray, List[int]]
|
|
172
|
+
Target variable index(s) (0-based). Specifies which series to forecast.
|
|
173
|
+
Can be:
|
|
174
|
+
- int: Single target variable (backward compatible)
|
|
175
|
+
- np.ndarray or List[int]: Multiple target variables (MATLAB-compatible)
|
|
176
|
+
All indices must be in range [0, N) where N is number of series.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
--------
|
|
180
|
+
Tuple containing 9 elements:
|
|
181
|
+
|
|
182
|
+
y_old : Union[float, np.ndarray]
|
|
183
|
+
Forecast of target variable(s) using old data.
|
|
184
|
+
- If v_news is scalar: float
|
|
185
|
+
- If v_news is array: (n_targets,) array
|
|
186
|
+
y_new : Union[float, np.ndarray]
|
|
187
|
+
Forecast of target variable(s) using new data.
|
|
188
|
+
- If v_news is scalar: float
|
|
189
|
+
- If v_news is array: (n_targets,) array
|
|
190
|
+
singlenews : np.ndarray
|
|
191
|
+
Individual news contributions from each new data point.
|
|
192
|
+
- If v_news is scalar: (N,) array, where singlenews[i] is contribution from series i
|
|
193
|
+
- If v_news is array: (N, n_targets) array, where each column is news for one target
|
|
194
|
+
actual : np.ndarray
|
|
195
|
+
Actual values of new data points (N,). NaN for series with no new data.
|
|
196
|
+
forecast : np.ndarray
|
|
197
|
+
Forecasted values (before new data) for new data points (N,). NaN for series with no new data.
|
|
198
|
+
weight : np.ndarray
|
|
199
|
+
Weights for news contributions (N,). NaN for series with no new data.
|
|
200
|
+
- If v_news is scalar: (N,) array
|
|
201
|
+
- If v_news is array: (N, n_targets) array
|
|
202
|
+
t_miss : np.ndarray
|
|
203
|
+
Time indices where new data is available (n_news,). Integer array.
|
|
204
|
+
v_miss : np.ndarray
|
|
205
|
+
Variable indices where new data is available (n_news,). Integer array.
|
|
206
|
+
innov : np.ndarray
|
|
207
|
+
Innovation terms (n_news,). Forecast errors for new data points.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
------
|
|
211
|
+
ValueError
|
|
212
|
+
If inputs are invalid:
|
|
213
|
+
- X_old or X_new are not 2D numpy arrays
|
|
214
|
+
- X_old and X_new have different number of series
|
|
215
|
+
- t_fcst is out of bounds
|
|
216
|
+
- v_news indices are out of range
|
|
217
|
+
- Res does not have required attributes (C, Mx, Wx, X_sm)
|
|
218
|
+
|
|
219
|
+
Notes:
|
|
220
|
+
-----
|
|
221
|
+
- Now supports both single and multiple target variables, matching MATLAB functionality.
|
|
222
|
+
- The function handles two cases:
|
|
223
|
+
1. NO FORECAST CASE: Target variable(s) already observed at t_fcst in X_new
|
|
224
|
+
2. FORECAST CASE: Target variable(s) not yet observed, requires forecast update
|
|
225
|
+
- News decomposition follows the methodology in Banbura et al. (2015).
|
|
226
|
+
- If matrix inversion fails during gain calculation, fallback values are used and logged.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
--------
|
|
230
|
+
>>> from dfm_python import dfm, news_dfm
|
|
231
|
+
>>> # Estimate DFM model
|
|
232
|
+
>>> Res = dfm(X, config)
|
|
233
|
+
>>> # Calculate news for single target variable
|
|
234
|
+
>>> y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov = \\
|
|
235
|
+
... news_dfm(X_old, X_new, Res, t_fcst=100, v_news=0)
|
|
236
|
+
>>> # Calculate news for multiple target variables
|
|
237
|
+
>>> targets = [0, 1, 2]
|
|
238
|
+
>>> y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov = \\
|
|
239
|
+
... news_dfm(X_old, X_new, Res, t_fcst=100, v_news=targets)
|
|
240
|
+
"""
|
|
241
|
+
# Input validation
|
|
242
|
+
if not isinstance(X_old, np.ndarray) or X_old.ndim != 2:
|
|
243
|
+
raise ValueError(f"news_dfm: X_old must be a 2D numpy array, got {type(X_old)} with shape {getattr(X_old, 'shape', 'N/A')}")
|
|
244
|
+
if not isinstance(X_new, np.ndarray) or X_new.ndim != 2:
|
|
245
|
+
raise ValueError(f"news_dfm: X_new must be a 2D numpy array, got {type(X_new)} with shape {getattr(X_new, 'shape', 'N/A')}")
|
|
246
|
+
if X_old.shape[1] != X_new.shape[1]:
|
|
247
|
+
raise ValueError(f"news_dfm: X_old and X_new must have same number of series. Got {X_old.shape[1]} and {X_new.shape[1]}")
|
|
248
|
+
if not isinstance(t_fcst, (int, np.integer)) or t_fcst < 0:
|
|
249
|
+
raise ValueError(f"news_dfm: t_fcst must be a non-negative integer, got {t_fcst}")
|
|
250
|
+
if t_fcst >= X_new.shape[0]:
|
|
251
|
+
raise ValueError(f"news_dfm: t_fcst ({t_fcst}) must be less than number of time periods ({X_new.shape[0]})")
|
|
252
|
+
|
|
253
|
+
# Validate Res structure
|
|
254
|
+
if isinstance(Res, dict):
|
|
255
|
+
if 'Res' not in Res:
|
|
256
|
+
raise ValueError("news_dfm: Res dict must contain 'Res' key")
|
|
257
|
+
Res_actual = Res['Res']
|
|
258
|
+
else:
|
|
259
|
+
Res_actual = Res
|
|
260
|
+
|
|
261
|
+
# Check required attributes
|
|
262
|
+
required_attrs = ['C', 'Mx', 'Wx', 'X_sm']
|
|
263
|
+
for attr in required_attrs:
|
|
264
|
+
if not hasattr(Res_actual, attr):
|
|
265
|
+
raise ValueError(f"news_dfm: Res must have '{attr}' attribute. Got {type(Res_actual)}")
|
|
266
|
+
|
|
267
|
+
# Normalize v_news to array for consistent handling
|
|
268
|
+
v_news_arr = np.atleast_1d(v_news)
|
|
269
|
+
is_scalar = isinstance(v_news, (int, np.integer)) or (isinstance(v_news, np.ndarray) and v_news.ndim == 0)
|
|
270
|
+
n_targets = len(v_news_arr)
|
|
271
|
+
|
|
272
|
+
# Validate v_news indices
|
|
273
|
+
if np.any(v_news_arr < 0) or np.any(v_news_arr >= X_new.shape[1]):
|
|
274
|
+
raise ValueError(f"news_dfm: v_news indices must be in range [0, {X_new.shape[1]}), got {v_news_arr}")
|
|
275
|
+
|
|
276
|
+
r = Res_actual.C.shape[1]
|
|
277
|
+
_, N = X_new.shape
|
|
278
|
+
|
|
279
|
+
# Check if targets are already observed
|
|
280
|
+
targets_observed = np.array([not np.isnan(X_new[t_fcst, v]) for v in v_news_arr])
|
|
281
|
+
|
|
282
|
+
if np.all(targets_observed):
|
|
283
|
+
# NO FORECAST CASE: Already values for all target variables at time t_fcst
|
|
284
|
+
Res_old = para_const(X_old, Res_actual, 0)
|
|
285
|
+
|
|
286
|
+
# Initialize output arrays
|
|
287
|
+
if is_scalar:
|
|
288
|
+
singlenews = np.zeros(N)
|
|
289
|
+
singlenews[v_news_arr[0]] = X_new[t_fcst, v_news_arr[0]] - Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
290
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
291
|
+
y_new = X_new[t_fcst, v_news_arr[0]]
|
|
292
|
+
else:
|
|
293
|
+
singlenews = np.zeros((N, n_targets))
|
|
294
|
+
for i, v in enumerate(v_news_arr):
|
|
295
|
+
singlenews[v, i] = X_new[t_fcst, v] - Res_old['X_sm'][t_fcst, v]
|
|
296
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
297
|
+
y_new = np.array([X_new[t_fcst, v] for v in v_news_arr])
|
|
298
|
+
|
|
299
|
+
actual = np.array([])
|
|
300
|
+
forecast = np.array([])
|
|
301
|
+
weight = np.array([])
|
|
302
|
+
t_miss = np.array([])
|
|
303
|
+
v_miss = np.array([])
|
|
304
|
+
innov = np.array([])
|
|
305
|
+
else:
|
|
306
|
+
# FORECAST CASE
|
|
307
|
+
Mx = Res_actual.Mx
|
|
308
|
+
Wx = Res_actual.Wx
|
|
309
|
+
|
|
310
|
+
# Calculate indicators for missing values
|
|
311
|
+
miss_old = np.isnan(X_old)
|
|
312
|
+
miss_new = np.isnan(X_new)
|
|
313
|
+
|
|
314
|
+
# Indicator for missing: 1 = new data available, -1 = old data available but not new
|
|
315
|
+
i_miss = miss_old.astype(int) - miss_new.astype(int)
|
|
316
|
+
|
|
317
|
+
# Time/variable indices where new data is available
|
|
318
|
+
t_miss, v_miss = np.where(i_miss == 1)
|
|
319
|
+
t_miss = t_miss.flatten()
|
|
320
|
+
v_miss = v_miss.flatten()
|
|
321
|
+
|
|
322
|
+
if len(v_miss) == 0:
|
|
323
|
+
# NO NEW INFORMATION
|
|
324
|
+
Res_old = para_const(X_old, Res_actual, 0)
|
|
325
|
+
Res_new = para_const(X_new, Res_actual, 0)
|
|
326
|
+
|
|
327
|
+
if is_scalar:
|
|
328
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
329
|
+
y_new = y_old
|
|
330
|
+
singlenews = np.array([])
|
|
331
|
+
else:
|
|
332
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
333
|
+
y_new = y_old
|
|
334
|
+
singlenews = np.array([]).reshape(0, n_targets)
|
|
335
|
+
|
|
336
|
+
actual = np.array([])
|
|
337
|
+
forecast = np.array([])
|
|
338
|
+
weight = np.array([])
|
|
339
|
+
t_miss = np.array([])
|
|
340
|
+
v_miss = np.array([])
|
|
341
|
+
innov = np.array([])
|
|
342
|
+
else:
|
|
343
|
+
# NEW INFORMATION
|
|
344
|
+
# Difference between forecast time and new data time
|
|
345
|
+
lag = t_fcst - t_miss
|
|
346
|
+
|
|
347
|
+
# Biggest time interval
|
|
348
|
+
k = max([np.max(np.abs(lag)), np.max(lag) - np.min(lag)])
|
|
349
|
+
k = int(k)
|
|
350
|
+
|
|
351
|
+
C = Res_actual.C
|
|
352
|
+
R_cov = Res_actual.R.T # Note: R in Res might need transposition
|
|
353
|
+
|
|
354
|
+
n_news = len(lag)
|
|
355
|
+
|
|
356
|
+
# Smooth old dataset
|
|
357
|
+
Res_old = para_const(X_old, Res_actual, k)
|
|
358
|
+
Plag = Res_old['Plag']
|
|
359
|
+
|
|
360
|
+
# Smooth new dataset
|
|
361
|
+
Res_new = para_const(X_new, Res_actual, 0)
|
|
362
|
+
|
|
363
|
+
# Get nowcasts for all target variables
|
|
364
|
+
if is_scalar:
|
|
365
|
+
y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
|
|
366
|
+
y_new = Res_new['X_sm'][t_fcst, v_news_arr[0]]
|
|
367
|
+
else:
|
|
368
|
+
y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
369
|
+
y_new = np.array([Res_new['X_sm'][t_fcst, v] for v in v_news_arr])
|
|
370
|
+
|
|
371
|
+
# Calculate projection matrices (simplified)
|
|
372
|
+
P1 = []
|
|
373
|
+
for i in range(n_news):
|
|
374
|
+
h = abs(t_fcst - t_miss[i])
|
|
375
|
+
m = max(t_miss[i], t_fcst)
|
|
376
|
+
|
|
377
|
+
if t_miss[i] > t_fcst:
|
|
378
|
+
Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
|
|
379
|
+
else:
|
|
380
|
+
Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
|
|
381
|
+
|
|
382
|
+
P1.append(Pp @ C[v_miss[i], :r].T)
|
|
383
|
+
|
|
384
|
+
P1 = np.hstack(P1) if len(P1) > 0 else np.zeros((r, 1))
|
|
385
|
+
|
|
386
|
+
# Calculate innovations
|
|
387
|
+
innov = np.zeros(n_news)
|
|
388
|
+
for i in range(n_news):
|
|
389
|
+
X_new_norm = (X_new[t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
|
|
390
|
+
X_sm_norm = (Res_old['X_sm'][t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
|
|
391
|
+
innov[i] = X_new_norm - X_sm_norm
|
|
392
|
+
|
|
393
|
+
# Calculate P2 (covariance of innovations) - simplified
|
|
394
|
+
P2 = np.zeros((n_news, n_news))
|
|
395
|
+
for i in range(n_news):
|
|
396
|
+
for j in range(n_news):
|
|
397
|
+
h = abs(lag[i] - lag[j])
|
|
398
|
+
m = max(t_miss[i], t_miss[j])
|
|
399
|
+
|
|
400
|
+
if t_miss[j] > t_miss[i]:
|
|
401
|
+
Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
|
|
402
|
+
else:
|
|
403
|
+
Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
|
|
404
|
+
|
|
405
|
+
if v_miss[i] == v_miss[j] and t_miss[i] != t_miss[j]:
|
|
406
|
+
WW = 0
|
|
407
|
+
else:
|
|
408
|
+
WW = R_cov[v_miss[i], v_miss[j]]
|
|
409
|
+
|
|
410
|
+
P2[i, j] = C[v_miss[i], :r] @ Pp @ C[v_miss[j], :r].T + WW
|
|
411
|
+
|
|
412
|
+
# Calculate weights and news for each target variable
|
|
413
|
+
if n_news > 0 and P2.size > 0:
|
|
414
|
+
try:
|
|
415
|
+
P2_inv = inv(P2)
|
|
416
|
+
# Calculate gain for each target variable
|
|
417
|
+
if is_scalar:
|
|
418
|
+
v_idx = v_news_arr[0]
|
|
419
|
+
gain = (Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv).reshape(-1)
|
|
420
|
+
totnews = Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv @ innov
|
|
421
|
+
else:
|
|
422
|
+
gain = np.zeros((n_targets, n_news))
|
|
423
|
+
totnews = np.zeros(n_targets)
|
|
424
|
+
for idx, v in enumerate(v_news_arr):
|
|
425
|
+
gain[idx, :] = Wx[v] * C[v, :r] @ P1 @ P2_inv
|
|
426
|
+
totnews[idx] = Wx[v] * C[v, :r] @ P1 @ P2_inv @ innov
|
|
427
|
+
except (np.linalg.LinAlgError, ValueError) as e:
|
|
428
|
+
# If inversion fails, use simpler approach
|
|
429
|
+
_logger.warning(
|
|
430
|
+
f"news_dfm: Matrix inversion failed for P2, using fallback values. "
|
|
431
|
+
f"Error: {type(e).__name__}: {str(e)}"
|
|
432
|
+
)
|
|
433
|
+
if is_scalar:
|
|
434
|
+
gain = np.ones(n_news) * 0.1
|
|
435
|
+
totnews = np.sum(innov) * 0.1
|
|
436
|
+
else:
|
|
437
|
+
gain = np.ones((n_targets, n_news)) * 0.1
|
|
438
|
+
totnews = np.ones(n_targets) * np.sum(innov) * 0.1
|
|
439
|
+
else:
|
|
440
|
+
if is_scalar:
|
|
441
|
+
gain = np.zeros(n_news)
|
|
442
|
+
totnews = 0
|
|
443
|
+
else:
|
|
444
|
+
gain = np.zeros((n_targets, n_news))
|
|
445
|
+
totnews = np.zeros(n_targets)
|
|
446
|
+
|
|
447
|
+
# Organize output
|
|
448
|
+
if is_scalar:
|
|
449
|
+
singlenews = np.full(N, np.nan)
|
|
450
|
+
actual = np.full(N, np.nan)
|
|
451
|
+
forecast = np.full(N, np.nan)
|
|
452
|
+
weight = np.full(N, np.nan)
|
|
453
|
+
|
|
454
|
+
for i in range(n_news):
|
|
455
|
+
actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
|
|
456
|
+
forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
|
|
457
|
+
if i < len(gain):
|
|
458
|
+
singlenews[v_miss[i]] = gain[i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
459
|
+
weight[v_miss[i]] = gain[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
460
|
+
else:
|
|
461
|
+
# Multiple targets: singlenews is (N, n_targets)
|
|
462
|
+
singlenews = np.full((N, n_targets), np.nan)
|
|
463
|
+
actual = np.full(N, np.nan)
|
|
464
|
+
forecast = np.full(N, np.nan)
|
|
465
|
+
weight = np.full((N, n_targets), np.nan)
|
|
466
|
+
|
|
467
|
+
for i in range(n_news):
|
|
468
|
+
actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
|
|
469
|
+
forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
|
|
470
|
+
for idx in range(n_targets):
|
|
471
|
+
if i < len(gain[idx]):
|
|
472
|
+
singlenews[v_miss[i], idx] = gain[idx, i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
473
|
+
weight[v_miss[i], idx] = gain[idx, i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
|
|
474
|
+
|
|
475
|
+
# Remove duplicates from v_miss
|
|
476
|
+
v_miss = np.unique(v_miss)
|
|
477
|
+
|
|
478
|
+
return y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# Database saving is application-specific and should be implemented in adapters.
|
|
482
|
+
# This keeps the DFM module generic and database-agnostic.
|
|
483
|
+
def update_nowcast(X_old: np.ndarray, X_new: np.ndarray, Time: pd.DatetimeIndex,
|
|
484
|
+
config: DFMConfig, Res: Union[DFMResult, Dict[str, Any]],
|
|
485
|
+
series: str, period: str,
|
|
486
|
+
vintage_old: str, vintage_new: str,
|
|
487
|
+
model_id: Optional[int] = None,
|
|
488
|
+
save_callback: Optional[Callable] = None) -> None:
|
|
489
|
+
"""Update nowcast and decompose changes into news.
|
|
490
|
+
|
|
491
|
+
Parameters:
|
|
492
|
+
-----------
|
|
493
|
+
X_old : np.ndarray
|
|
494
|
+
Old vintage data (T_old x N)
|
|
495
|
+
X_new : np.ndarray
|
|
496
|
+
New vintage data (T_new x N)
|
|
497
|
+
Time : pd.DatetimeIndex
|
|
498
|
+
Time index
|
|
499
|
+
config : DFMConfig
|
|
500
|
+
Model configuration
|
|
501
|
+
Res : DFMResult or dict
|
|
502
|
+
DFM estimation results. If dict, must contain 'Res' and optionally 'Spec'.
|
|
503
|
+
If DFMResult and has .Spec attribute, it will be checked for consistency.
|
|
504
|
+
series : str
|
|
505
|
+
Target series ID
|
|
506
|
+
period : str
|
|
507
|
+
Target period (e.g., '2016q4')
|
|
508
|
+
vintage_old : str
|
|
509
|
+
Old vintage date
|
|
510
|
+
vintage_new : str
|
|
511
|
+
New vintage date
|
|
512
|
+
model_id : int, optional
|
|
513
|
+
Model ID for saving (passed to save_callback if provided)
|
|
514
|
+
save_callback : Callable, optional
|
|
515
|
+
Optional callback function to save nowcast results.
|
|
516
|
+
If provided, will be called with nowcast parameters.
|
|
517
|
+
Example: Implement save_callback in your application adapter
|
|
518
|
+
save_callback=lambda **kwargs: save_nowcast_to_db(**kwargs)
|
|
519
|
+
|
|
520
|
+
Notes:
|
|
521
|
+
------
|
|
522
|
+
If Res contains a saved Config and it differs from the current config, a warning
|
|
523
|
+
is issued. This matches MATLAB behavior where Res.Config is checked for consistency.
|
|
524
|
+
|
|
525
|
+
Database saving is robust with error handling - failures will log warnings
|
|
526
|
+
but not interrupt the nowcast computation.
|
|
527
|
+
"""
|
|
528
|
+
# Input validation
|
|
529
|
+
if not isinstance(X_old, np.ndarray) or X_old.ndim != 2:
|
|
530
|
+
raise ValueError(f"update_nowcast: X_old must be a 2D numpy array, got {type(X_old)}")
|
|
531
|
+
if not isinstance(X_new, np.ndarray) or X_new.ndim != 2:
|
|
532
|
+
raise ValueError(f"update_nowcast: X_new must be a 2D numpy array, got {type(X_new)}")
|
|
533
|
+
if X_old.shape[1] != X_new.shape[1]:
|
|
534
|
+
raise ValueError(f"update_nowcast: X_old and X_new must have same number of series. Got {X_old.shape[1]} and {X_new.shape[1]}")
|
|
535
|
+
if not isinstance(series, str):
|
|
536
|
+
raise ValueError(f"update_nowcast: series must be a string, got {type(series)}")
|
|
537
|
+
if not isinstance(period, str):
|
|
538
|
+
raise ValueError(f"update_nowcast: period must be a string, got {type(period)}")
|
|
539
|
+
if not hasattr(config, 'SeriesID'):
|
|
540
|
+
raise ValueError(f"update_nowcast: config must have 'SeriesID' attribute")
|
|
541
|
+
if series not in config.SeriesID:
|
|
542
|
+
raise ValueError(f"update_nowcast: series '{series}' not found in config.SeriesID")
|
|
543
|
+
|
|
544
|
+
# Validate period format
|
|
545
|
+
try:
|
|
546
|
+
i_series = config.SeriesID.index(series)
|
|
547
|
+
freq = config.Frequency[i_series]
|
|
548
|
+
if freq == 'm':
|
|
549
|
+
# Format: YYYYmMM (e.g., "2016m12")
|
|
550
|
+
parts = period.split('m')
|
|
551
|
+
if len(parts) != 2:
|
|
552
|
+
raise ValueError(f"update_nowcast: period '{period}' must be in format 'YYYYmMM' for monthly frequency")
|
|
553
|
+
year, month = int(parts[0]), int(parts[1])
|
|
554
|
+
if not (1 <= month <= 12):
|
|
555
|
+
raise ValueError(f"update_nowcast: month must be between 1 and 12, got {month}")
|
|
556
|
+
elif freq == 'q':
|
|
557
|
+
# Format: YYYYqQ (e.g., "2016q4")
|
|
558
|
+
parts = period.split('q')
|
|
559
|
+
if len(parts) != 2:
|
|
560
|
+
raise ValueError(f"update_nowcast: period '{period}' must be in format 'YYYYqQ' for quarterly frequency")
|
|
561
|
+
year, q = int(parts[0]), int(parts[1])
|
|
562
|
+
if not (1 <= q <= 4):
|
|
563
|
+
raise ValueError(f"update_nowcast: quarter must be between 1 and 4, got {q}")
|
|
564
|
+
else:
|
|
565
|
+
raise ValueError(f"update_nowcast: Unsupported frequency '{freq}' for period validation")
|
|
566
|
+
except (ValueError, IndexError) as e:
|
|
567
|
+
if isinstance(e, ValueError) and "not found" not in str(e):
|
|
568
|
+
raise ValueError(f"update_nowcast: Invalid period format '{period}': {str(e)}")
|
|
569
|
+
raise
|
|
570
|
+
|
|
571
|
+
# Handle dict format (from saved results) - extract Res and check config consistency
|
|
572
|
+
if isinstance(Res, dict):
|
|
573
|
+
if 'Spec' in Res and 'Res' in Res:
|
|
574
|
+
# Legacy: Spec key for backward compatibility
|
|
575
|
+
saved_config = Res['Spec']
|
|
576
|
+
Res = Res['Res']
|
|
577
|
+
# Simple consistency check: compare SeriesID
|
|
578
|
+
_check_config_consistency(saved_config, config)
|
|
579
|
+
elif 'Config' in Res and 'Res' in Res:
|
|
580
|
+
# New format with Config key
|
|
581
|
+
saved_config = Res['Config']
|
|
582
|
+
Res = Res['Res']
|
|
583
|
+
_check_config_consistency(saved_config, config)
|
|
584
|
+
elif 'Res' in Res:
|
|
585
|
+
Res = Res['Res']
|
|
586
|
+
|
|
587
|
+
# Also check if DFMResult has a Config attribute (for future compatibility)
|
|
588
|
+
if hasattr(Res, 'Config') and hasattr(config, 'SeriesID'):
|
|
589
|
+
_check_config_consistency(Res.Config, config)
|
|
590
|
+
|
|
591
|
+
# Make sure datasets are same size
|
|
592
|
+
N = X_new.shape[1]
|
|
593
|
+
T_old = X_old.shape[0]
|
|
594
|
+
T_new = X_new.shape[0]
|
|
595
|
+
|
|
596
|
+
if T_new > T_old:
|
|
597
|
+
X_old = np.vstack([X_old, np.full((T_new - T_old, N), np.nan)])
|
|
598
|
+
|
|
599
|
+
# Append 1 year (12 months) for forecasting horizon
|
|
600
|
+
X_old = np.vstack([X_old, np.full((FORECAST_HORIZON_MONTHS, N), np.nan)])
|
|
601
|
+
X_new = np.vstack([X_new, np.full((FORECAST_HORIZON_MONTHS, N), np.nan)])
|
|
602
|
+
|
|
603
|
+
# Extend time index
|
|
604
|
+
last_date = Time.iloc[-1] if hasattr(Time, 'iloc') else Time[-1]
|
|
605
|
+
if hasattr(last_date, 'month'):
|
|
606
|
+
new_dates = pd.date_range(
|
|
607
|
+
start=last_date + pd.DateOffset(months=1),
|
|
608
|
+
periods=FORECAST_HORIZON_MONTHS,
|
|
609
|
+
freq='MS'
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
# Fallback if not pandas datetime
|
|
613
|
+
new_dates = pd.date_range(start=DEFAULT_FALLBACK_DATE, periods=FORECAST_HORIZON_MONTHS, freq='MS')
|
|
614
|
+
|
|
615
|
+
# Handle both Index and Series types
|
|
616
|
+
if isinstance(Time, pd.Series):
|
|
617
|
+
Time = pd.concat([Time, pd.Series(new_dates)])
|
|
618
|
+
elif hasattr(Time, 'append'):
|
|
619
|
+
try:
|
|
620
|
+
Time = Time.append(pd.Index(new_dates))
|
|
621
|
+
except AttributeError:
|
|
622
|
+
Time = pd.concat([Time, pd.Index(new_dates)])
|
|
623
|
+
else:
|
|
624
|
+
Time = pd.concat([Time, pd.Index(new_dates)])
|
|
625
|
+
|
|
626
|
+
# Find series index
|
|
627
|
+
try:
|
|
628
|
+
i_series = config.SeriesID.index(series)
|
|
629
|
+
except ValueError:
|
|
630
|
+
raise ValueError(f"Series {series} not found in configuration")
|
|
631
|
+
|
|
632
|
+
series_name = config.SeriesName[i_series]
|
|
633
|
+
freq = config.Frequency[i_series]
|
|
634
|
+
|
|
635
|
+
# Parse period
|
|
636
|
+
if freq == 'm':
|
|
637
|
+
year_str, month_str = period.split('m')
|
|
638
|
+
year = int(year_str)
|
|
639
|
+
month = int(month_str)
|
|
640
|
+
target_date = pd.Timestamp(year, month, 1)
|
|
641
|
+
elif freq == 'q':
|
|
642
|
+
year_str, q_str = period.split('q')
|
|
643
|
+
year = int(year_str)
|
|
644
|
+
q = int(q_str)
|
|
645
|
+
month = 3 * q
|
|
646
|
+
target_date = pd.Timestamp(year, month, 1)
|
|
647
|
+
else:
|
|
648
|
+
raise ValueError(f"Unsupported frequency: {freq}")
|
|
649
|
+
|
|
650
|
+
# Find time index
|
|
651
|
+
t_nowcast = None
|
|
652
|
+
for i, t in enumerate(Time):
|
|
653
|
+
if hasattr(t, 'year') and hasattr(t, 'month'):
|
|
654
|
+
if t.year == target_date.year and t.month == target_date.month:
|
|
655
|
+
t_nowcast = i
|
|
656
|
+
break
|
|
657
|
+
|
|
658
|
+
if t_nowcast is None:
|
|
659
|
+
raise ValueError('Period is out of nowcasting horizon (up to one year ahead).')
|
|
660
|
+
|
|
661
|
+
# Separate revisions from new releases
|
|
662
|
+
X_rev = X_new.copy()
|
|
663
|
+
X_rev[np.isnan(X_old)] = np.nan
|
|
664
|
+
|
|
665
|
+
# Compute news
|
|
666
|
+
# Impact from data revisions
|
|
667
|
+
y_old = news_dfm(X_old, X_rev, Res, t_nowcast, i_series)[0]
|
|
668
|
+
|
|
669
|
+
# Impact from data releases
|
|
670
|
+
y_rev, y_new, _, actual, forecast, weight = news_dfm(X_rev, X_new, Res, t_nowcast, i_series)[:6]
|
|
671
|
+
|
|
672
|
+
# Display output
|
|
673
|
+
print('\n\n\n')
|
|
674
|
+
print(f'Nowcast Update: {pd.to_datetime(vintage_new).strftime("%B %d, %Y")}')
|
|
675
|
+
|
|
676
|
+
if freq == 'q':
|
|
677
|
+
period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y:Q%q')
|
|
678
|
+
else:
|
|
679
|
+
period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y-%m')
|
|
680
|
+
|
|
681
|
+
print(f'Nowcast for {series_name} ({config.UnitsTransformed[i_series]}), {period_str}')
|
|
682
|
+
|
|
683
|
+
if len(forecast) == 0 or np.all(np.isnan(forecast)):
|
|
684
|
+
print('\n No forecast was made.\n')
|
|
685
|
+
else:
|
|
686
|
+
impact_revisions = y_rev - y_old
|
|
687
|
+
news = actual - forecast
|
|
688
|
+
impact_releases = weight * news
|
|
689
|
+
|
|
690
|
+
# Create summary table
|
|
691
|
+
data_released = np.any(np.isnan(X_old) & ~np.isnan(X_new), axis=0)
|
|
692
|
+
|
|
693
|
+
print('\n Nowcast Impact Decomposition')
|
|
694
|
+
print(' Note: The displayed output is subject to rounding error\n')
|
|
695
|
+
print(f' {pd.to_datetime(vintage_old).strftime("%b %d")} nowcast: {y_old:5.2f}')
|
|
696
|
+
print(f' Impact from data revisions: {impact_revisions:5.2f}')
|
|
697
|
+
print(f' Impact from data releases: {np.nansum(impact_releases):5.2f}')
|
|
698
|
+
print(' +_________')
|
|
699
|
+
print(f' Total impact: {impact_revisions + np.nansum(impact_releases):5.2f}')
|
|
700
|
+
print(f' {pd.to_datetime(vintage_new).strftime("%b %d")} nowcast: {y_new:5.2f}\n')
|
|
701
|
+
|
|
702
|
+
# Display table for series with updates
|
|
703
|
+
print('\n Nowcast Detail Table \n')
|
|
704
|
+
for i in range(N):
|
|
705
|
+
if data_released[i] and not np.isnan(forecast[i]):
|
|
706
|
+
series_id = config.SeriesID[i]
|
|
707
|
+
print(f'{series_id:20s} Forecast: {forecast[i]:8.2f} Actual: {actual[i]:8.2f} '
|
|
708
|
+
f'Weight: {weight[i]:8.4f} Impact: {impact_releases[i]:8.2f}')
|
|
709
|
+
|
|
710
|
+
# Save nowcast using callback if provided (generic - not database-specific)
|
|
711
|
+
if save_callback is not None:
|
|
712
|
+
try:
|
|
713
|
+
save_callback(
|
|
714
|
+
model_id=model_id,
|
|
715
|
+
series=series,
|
|
716
|
+
forecast_date=target_date,
|
|
717
|
+
forecast_value=y_new,
|
|
718
|
+
old_forecast_value=y_old,
|
|
719
|
+
impact_revisions=impact_revisions,
|
|
720
|
+
impact_releases=np.nansum(impact_releases),
|
|
721
|
+
total_impact=impact_revisions + np.nansum(impact_releases),
|
|
722
|
+
vintage_old=vintage_old,
|
|
723
|
+
vintage_new=vintage_new,
|
|
724
|
+
Res=Res
|
|
725
|
+
)
|
|
726
|
+
except Exception as e:
|
|
727
|
+
import warnings
|
|
728
|
+
warnings.warn(
|
|
729
|
+
f"save_callback failed: {e}. Continuing without saving.",
|
|
730
|
+
UserWarning
|
|
731
|
+
)
|
|
732
|
+
|