dfm-python 0.2.0__py3-none-any.whl → 0.2.2__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,54 @@
1
+ from typing import Dict, List
2
+ import numpy as np
3
+
4
+ from ..utils.aggregation import FREQUENCY_HIERARCHY
5
+
6
+
7
+ def group_series_by_frequency(
8
+ idx_i: np.ndarray,
9
+ frequencies: np.ndarray,
10
+ clock: str
11
+ ) -> Dict[str, np.ndarray]:
12
+ """Group series indices by their actual frequency.
13
+
14
+ Groups series by their actual frequency values, allowing each frequency
15
+ to be processed independently. Faster frequencies than clock are rejected.
16
+ """
17
+ if frequencies is None or len(frequencies) == 0:
18
+ # Fallback: assume all are same as clock if frequencies not provided
19
+ return {clock: idx_i.copy()}
20
+
21
+ clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3) # Default to monthly
22
+
23
+ freq_groups: Dict[str, List[int]] = {}
24
+ faster_indices = []
25
+
26
+ for idx in idx_i:
27
+ if idx >= len(frequencies):
28
+ # Index out of bounds - skip
29
+ continue
30
+
31
+ freq = frequencies[idx]
32
+ freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3) # Default to monthly
33
+
34
+ if freq_hierarchy < clock_hierarchy:
35
+ # Faster frequency (lower hierarchy number) - NOT SUPPORTED
36
+ faster_indices.append(idx)
37
+ else:
38
+ # Group by actual frequency
39
+ if freq not in freq_groups:
40
+ freq_groups[freq] = []
41
+ freq_groups[freq].append(idx)
42
+
43
+ # Validate: faster frequencies are not supported
44
+ if len(faster_indices) > 0:
45
+ raise ValueError(
46
+ f"Higher frequencies (daily, weekly) are not supported. "
47
+ f"Found {len(faster_indices)} series with frequency faster than clock '{clock}'. "
48
+ f"Please use monthly, quarterly, semi-annual, or annual frequencies only."
49
+ )
50
+
51
+ # Convert lists to numpy arrays
52
+ return {freq: np.array(indices, dtype=int) for freq, indices in freq_groups.items()}
53
+
54
+
@@ -0,0 +1,56 @@
1
+ """Helper functions for common patterns across the package.
2
+
3
+ This module provides utility functions for common code patterns to reduce
4
+ duplication and improve maintainability.
5
+ """
6
+
7
+ from typing import Optional, Callable, Any
8
+ from ..config import DFMConfig
9
+
10
+
11
+ def safe_get_method(config: Optional[DFMConfig], method_name: str, default: Any = None) -> Any:
12
+ """Safely get a method from config if it exists and is callable.
13
+
14
+ Parameters
15
+ ----------
16
+ config : DFMConfig, optional
17
+ Configuration object
18
+ method_name : str
19
+ Name of the method to retrieve
20
+ default : Any
21
+ Default value if method doesn't exist or isn't callable
22
+
23
+ Returns
24
+ -------
25
+ Any
26
+ Method result if callable, else default value
27
+ """
28
+ if config is None:
29
+ return default
30
+ method = getattr(config, method_name, None)
31
+ if method is not None and callable(method):
32
+ return method()
33
+ return default
34
+
35
+
36
+ def safe_get_attr(config: Optional[DFMConfig], attr_name: str, default: Any = None) -> Any:
37
+ """Safely get an attribute from config if it exists.
38
+
39
+ Parameters
40
+ ----------
41
+ config : DFMConfig, optional
42
+ Configuration object
43
+ attr_name : str
44
+ Name of the attribute to retrieve
45
+ default : Any
46
+ Default value if attribute doesn't exist
47
+
48
+ Returns
49
+ -------
50
+ Any
51
+ Attribute value or default
52
+ """
53
+ if config is None:
54
+ return default
55
+ return getattr(config, attr_name, default)
56
+
@@ -0,0 +1,346 @@
1
+ from typing import Optional, Tuple, Dict, Any
2
+ import logging
3
+ import numpy as np
4
+
5
+ try:
6
+ from scipy.sparse.linalg import eigs
7
+ from scipy.sparse import csc_matrix
8
+ SCIPY_SPARSE_AVAILABLE = True
9
+ except ImportError:
10
+ SCIPY_SPARSE_AVAILABLE = False
11
+ eigs = None
12
+ csc_matrix = None
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _check_finite(array: np.ndarray, name: str = "array", raise_on_invalid: bool = False) -> bool:
18
+ """Check if array contains only finite values.
19
+
20
+ Parameters
21
+ ----------
22
+ array : np.ndarray
23
+ Array to check
24
+ name : str
25
+ Name of array for logging
26
+ raise_on_invalid : bool
27
+ If True, raise ValueError on invalid values. If False, only log warning.
28
+
29
+ Returns
30
+ -------
31
+ bool
32
+ True if all values are finite, False otherwise
33
+
34
+ Raises
35
+ ------
36
+ ValueError
37
+ If raise_on_invalid=True and array contains non-finite values
38
+ """
39
+ has_nan = np.any(np.isnan(array))
40
+ has_inf = np.any(np.isinf(array))
41
+
42
+ if has_nan or has_inf:
43
+ msg = f"{name} contains "
44
+ issues = []
45
+ if has_nan:
46
+ issues.append(f"{np.sum(np.isnan(array))} NaN values")
47
+ if has_inf:
48
+ issues.append(f"{np.sum(np.isinf(array))} Inf values")
49
+ msg += " and ".join(issues)
50
+
51
+ if raise_on_invalid:
52
+ raise ValueError(msg)
53
+ else:
54
+ _logger.warning(msg)
55
+ return False
56
+ return True
57
+
58
+
59
+ def _ensure_square_matrix(M: np.ndarray, method: str = 'diag') -> np.ndarray:
60
+ """Ensure matrix is square by extracting diagonal if needed.
61
+
62
+ Parameters
63
+ ----------
64
+ M : np.ndarray
65
+ Matrix to ensure is square
66
+ method : str
67
+ Method to use if matrix is not square:
68
+ - 'diag': Extract diagonal (default)
69
+ - 'eye': Return identity matrix of appropriate size
70
+
71
+ Returns
72
+ -------
73
+ np.ndarray
74
+ Square matrix (n x n)
75
+ """
76
+ if M.size == 0:
77
+ return M
78
+ if M.shape[0] != M.shape[1]:
79
+ if method == 'diag':
80
+ return np.diag(np.diag(M))
81
+ elif method == 'eye':
82
+ size = max(M.shape[0], M.shape[1])
83
+ return np.eye(size)
84
+ return M
85
+
86
+
87
+ def _ensure_symmetric(M: np.ndarray) -> np.ndarray:
88
+ """Ensure matrix is symmetric by averaging with its transpose."""
89
+ return 0.5 * (M + M.T)
90
+
91
+
92
+ def _compute_principal_components(cov_matrix: np.ndarray, n_components: int,
93
+ block_idx: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]:
94
+ """Compute top principal components via eigendecomposition with fallbacks."""
95
+ if cov_matrix.size == 1:
96
+ eigenvector = np.array([[1.0]])
97
+ eigenvalue = cov_matrix[0, 0] if np.isfinite(cov_matrix[0, 0]) else 1.0
98
+ return np.array([eigenvalue]), eigenvector
99
+
100
+ n_series = cov_matrix.shape[0]
101
+
102
+ # Strategy 1: Sparse eigs when feasible
103
+ if n_components < n_series - 1 and SCIPY_SPARSE_AVAILABLE:
104
+ try:
105
+ cov_sparse = csc_matrix(cov_matrix)
106
+ eigenvalues, eigenvectors = eigs(cov_sparse, k=n_components, which='LM')
107
+ eigenvectors = eigenvectors.real
108
+ if np.any(~np.isfinite(eigenvalues)) or np.any(~np.isfinite(eigenvectors)):
109
+ raise ValueError("Invalid eigenvalue results")
110
+ return eigenvalues.real, eigenvectors
111
+ except (ValueError, np.linalg.LinAlgError, RuntimeError) as e:
112
+ if block_idx is not None:
113
+ _logger.warning(
114
+ f"init_conditions: Sparse eigendecomposition failed for block {block_idx+1}, "
115
+ f"falling back to np.linalg.eig. Error: {type(e).__name__}"
116
+ )
117
+ eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
118
+ sort_idx = np.argsort(np.abs(eigenvalues))[::-1][:n_components]
119
+ return eigenvalues[sort_idx].real, eigenvectors[:, sort_idx].real
120
+
121
+ # Strategy 2: Full eig
122
+ try:
123
+ eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
124
+ valid_mask = np.isfinite(eigenvalues)
125
+ if np.sum(valid_mask) < n_components:
126
+ raise ValueError("Not enough valid eigenvalues")
127
+ valid_eigenvalues = eigenvalues[valid_mask]
128
+ valid_eigenvectors = eigenvectors[:, valid_mask]
129
+ sort_idx = np.argsort(np.abs(valid_eigenvalues))[::-1][:n_components]
130
+ return valid_eigenvalues[sort_idx].real, valid_eigenvectors[:, sort_idx].real
131
+ except (IndexError, ValueError, np.linalg.LinAlgError) as e:
132
+ if block_idx is not None:
133
+ _logger.warning(
134
+ f"init_conditions: Eigendecomposition failed for block {block_idx+1}, "
135
+ f"using identity matrix as fallback. Error: {type(e).__name__}"
136
+ )
137
+ eigenvectors = np.eye(n_series)[:, :n_components]
138
+ eigenvalues = np.ones(n_components)
139
+ return eigenvalues, eigenvectors
140
+
141
+
142
+ def _clean_matrix(M: np.ndarray, matrix_type: str = 'general',
143
+ default_nan: float = 0.0, default_inf: Optional[float] = None) -> np.ndarray:
144
+ """Clean matrix by removing NaN/Inf values and ensuring numerical stability."""
145
+ if matrix_type == 'covariance':
146
+ M = np.nan_to_num(M, nan=default_nan, posinf=1e6, neginf=-1e6)
147
+ M = _ensure_symmetric(M)
148
+ try:
149
+ eigenvals = np.linalg.eigvals(M)
150
+ min_eigenval = np.min(eigenvals)
151
+ if min_eigenval < 1e-8:
152
+ M = M + np.eye(M.shape[0]) * (1e-8 - min_eigenval)
153
+ M = _ensure_symmetric(M)
154
+ except (np.linalg.LinAlgError, ValueError):
155
+ M = M + np.eye(M.shape[0]) * 1e-8
156
+ M = _ensure_symmetric(M)
157
+ elif matrix_type == 'diagonal':
158
+ diag = np.diag(M)
159
+ diag = np.nan_to_num(diag, nan=default_nan,
160
+ posinf=default_inf if default_inf is not None else 1e4,
161
+ neginf=default_nan)
162
+ diag = np.maximum(diag, 1e-6)
163
+ M = np.diag(diag)
164
+ elif matrix_type == 'loading':
165
+ M = np.nan_to_num(M, nan=default_nan, posinf=1.0, neginf=-1.0)
166
+ else:
167
+ default_inf_val = default_inf if default_inf is not None else 1e6
168
+ M = np.nan_to_num(M, nan=default_nan, posinf=default_inf_val, neginf=-default_inf_val)
169
+ return M
170
+
171
+
172
+ def _ensure_positive_definite(M: np.ndarray, min_eigenval: float = 1e-8,
173
+ warn: bool = True) -> Tuple[np.ndarray, Dict[str, Any]]:
174
+ """Ensure matrix is positive semi-definite by adding regularization if needed."""
175
+ M = _ensure_symmetric(M)
176
+ stats = {
177
+ 'regularized': False,
178
+ 'min_eigenval_before': None,
179
+ 'reg_amount': 0.0,
180
+ 'min_eigenval_after': None
181
+ }
182
+ if M.size == 0 or M.shape[0] == 0:
183
+ return M, stats
184
+ try:
185
+ eigenvals = np.linalg.eigvalsh(M)
186
+ min_eig = float(np.min(eigenvals))
187
+ stats['min_eigenval_before'] = float(min_eig)
188
+ if min_eig < min_eigenval:
189
+ reg_amount = min_eigenval - min_eig
190
+ M = M + np.eye(M.shape[0]) * reg_amount
191
+ M = _ensure_symmetric(M)
192
+ stats['regularized'] = True
193
+ stats['reg_amount'] = float(reg_amount)
194
+ eigenvals_after = np.linalg.eigvalsh(M)
195
+ stats['min_eigenval_after'] = float(np.min(eigenvals_after))
196
+ if warn:
197
+ _logger.warning(
198
+ f"Matrix regularization applied: min eigenvalue {min_eig:.2e} < {min_eigenval:.2e}, "
199
+ f"added {reg_amount:.2e} to diagonal. This biases the covariance matrix."
200
+ )
201
+ else:
202
+ stats['min_eigenval_after'] = float(min_eig)
203
+ except (np.linalg.LinAlgError, ValueError) as e:
204
+ M = M + np.eye(M.shape[0]) * min_eigenval
205
+ M = _ensure_symmetric(M)
206
+ stats['regularized'] = True
207
+ stats['reg_amount'] = float(min_eigenval)
208
+ if warn:
209
+ _logger.warning(
210
+ f"Matrix regularization applied (eigendecomposition failed: {e}). "
211
+ f"Added {min_eigenval:.2e} to diagonal. This biases the covariance matrix."
212
+ )
213
+ return M, stats
214
+
215
+
216
+ def _compute_regularization_param(matrix: np.ndarray, scale_factor: float = 1e-5,
217
+ warn: bool = True) -> Tuple[float, Dict[str, Any]]:
218
+ """Compute regularization parameter based on matrix scale."""
219
+ trace = np.trace(matrix)
220
+ reg_param = max(trace * scale_factor, 1e-8)
221
+ stats = {'trace': float(trace), 'scale_factor': float(scale_factor), 'reg_param': float(reg_param)}
222
+ if warn and reg_param > 1e-8:
223
+ _logger.info(
224
+ f"Regularization parameter computed: {reg_param:.2e} "
225
+ f"(trace={trace:.2e}, scale={scale_factor:.2e})."
226
+ )
227
+ return reg_param, stats
228
+
229
+
230
+ def _clip_ar_coefficients(A: np.ndarray, min_val: float = -0.99, max_val: float = 0.99,
231
+ warn: bool = True) -> Tuple[np.ndarray, Dict[str, Any]]:
232
+ """Clip AR coefficients to stability bounds."""
233
+ A_flat = A.flatten()
234
+ n_total = len(A_flat)
235
+ below_min = A_flat < min_val
236
+ above_max = A_flat > max_val
237
+ needs_clip = below_min | above_max
238
+ n_clipped = np.sum(needs_clip)
239
+ A_clipped = np.clip(A, min_val, max_val)
240
+ stats = {
241
+ 'n_clipped': int(n_clipped),
242
+ 'n_total': int(n_total),
243
+ 'clipped_indices': np.where(needs_clip)[0].tolist() if n_clipped > 0 else [],
244
+ 'min_violations': int(np.sum(below_min)),
245
+ 'max_violations': int(np.sum(above_max))
246
+ }
247
+ if warn and n_clipped > 0:
248
+ pct_clipped = 100.0 * n_clipped / n_total if n_total > 0 else 0.0
249
+ _logger.warning(
250
+ f"AR coefficient clipping applied: {n_clipped}/{n_total} ({pct_clipped:.1f}%) "
251
+ f"coefficients clipped to [{min_val}, {max_val}]."
252
+ )
253
+ return A_clipped, stats
254
+
255
+
256
+ def _apply_ar_clipping(A: np.ndarray, config: Optional[Any] = None) -> Tuple[np.ndarray, Dict[str, Any]]:
257
+ """Apply AR coefficient clipping based on configuration.
258
+
259
+ Parameters
260
+ ----------
261
+ A : np.ndarray
262
+ Transition matrix to clip
263
+ config : object, optional
264
+ Configuration object with clipping parameters. If None, uses defaults.
265
+
266
+ Returns
267
+ -------
268
+ A_clipped : np.ndarray
269
+ Clipped transition matrix
270
+ stats : dict
271
+ Statistics about clipping operation
272
+ """
273
+ if config is None:
274
+ return _clip_ar_coefficients(A, -0.99, 0.99, True)
275
+
276
+ from ..core.em import _get_config_param
277
+
278
+ clip_enabled = _get_config_param(config, 'clip_ar_coefficients', True)
279
+ if not clip_enabled:
280
+ return A, {'n_clipped': 0, 'n_total': A.size, 'clipped_indices': []}
281
+
282
+ min_val = _get_config_param(config, 'ar_clip_min', -0.99)
283
+ max_val = _get_config_param(config, 'ar_clip_max', 0.99)
284
+ warn = _get_config_param(config, 'warn_on_ar_clip', True)
285
+ return _clip_ar_coefficients(A, min_val, max_val, warn)
286
+
287
+
288
+ def _cap_max_eigenvalue(M: np.ndarray, max_eigenval: float = 1e6) -> np.ndarray:
289
+ """Cap maximum eigenvalue of a matrix to prevent numerical explosion."""
290
+ try:
291
+ eigenvals = np.linalg.eigvals(M)
292
+ max_eig = np.max(eigenvals)
293
+ if max_eig > max_eigenval:
294
+ scale = max_eigenval / max_eig
295
+ return M * scale
296
+ except (np.linalg.LinAlgError, ValueError):
297
+ M_diag = np.diag(M)
298
+ M_diag = np.maximum(M_diag, 1e-8)
299
+ M_diag = np.minimum(M_diag, max_eigenval)
300
+ M_capped = np.diag(M_diag)
301
+ return _ensure_symmetric(M_capped)
302
+ return M
303
+
304
+
305
+ def _estimate_ar_coefficient(EZZ_FB: np.ndarray, EZZ_BB: np.ndarray,
306
+ vsmooth_sum: Optional[np.ndarray] = None,
307
+ T: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]:
308
+ """Estimate AR coefficients and innovation variances from expectations."""
309
+ if np.isscalar(EZZ_FB):
310
+ EZZ_FB = np.array([EZZ_FB])
311
+ EZZ_BB = np.array([EZZ_BB])
312
+ if EZZ_FB.ndim > 1:
313
+ EZZ_FB_diag = np.diag(EZZ_FB).copy()
314
+ EZZ_BB_diag = np.diag(EZZ_BB).copy()
315
+ else:
316
+ EZZ_FB_diag = EZZ_FB.copy()
317
+ EZZ_BB_diag = EZZ_BB.copy()
318
+ if vsmooth_sum is not None:
319
+ if vsmooth_sum.ndim > 1:
320
+ vsmooth_diag = np.diag(vsmooth_sum)
321
+ else:
322
+ vsmooth_diag = vsmooth_sum
323
+ EZZ_BB_diag = EZZ_BB_diag + vsmooth_diag
324
+ min_denom = np.maximum(np.abs(EZZ_BB_diag) * 1e-6, 1e-10)
325
+ EZZ_BB_diag = np.where(
326
+ (np.isnan(EZZ_BB_diag) | np.isinf(EZZ_BB_diag) | (np.abs(EZZ_BB_diag) < min_denom)),
327
+ min_denom, EZZ_BB_diag
328
+ )
329
+ EZZ_FB_diag = _clean_matrix(EZZ_FB_diag, 'general', default_nan=0.0) if EZZ_FB_diag.ndim > 0 else np.nan_to_num(EZZ_FB_diag, nan=0.0, posinf=1e6, neginf=-1e6)
330
+ A_diag = EZZ_FB_diag / EZZ_BB_diag
331
+ if T is not None:
332
+ Q_diag = None
333
+ else:
334
+ Q_diag = None
335
+ return A_diag, Q_diag
336
+
337
+
338
+ def _safe_divide(numerator: np.ndarray, denominator: float, default: float = 0.0) -> np.ndarray:
339
+ """Safely divide numerator by denominator, handling zero and invalid values."""
340
+ if denominator == 0 or np.isnan(denominator) or np.isinf(denominator):
341
+ return np.full_like(numerator, default)
342
+ result = numerator / denominator
343
+ result = np.where(np.isfinite(result), result, default)
344
+ return result
345
+
346
+
@@ -0,0 +1,70 @@
1
+ from typing import Optional, Tuple
2
+ import numpy as np
3
+
4
+
5
+ def calculate_rmse(actual: np.ndarray, predicted: np.ndarray,
6
+ mask: Optional[np.ndarray] = None) -> Tuple[float, np.ndarray]:
7
+ """Calculate Root Mean Squared Error (RMSE) between actual and predicted values.
8
+
9
+ RMSE is calculated as: sqrt(mean((actual - predicted)^2))
10
+
11
+ Parameters
12
+ ----------
13
+ actual : np.ndarray
14
+ Actual values (T × N) or (T,) array
15
+ predicted : np.ndarray
16
+ Predicted/forecasted values (T × N) or (T,) array, same shape as actual
17
+ mask : np.ndarray, optional
18
+ Boolean mask (T × N) or (T,) indicating which values to include in calculation.
19
+ If None, only non-NaN values are used.
20
+
21
+ Returns
22
+ -------
23
+ rmse_overall : float
24
+ Overall RMSE averaged across all series and time periods
25
+ rmse_per_series : np.ndarray
26
+ RMSE for each series (N,) or scalar if 1D input
27
+ """
28
+ # Ensure arrays are the same shape
29
+ if actual.shape != predicted.shape:
30
+ raise ValueError(f"actual and predicted must have same shape, got {actual.shape} and {predicted.shape}")
31
+
32
+ # Create mask for valid values
33
+ if mask is None:
34
+ # Use non-NaN values in both actual and predicted
35
+ mask = np.isfinite(actual) & np.isfinite(predicted)
36
+ else:
37
+ # Combine user mask with finite check
38
+ mask = mask & np.isfinite(actual) & np.isfinite(predicted)
39
+
40
+ # Calculate squared errors
41
+ errors_sq = (actual - predicted) ** 2
42
+
43
+ # Handle 1D case (single series)
44
+ if actual.ndim == 1:
45
+ if np.sum(mask) == 0:
46
+ return np.nan, np.array([np.nan])
47
+ rmse_series = np.sqrt(np.mean(errors_sq[mask]))
48
+ return rmse_series, np.array([rmse_series])
49
+
50
+ # Handle 2D case (multiple series)
51
+ T, N = actual.shape
52
+
53
+ # Calculate RMSE per series
54
+ rmse_per_series = np.zeros(N)
55
+ for i in range(N):
56
+ series_mask = mask[:, i]
57
+ if np.sum(series_mask) > 0:
58
+ rmse_per_series[i] = np.sqrt(np.mean(errors_sq[series_mask, i]))
59
+ else:
60
+ rmse_per_series[i] = np.nan
61
+
62
+ # Calculate overall RMSE (average across all valid observations)
63
+ if np.any(mask):
64
+ rmse_overall = np.sqrt(np.mean(errors_sq[mask]))
65
+ else:
66
+ rmse_overall = np.nan
67
+
68
+ return rmse_overall, rmse_per_series
69
+
70
+