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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Utility functions for data preprocessing and summary statistics."""
|
|
2
|
+
|
|
3
|
+
from .data_utils import rem_nans_spline, summarize
|
|
4
|
+
from .aggregation import (
|
|
5
|
+
generate_tent_weights,
|
|
6
|
+
generate_R_mat,
|
|
7
|
+
get_tent_weights_for_pair,
|
|
8
|
+
get_aggregation_structure,
|
|
9
|
+
FREQUENCY_HIERARCHY,
|
|
10
|
+
TENT_WEIGHTS_LOOKUP,
|
|
11
|
+
MAX_TENT_SIZE,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'rem_nans_spline',
|
|
16
|
+
'summarize',
|
|
17
|
+
'generate_tent_weights',
|
|
18
|
+
'generate_R_mat',
|
|
19
|
+
'get_tent_weights_for_pair',
|
|
20
|
+
'get_aggregation_structure',
|
|
21
|
+
'FREQUENCY_HIERARCHY',
|
|
22
|
+
'TENT_WEIGHTS_LOOKUP',
|
|
23
|
+
'MAX_TENT_SIZE',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Note: Direct import of data_utils module available for backward compatibility
|
|
27
|
+
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Aggregation utilities for mixed-frequency data in Dynamic Factor Models.
|
|
2
|
+
|
|
3
|
+
This module implements the clock-based aggregation framework for handling mixed-frequency
|
|
4
|
+
time series in Dynamic Factor Models. The key innovation is the use of deterministic
|
|
5
|
+
tent kernels to map lower-frequency observed variables to higher-frequency latent states
|
|
6
|
+
within the observation equation.
|
|
7
|
+
|
|
8
|
+
The module provides:
|
|
9
|
+
- Frequency hierarchy definitions
|
|
10
|
+
- Tent weight generation and lookup
|
|
11
|
+
- Constraint matrix (R_mat) generation from tent weights
|
|
12
|
+
- Aggregation structure computation based on global clock
|
|
13
|
+
|
|
14
|
+
The tent kernel approach follows the FRBNY methodology, where quarterly (or other
|
|
15
|
+
lower-frequency) series are connected to monthly latent factors through weighted
|
|
16
|
+
aggregation constraints. This allows all factors to evolve at the same clock frequency
|
|
17
|
+
while properly handling mixed-frequency observations.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from typing import Tuple, Optional, Dict, Any
|
|
22
|
+
from ..config import DFMConfig
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Frequency Hierarchy
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
# Frequency hierarchy (from highest to lowest frequency)
|
|
29
|
+
# Used to determine which frequencies are slower/faster than the clock
|
|
30
|
+
FREQUENCY_HIERARCHY: Dict[str, int] = {
|
|
31
|
+
'd': 1, # Daily (highest frequency)
|
|
32
|
+
'w': 2, # Weekly
|
|
33
|
+
'm': 3, # Monthly
|
|
34
|
+
'q': 4, # Quarterly
|
|
35
|
+
'sa': 5, # Semi-annual
|
|
36
|
+
'a': 6 # Annual (lowest frequency)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# Tent Kernel Configuration
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
# Maximum tent kernel size (number of periods)
|
|
44
|
+
# For frequency gaps larger than this, the missing data approach is used instead
|
|
45
|
+
# This prevents excessively large constraint matrices that would be computationally
|
|
46
|
+
# expensive and potentially numerically unstable
|
|
47
|
+
MAX_TENT_SIZE: int = 12
|
|
48
|
+
|
|
49
|
+
# Deterministic tent weights lookup for supported frequency pairs
|
|
50
|
+
# Format: (slower_freq, faster_freq) -> tent_weights_array
|
|
51
|
+
#
|
|
52
|
+
# These weights define how lower-frequency observations aggregate to higher-frequency
|
|
53
|
+
# latent states. The tent shape (symmetric, peaking in the middle) follows FRBNY's
|
|
54
|
+
# approach and ensures smooth aggregation while giving more weight to the central
|
|
55
|
+
# periods of the aggregation window.
|
|
56
|
+
#
|
|
57
|
+
# Example: ('q', 'm'): [1, 2, 3, 2, 1] means a quarterly observation aggregates
|
|
58
|
+
# 5 monthly latent states with weights 1, 2, 3, 2, 1 (peaking at the middle month).
|
|
59
|
+
TENT_WEIGHTS_LOOKUP: Dict[Tuple[str, str], np.ndarray] = {
|
|
60
|
+
('q', 'm'): np.array([1, 2, 3, 2, 1]), # 5 periods: quarterly -> monthly
|
|
61
|
+
('sa', 'm'): np.array([1, 2, 3, 4, 3, 2, 1]), # 7 periods: semi-annual -> monthly
|
|
62
|
+
('a', 'm'): np.array([1, 2, 3, 4, 5, 4, 3, 2, 1]), # 9 periods: annual -> monthly
|
|
63
|
+
('m', 'w'): np.array([1, 2, 3, 2, 1]), # 5 periods: monthly -> weekly
|
|
64
|
+
('q', 'w'): np.array([1, 2, 3, 4, 5, 4, 3, 2, 1]), # 9 periods: quarterly -> weekly
|
|
65
|
+
('sa', 'q'): np.array([1, 2, 1]), # 3 periods: semi-annual -> quarterly
|
|
66
|
+
('a', 'q'): np.array([1, 2, 3, 2, 1]), # 5 periods: annual -> quarterly
|
|
67
|
+
('a', 'sa'): np.array([1, 2, 1]), # 3 periods: annual -> semi-annual
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def generate_tent_weights(n_periods: int, tent_type: str = 'symmetric') -> np.ndarray:
|
|
72
|
+
"""Generate tent-shaped weights for aggregation.
|
|
73
|
+
|
|
74
|
+
Parameters:
|
|
75
|
+
-----------
|
|
76
|
+
n_periods : int
|
|
77
|
+
Number of base periods to aggregate (e.g., 5 for monthly->quarterly)
|
|
78
|
+
tent_type : str
|
|
79
|
+
Type of tent: 'symmetric' (default), 'linear', 'exponential'
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
--------
|
|
83
|
+
weights : np.ndarray
|
|
84
|
+
Array of weights that sum to a convenient number
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
--------
|
|
88
|
+
>>> generate_tent_weights(5, 'symmetric')
|
|
89
|
+
array([1, 2, 3, 2, 1]) # Classic tent for monthly->quarterly
|
|
90
|
+
|
|
91
|
+
>>> generate_tent_weights(7, 'symmetric')
|
|
92
|
+
array([1, 2, 3, 4, 3, 2, 1]) # Weekly aggregation
|
|
93
|
+
"""
|
|
94
|
+
if tent_type == 'symmetric':
|
|
95
|
+
if n_periods % 2 == 1:
|
|
96
|
+
# Odd number: symmetric around middle
|
|
97
|
+
half = n_periods // 2
|
|
98
|
+
weights = np.concatenate([
|
|
99
|
+
np.arange(1, half + 2), # [1, 2, ..., peak]
|
|
100
|
+
np.arange(half, 0, -1) # [peak-1, ..., 2, 1]
|
|
101
|
+
])
|
|
102
|
+
else:
|
|
103
|
+
# Even number: symmetric with two peaks
|
|
104
|
+
half = n_periods // 2
|
|
105
|
+
weights = np.concatenate([
|
|
106
|
+
np.arange(1, half + 1), # [1, 2, ..., half]
|
|
107
|
+
np.arange(half, 0, -1) # [half, ..., 2, 1]
|
|
108
|
+
])
|
|
109
|
+
elif tent_type == 'linear':
|
|
110
|
+
# Linear weights (simple average)
|
|
111
|
+
weights = np.ones(n_periods)
|
|
112
|
+
elif tent_type == 'exponential':
|
|
113
|
+
# Exponential decay from center
|
|
114
|
+
center = n_periods / 2
|
|
115
|
+
weights = np.exp(-np.abs(np.arange(n_periods) - center) / (n_periods / 4))
|
|
116
|
+
weights = weights / weights.sum() * n_periods # Normalize
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(f"Unknown tent_type: {tent_type}. Must be 'symmetric', 'linear', or 'exponential'")
|
|
119
|
+
|
|
120
|
+
return weights.astype(int)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_R_mat(tent_weights: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
124
|
+
"""Generate constraint matrix R_mat from tent weights.
|
|
125
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
-----------
|
|
128
|
+
tent_weights : np.ndarray
|
|
129
|
+
Tent weights array, e.g., [1, 2, 3, 2, 1] for monthly->quarterly
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
--------
|
|
133
|
+
R_mat : np.ndarray
|
|
134
|
+
Constraint matrix of shape (n-1) × n
|
|
135
|
+
q : np.ndarray
|
|
136
|
+
Constraint vector of zeros, shape (n-1,)
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
--------
|
|
140
|
+
>>> weights = np.array([1, 2, 3, 2, 1])
|
|
141
|
+
>>> R_mat, q = generate_R_mat(weights)
|
|
142
|
+
>>> # Returns the classic monthly->quarterly R_mat
|
|
143
|
+
"""
|
|
144
|
+
n = len(tent_weights)
|
|
145
|
+
w1 = tent_weights[0] # First weight (reference)
|
|
146
|
+
|
|
147
|
+
# Create constraint matrix: (n-1) rows × n columns
|
|
148
|
+
R_mat = np.zeros((n - 1, n))
|
|
149
|
+
q = np.zeros(n - 1)
|
|
150
|
+
|
|
151
|
+
# Row i: relates w1*c1 to w(i+1)*c(i+1)
|
|
152
|
+
# Constraint: w1*c1 - w(i+1)*c(i+1) = 0
|
|
153
|
+
for i in range(n - 1):
|
|
154
|
+
R_mat[i, 0] = w1 # Coefficient for c1
|
|
155
|
+
R_mat[i, i + 1] = -tent_weights[i + 1] # Coefficient for c(i+1)
|
|
156
|
+
# All other columns remain 0
|
|
157
|
+
|
|
158
|
+
return R_mat, q
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_tent_weights_for_pair(slower_freq: str, faster_freq: str) -> Optional[np.ndarray]:
|
|
162
|
+
"""Get deterministic tent weights for a frequency pair.
|
|
163
|
+
|
|
164
|
+
Parameters:
|
|
165
|
+
-----------
|
|
166
|
+
slower_freq : str
|
|
167
|
+
Slower frequency (e.g., 'q' for quarterly)
|
|
168
|
+
faster_freq : str
|
|
169
|
+
Faster frequency (e.g., 'm' for monthly) - this is the clock
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
--------
|
|
173
|
+
tent_weights : np.ndarray or None
|
|
174
|
+
Tent weights array if pair is supported, None otherwise
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
--------
|
|
178
|
+
>>> get_tent_weights_for_pair('q', 'm')
|
|
179
|
+
array([1, 2, 3, 2, 1]) # Quarterly -> monthly
|
|
180
|
+
|
|
181
|
+
>>> get_tent_weights_for_pair('m', 'd')
|
|
182
|
+
None # Not supported (too large gap)
|
|
183
|
+
"""
|
|
184
|
+
return TENT_WEIGHTS_LOOKUP.get((slower_freq, faster_freq))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_aggregation_structure(
|
|
188
|
+
config: DFMConfig,
|
|
189
|
+
clock: str = 'm'
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""Get aggregation structure for all frequency combinations in config based on clock.
|
|
192
|
+
|
|
193
|
+
This function determines which series need tent kernels (those with frequencies
|
|
194
|
+
slower than the clock) and generates the corresponding constraint matrices (R_mat)
|
|
195
|
+
and constraint vectors (q) for use in constrained least squares estimation.
|
|
196
|
+
|
|
197
|
+
The aggregation structure follows the clock-based approach:
|
|
198
|
+
- All latent factors evolve at the clock frequency
|
|
199
|
+
- Series with frequencies slower than the clock use tent kernels
|
|
200
|
+
- Series with frequencies faster than the clock use missing data approach
|
|
201
|
+
- If tent kernel size exceeds MAX_TENT_SIZE, missing data approach is used
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
config : DFMConfig
|
|
206
|
+
Model configuration containing series frequencies and structure
|
|
207
|
+
clock : str, optional
|
|
208
|
+
Base frequency (global clock) for nowcasting, by default 'm' (monthly).
|
|
209
|
+
All latent factors will evolve at this frequency.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
aggregation_info : Dict[str, Any]
|
|
214
|
+
Dictionary containing:
|
|
215
|
+
- 'structures': Dict[Tuple[str, str], Tuple[np.ndarray, np.ndarray]]
|
|
216
|
+
Maps (slower_freq, clock) tuples to (R_mat, q) constraint pairs
|
|
217
|
+
- 'tent_weights': Dict[str, np.ndarray]
|
|
218
|
+
Maps frequency strings to their tent weight arrays
|
|
219
|
+
- 'n_periods': Dict[str, int]
|
|
220
|
+
Maps frequency strings to tent kernel sizes
|
|
221
|
+
- 'clock': str
|
|
222
|
+
The clock frequency used
|
|
223
|
+
|
|
224
|
+
Examples
|
|
225
|
+
--------
|
|
226
|
+
>>> from dfm_python import load_config
|
|
227
|
+
>>> config = load_config('config.yaml')
|
|
228
|
+
>>> agg_info = get_aggregation_structure(config, clock='m')
|
|
229
|
+
>>> # Check which frequencies need tent kernels
|
|
230
|
+
>>> print(agg_info['tent_weights'])
|
|
231
|
+
{'q': array([1, 2, 3, 2, 1]), 'sa': array([1, 2, 3, 4, 3, 2, 1])}
|
|
232
|
+
"""
|
|
233
|
+
frequencies = set(config.Frequency)
|
|
234
|
+
structures = {}
|
|
235
|
+
tent_weights = {}
|
|
236
|
+
n_periods_map = {}
|
|
237
|
+
|
|
238
|
+
# Find series with frequencies slower than clock (need tent kernels)
|
|
239
|
+
for freq in frequencies:
|
|
240
|
+
if FREQUENCY_HIERARCHY.get(freq, 999) > FREQUENCY_HIERARCHY.get(clock, 0):
|
|
241
|
+
# This frequency is slower than clock, check if tent kernel is available
|
|
242
|
+
tent_w = get_tent_weights_for_pair(freq, clock)
|
|
243
|
+
if tent_w is not None and len(tent_w) <= MAX_TENT_SIZE:
|
|
244
|
+
# Tent kernel available and within size limit
|
|
245
|
+
tent_weights[freq] = tent_w
|
|
246
|
+
n_periods_map[freq] = len(tent_w)
|
|
247
|
+
# Generate R_mat from tent weights
|
|
248
|
+
R_mat, q = generate_R_mat(tent_w)
|
|
249
|
+
structures[(freq, clock)] = (R_mat, q)
|
|
250
|
+
# If tent kernel not available or too large, use missing data approach (no structure needed)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
'structures': structures,
|
|
254
|
+
'tent_weights': tent_weights,
|
|
255
|
+
'n_periods': n_periods_map,
|
|
256
|
+
'clock': clock
|
|
257
|
+
}
|
|
258
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Utility functions for data preprocessing and summary statistics.
|
|
2
|
+
|
|
3
|
+
This module provides essential data handling utilities for Dynamic Factor Models:
|
|
4
|
+
- Missing value treatment via spline interpolation
|
|
5
|
+
- Data summary and visualization
|
|
6
|
+
- Robust handling of edge cases (all-NaN series, constant series, etc.)
|
|
7
|
+
|
|
8
|
+
The spline interpolation methods follow standard econometric practice for handling
|
|
9
|
+
missing data in time series, with options for different treatment strategies
|
|
10
|
+
depending on data characteristics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy.interpolate import CubicSpline
|
|
15
|
+
from scipy.signal import lfilter
|
|
16
|
+
from typing import Tuple, Optional
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
# Set up logger
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def rem_nans_spline(X: np.ndarray, method: int = 2, k: int = 3) -> Tuple[np.ndarray, np.ndarray]:
|
|
24
|
+
"""Treat NaNs in dataset for DFM estimation."""
|
|
25
|
+
T, N = X.shape
|
|
26
|
+
indNaN = np.isnan(X)
|
|
27
|
+
|
|
28
|
+
def _remove_leading_trailing(threshold: float):
|
|
29
|
+
"""Remove rows with NaN count above threshold."""
|
|
30
|
+
rem = np.sum(indNaN, axis=1) > (N * threshold if threshold < 1 else threshold)
|
|
31
|
+
nan_lead = np.cumsum(rem) == np.arange(1, T + 1)
|
|
32
|
+
nan_end = np.cumsum(rem[::-1]) == np.arange(1, T + 1)[::-1]
|
|
33
|
+
return ~(nan_lead | nan_end)
|
|
34
|
+
|
|
35
|
+
def _fill_missing(x: np.ndarray, mask: np.ndarray):
|
|
36
|
+
"""Fill missing values using spline interpolation and moving average."""
|
|
37
|
+
# Ensure mask matches x length
|
|
38
|
+
if len(mask) != len(x):
|
|
39
|
+
mask = mask[:len(x)]
|
|
40
|
+
|
|
41
|
+
non_nan = np.where(~mask)[0]
|
|
42
|
+
if len(non_nan) < 2:
|
|
43
|
+
return x
|
|
44
|
+
|
|
45
|
+
x_filled = x.copy()
|
|
46
|
+
if non_nan[-1] >= len(x):
|
|
47
|
+
non_nan = non_nan[non_nan < len(x)]
|
|
48
|
+
if len(non_nan) < 2:
|
|
49
|
+
return x
|
|
50
|
+
|
|
51
|
+
x_filled[non_nan[0]:non_nan[-1]+1] = CubicSpline(non_nan, x[non_nan])(np.arange(non_nan[0], min(non_nan[-1]+1, len(x))))
|
|
52
|
+
x_filled[mask[:len(x_filled)]] = np.nanmedian(x_filled)
|
|
53
|
+
|
|
54
|
+
# Moving average filter
|
|
55
|
+
pad = np.concatenate([np.full(k, x_filled[0]), x_filled, np.full(k, x_filled[-1])])
|
|
56
|
+
ma = lfilter(np.ones(2*k+1)/(2*k+1), 1, pad)[2*k+1:]
|
|
57
|
+
if len(ma) == len(x_filled):
|
|
58
|
+
x_filled[mask[:len(x_filled)]] = ma[mask[:len(x_filled)]]
|
|
59
|
+
return x_filled
|
|
60
|
+
|
|
61
|
+
if method == 1:
|
|
62
|
+
# Replace all missing values
|
|
63
|
+
for i in range(N):
|
|
64
|
+
mask = indNaN[:, i]
|
|
65
|
+
x = X[:, i].copy()
|
|
66
|
+
x[mask] = np.nanmedian(x)
|
|
67
|
+
pad = np.concatenate([np.full(k, x[0]), x, np.full(k, x[-1])])
|
|
68
|
+
ma = lfilter(np.ones(2*k+1)/(2*k+1), 1, pad)[2*k+1:]
|
|
69
|
+
x[mask] = ma[mask]
|
|
70
|
+
X[:, i] = x
|
|
71
|
+
|
|
72
|
+
elif method == 2:
|
|
73
|
+
# Remove >80% NaN rows, then fill
|
|
74
|
+
mask = _remove_leading_trailing(0.8)
|
|
75
|
+
X = X[mask]
|
|
76
|
+
indNaN = np.isnan(X)
|
|
77
|
+
for i in range(N):
|
|
78
|
+
X[:, i] = _fill_missing(X[:, i], indNaN[:, i])
|
|
79
|
+
|
|
80
|
+
elif method == 3:
|
|
81
|
+
# Only remove all-NaN rows
|
|
82
|
+
mask = _remove_leading_trailing(N)
|
|
83
|
+
X = X[mask]
|
|
84
|
+
indNaN = np.isnan(X)
|
|
85
|
+
|
|
86
|
+
elif method == 4:
|
|
87
|
+
# Remove all-NaN rows, then fill
|
|
88
|
+
mask = _remove_leading_trailing(N)
|
|
89
|
+
X = X[mask]
|
|
90
|
+
indNaN = np.isnan(X)
|
|
91
|
+
for i in range(N):
|
|
92
|
+
X[:, i] = _fill_missing(X[:, i], indNaN[:, i])
|
|
93
|
+
|
|
94
|
+
elif method == 5:
|
|
95
|
+
# Fill missing values
|
|
96
|
+
for i in range(N):
|
|
97
|
+
X[:, i] = _fill_missing(X[:, i], indNaN[:, i])
|
|
98
|
+
|
|
99
|
+
return X, indNaN
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def summarize(X: np.ndarray, Time, config, vintage: Optional[str] = None) -> None:
|
|
103
|
+
"""Display data summary table."""
|
|
104
|
+
T, N = X.shape
|
|
105
|
+
|
|
106
|
+
print("\n\n\nTable 2: Data Summary")
|
|
107
|
+
print(f"N = {N:4d} data series")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
time_start = Time[0].strftime('%Y-%m-%d') if hasattr(Time[0], 'strftime') else str(Time[0])
|
|
111
|
+
time_end = Time[-1].strftime('%Y-%m-%d') if hasattr(Time[-1], 'strftime') else str(Time[-1])
|
|
112
|
+
except (AttributeError, ValueError, TypeError) as e:
|
|
113
|
+
_logger.debug(f"summarize: Failed to format time range: {type(e).__name__}: {str(e)}")
|
|
114
|
+
time_start = time_end = "N/A"
|
|
115
|
+
|
|
116
|
+
print(f"T = {T:4d} observations from {time_start:10s} to {time_end:10s}\n")
|
|
117
|
+
print(f"{'Data Series':30s} | {'Observations':17s} {'Units':12s} "
|
|
118
|
+
f"{'Frequency':10s} {'Mean':8s} {'Std. Dev.':8s} {'Min':8s} {'Max':8s}")
|
|
119
|
+
print("-" * 130)
|
|
120
|
+
|
|
121
|
+
for i in range(N):
|
|
122
|
+
t_obs = ~np.isnan(X[:, i])
|
|
123
|
+
obs_idx = np.where(t_obs)[0]
|
|
124
|
+
|
|
125
|
+
name = config.SeriesName[i][:27] + "..." if len(config.SeriesName[i]) > 30 else config.SeriesName[i]
|
|
126
|
+
sid = f"[{config.SeriesID[i][:25]}...]" if len(config.SeriesID[i]) > 28 else f"[{config.SeriesID[i]}]"
|
|
127
|
+
|
|
128
|
+
freq = 'Monthly' if config.Frequency[i] == 'm' else 'Quarterly'
|
|
129
|
+
trans = config.Transformation[i]
|
|
130
|
+
units_t = 'MoM%' if trans == 'pch' and config.Frequency[i] == 'm' else \
|
|
131
|
+
'QoQ% AR' if trans == 'pca' and config.Frequency[i] == 'q' else \
|
|
132
|
+
config.UnitsTransformed[i]
|
|
133
|
+
|
|
134
|
+
date_range = "N/A"
|
|
135
|
+
if len(obs_idx) > 0:
|
|
136
|
+
try:
|
|
137
|
+
fmt = '%b %Y' if config.Frequency[i] == 'm' else '%Y-%m'
|
|
138
|
+
# Use iloc for positional indexing
|
|
139
|
+
if hasattr(Time, 'iloc'):
|
|
140
|
+
date_range = f"{Time.iloc[obs_idx[0]].strftime(fmt)}-{Time.iloc[obs_idx[-1]].strftime(fmt)}"
|
|
141
|
+
else:
|
|
142
|
+
date_range = f"{Time[obs_idx[0]].strftime(fmt)}-{Time[obs_idx[-1]].strftime(fmt)}"
|
|
143
|
+
except (AttributeError, ValueError, TypeError) as e:
|
|
144
|
+
# Fallback to positional indexing
|
|
145
|
+
_logger.debug(f"summarize: Failed to format date range with strftime: {type(e).__name__}: {str(e)}")
|
|
146
|
+
try:
|
|
147
|
+
if hasattr(Time, 'iloc'):
|
|
148
|
+
date_range = f"{Time.iloc[obs_idx[0]]}-{Time.iloc[obs_idx[-1]]}"
|
|
149
|
+
else:
|
|
150
|
+
date_range = f"{Time[obs_idx[0]]}-{Time[obs_idx[-1]]}"
|
|
151
|
+
except (AttributeError, ValueError, TypeError, IndexError) as e2:
|
|
152
|
+
_logger.debug(f"summarize: Failed to format date range fallback: {type(e2).__name__}: {str(e2)}")
|
|
153
|
+
date_range = "N/A"
|
|
154
|
+
|
|
155
|
+
y = X[t_obs, i]
|
|
156
|
+
if len(y) == 0:
|
|
157
|
+
stats = (np.nan, np.nan, np.nan, np.nan)
|
|
158
|
+
else:
|
|
159
|
+
stats = np.nanmean(y), np.nanstd(y), np.nanmin(y) if len(y) > 0 else np.nan, np.nanmax(y) if len(y) > 0 else np.nan
|
|
160
|
+
|
|
161
|
+
print(f"{name:30s} | {len(obs_idx):17d} {units_t:12s} {freq:10s} "
|
|
162
|
+
f"{stats[0]:8.1f} {stats[1]:8.1f} {stats[2]:8.1f} {stats[3]:8.1f}")
|
|
163
|
+
print(f"{sid:30s} | {date_range:17s}\n")
|
|
164
|
+
|
|
165
|
+
print("\n\n\n")
|