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.
@@ -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")