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.
- dfm_python/__init__.py +2 -2
- dfm_python/{dfm_api.py → api.py} +41 -251
- dfm_python/config.py +72 -7
- dfm_python/core/__init__.py +59 -0
- dfm_python/core/diagnostics.py +274 -0
- dfm_python/core/em.py +1022 -0
- dfm_python/core/grouping.py +54 -0
- dfm_python/core/helpers.py +56 -0
- dfm_python/core/numeric.py +346 -0
- dfm_python/core/results.py +70 -0
- dfm_python/dfm.py +497 -3538
- dfm_python/kalman.py +18 -0
- dfm_python/utils/data_utils.py +3 -5
- dfm_python-0.2.2.dist-info/METADATA +312 -0
- dfm_python-0.2.2.dist-info/RECORD +21 -0
- dfm_python-0.2.0.dist-info/METADATA +0 -647
- dfm_python-0.2.0.dist-info/RECORD +0 -14
- {dfm_python-0.2.0.dist-info → dfm_python-0.2.2.dist-info}/WHEEL +0 -0
- {dfm_python-0.2.0.dist-info → dfm_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
|