spotforecast2 0.0.1__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.
- spotforecast2/.DS_Store +0 -0
- spotforecast2/__init__.py +2 -0
- spotforecast2/data/__init__.py +0 -0
- spotforecast2/data/data.py +130 -0
- spotforecast2/data/fetch_data.py +209 -0
- spotforecast2/exceptions.py +681 -0
- spotforecast2/forecaster/.DS_Store +0 -0
- spotforecast2/forecaster/__init__.py +7 -0
- spotforecast2/forecaster/base.py +448 -0
- spotforecast2/forecaster/metrics.py +527 -0
- spotforecast2/forecaster/recursive/__init__.py +4 -0
- spotforecast2/forecaster/recursive/_forecaster_equivalent_date.py +1075 -0
- spotforecast2/forecaster/recursive/_forecaster_recursive.py +939 -0
- spotforecast2/forecaster/recursive/_warnings.py +15 -0
- spotforecast2/forecaster/utils.py +954 -0
- spotforecast2/model_selection/__init__.py +5 -0
- spotforecast2/model_selection/bayesian_search.py +453 -0
- spotforecast2/model_selection/grid_search.py +314 -0
- spotforecast2/model_selection/random_search.py +151 -0
- spotforecast2/model_selection/split_base.py +357 -0
- spotforecast2/model_selection/split_one_step.py +245 -0
- spotforecast2/model_selection/split_ts_cv.py +634 -0
- spotforecast2/model_selection/utils_common.py +718 -0
- spotforecast2/model_selection/utils_metrics.py +103 -0
- spotforecast2/model_selection/validation.py +685 -0
- spotforecast2/preprocessing/__init__.py +30 -0
- spotforecast2/preprocessing/_binner.py +378 -0
- spotforecast2/preprocessing/_common.py +123 -0
- spotforecast2/preprocessing/_differentiator.py +123 -0
- spotforecast2/preprocessing/_rolling.py +136 -0
- spotforecast2/preprocessing/curate_data.py +254 -0
- spotforecast2/preprocessing/imputation.py +92 -0
- spotforecast2/preprocessing/outlier.py +114 -0
- spotforecast2/preprocessing/split.py +139 -0
- spotforecast2/py.typed +0 -0
- spotforecast2/utils/__init__.py +43 -0
- spotforecast2/utils/convert_to_utc.py +44 -0
- spotforecast2/utils/data_transform.py +208 -0
- spotforecast2/utils/forecaster_config.py +344 -0
- spotforecast2/utils/generate_holiday.py +70 -0
- spotforecast2/utils/validation.py +569 -0
- spotforecast2/weather/__init__.py +0 -0
- spotforecast2/weather/weather_client.py +288 -0
- spotforecast2-0.0.1.dist-info/METADATA +47 -0
- spotforecast2-0.0.1.dist-info/RECORD +46 -0
- spotforecast2-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Metrics for evaluating forecasting models.
|
|
2
|
+
|
|
3
|
+
This module provides various metric functions for evaluating forecasting performance,
|
|
4
|
+
including custom metrics like MASE, RMSSE, and probabilistic metrics like CRPS.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
Using standard metrics::
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from spotforecast2.forecaster.metrics import _get_metric
|
|
11
|
+
|
|
12
|
+
y_true = np.array([1, 2, 3, 4, 5])
|
|
13
|
+
y_pred = np.array([1.1, 1.9, 3.2, 3.8, 5.1])
|
|
14
|
+
|
|
15
|
+
# Get a metric function
|
|
16
|
+
mse = _get_metric('mean_squared_error')
|
|
17
|
+
error = mse(y_true, y_pred)
|
|
18
|
+
|
|
19
|
+
Using scaled metrics::
|
|
20
|
+
|
|
21
|
+
from spotforecast2.forecaster.metrics import mean_absolute_scaled_error
|
|
22
|
+
|
|
23
|
+
y_train = np.array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
24
|
+
y_true = np.array([9, 10, 11])
|
|
25
|
+
y_pred = np.array([8.8, 10.2, 10.9])
|
|
26
|
+
|
|
27
|
+
mase = mean_absolute_scaled_error(y_true, y_pred, y_train)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
from typing import Callable
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
import inspect
|
|
35
|
+
from functools import wraps
|
|
36
|
+
from sklearn.metrics import (
|
|
37
|
+
mean_squared_error,
|
|
38
|
+
mean_absolute_error,
|
|
39
|
+
mean_absolute_percentage_error,
|
|
40
|
+
mean_squared_log_error,
|
|
41
|
+
median_absolute_error,
|
|
42
|
+
mean_pinball_loss,
|
|
43
|
+
accuracy_score,
|
|
44
|
+
balanced_accuracy_score,
|
|
45
|
+
f1_score,
|
|
46
|
+
precision_score,
|
|
47
|
+
recall_score,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_metric(metric: str) -> Callable:
|
|
52
|
+
"""Get the corresponding scikit-learn function to calculate the metric.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
metric: Metric used to quantify the goodness of fit of the model.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
scikit-learn function to calculate the desired metric.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
>>> from spotforecast2.forecaster.metrics import _get_metric
|
|
62
|
+
>>> mse_func = _get_metric('mean_squared_error')
|
|
63
|
+
>>> y_true = np.array([1, 2, 3])
|
|
64
|
+
>>> y_pred = np.array([1.1, 1.9, 3.2])
|
|
65
|
+
>>> error = mse_func(y_true, y_pred)
|
|
66
|
+
>>> error > 0
|
|
67
|
+
True
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
allowed_metrics = [
|
|
71
|
+
# Regression metrics
|
|
72
|
+
"mean_squared_error",
|
|
73
|
+
"mean_absolute_error",
|
|
74
|
+
"mean_absolute_percentage_error",
|
|
75
|
+
"mean_squared_log_error",
|
|
76
|
+
"mean_absolute_scaled_error",
|
|
77
|
+
"root_mean_squared_scaled_error",
|
|
78
|
+
"median_absolute_error",
|
|
79
|
+
"symmetric_mean_absolute_percentage_error",
|
|
80
|
+
# Classification metrics
|
|
81
|
+
"accuracy_score",
|
|
82
|
+
"balanced_accuracy_score",
|
|
83
|
+
"f1_score",
|
|
84
|
+
"precision_score",
|
|
85
|
+
"recall_score",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
if metric not in allowed_metrics:
|
|
89
|
+
raise ValueError(f"Allowed metrics are: {allowed_metrics}. Got {metric}.")
|
|
90
|
+
|
|
91
|
+
metrics = {
|
|
92
|
+
"mean_squared_error": mean_squared_error,
|
|
93
|
+
"mean_absolute_error": mean_absolute_error,
|
|
94
|
+
"mean_absolute_percentage_error": mean_absolute_percentage_error,
|
|
95
|
+
"mean_squared_log_error": mean_squared_log_error,
|
|
96
|
+
"mean_absolute_scaled_error": mean_absolute_scaled_error,
|
|
97
|
+
"root_mean_squared_scaled_error": root_mean_squared_scaled_error,
|
|
98
|
+
"median_absolute_error": median_absolute_error,
|
|
99
|
+
"symmetric_mean_absolute_percentage_error": symmetric_mean_absolute_percentage_error,
|
|
100
|
+
"accuracy_score": accuracy_score,
|
|
101
|
+
"balanced_accuracy_score": balanced_accuracy_score,
|
|
102
|
+
"f1_score": f1_score,
|
|
103
|
+
"precision_score": precision_score,
|
|
104
|
+
"recall_score": recall_score,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
metric = add_y_train_argument(metrics[metric])
|
|
108
|
+
|
|
109
|
+
return metric
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def add_y_train_argument(func: Callable) -> Callable:
|
|
113
|
+
"""Add `y_train` argument to a function if it is not already present.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
func: Function to which the argument is added.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Function with `y_train` argument added.
|
|
120
|
+
|
|
121
|
+
Examples:
|
|
122
|
+
>>> def my_metric(y_true, y_pred):
|
|
123
|
+
... return np.mean(np.abs(y_true - y_pred))
|
|
124
|
+
>>> enhanced_metric = add_y_train_argument(my_metric)
|
|
125
|
+
>>> # Now the function accepts y_train parameter
|
|
126
|
+
>>> result = enhanced_metric(np.array([1,2,3]), np.array([1,2,3]), y_train=None)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
sig = inspect.signature(func)
|
|
130
|
+
|
|
131
|
+
if "y_train" in sig.parameters:
|
|
132
|
+
return func
|
|
133
|
+
|
|
134
|
+
new_params = list(sig.parameters.values()) + [
|
|
135
|
+
inspect.Parameter("y_train", inspect.Parameter.KEYWORD_ONLY, default=None)
|
|
136
|
+
]
|
|
137
|
+
new_sig = sig.replace(parameters=new_params)
|
|
138
|
+
|
|
139
|
+
@wraps(func)
|
|
140
|
+
def wrapper(*args, y_train=None, **kwargs):
|
|
141
|
+
return func(*args, **kwargs)
|
|
142
|
+
|
|
143
|
+
wrapper.__signature__ = new_sig
|
|
144
|
+
|
|
145
|
+
return wrapper
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def mean_absolute_scaled_error(
|
|
149
|
+
y_true: np.ndarray | pd.Series,
|
|
150
|
+
y_pred: np.ndarray | pd.Series,
|
|
151
|
+
y_train: list[float] | np.ndarray | pd.Series,
|
|
152
|
+
) -> float:
|
|
153
|
+
"""Mean Absolute Scaled Error (MASE).
|
|
154
|
+
|
|
155
|
+
MASE is a scale-independent error metric that measures the accuracy of
|
|
156
|
+
a forecast. It is the mean absolute error of the forecast divided by the
|
|
157
|
+
mean absolute error of a naive forecast in the training set. The naive
|
|
158
|
+
forecast is the one obtained by shifting the time series by one period.
|
|
159
|
+
If y_train is a list of numpy arrays or pandas Series, it is considered
|
|
160
|
+
that each element is the true value of the target variable in the training
|
|
161
|
+
set for each time series. In this case, the naive forecast is calculated
|
|
162
|
+
for each time series separately.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
y_true: True values of the target variable.
|
|
166
|
+
y_pred: Predicted values of the target variable.
|
|
167
|
+
y_train: True values of the target variable in the training set. If `list`, it
|
|
168
|
+
is consider that each element is the true value of the target variable
|
|
169
|
+
in the training set for each time series.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
MASE value.
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
>>> from spotforecast2.forecaster.metrics import mean_absolute_scaled_error
|
|
176
|
+
>>> y_train = np.array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
177
|
+
>>> y_true = np.array([9, 10, 11])
|
|
178
|
+
>>> y_pred = np.array([8.8, 10.2, 10.9])
|
|
179
|
+
>>> mase = mean_absolute_scaled_error(y_true, y_pred, y_train)
|
|
180
|
+
>>> mase < 1.0 # Good forecast
|
|
181
|
+
True
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
# NOTE: When using this metric in validation, `y_train` doesn't include
|
|
185
|
+
# the first window_size observations used to create the predictors and/or
|
|
186
|
+
# rolling features.
|
|
187
|
+
|
|
188
|
+
if not isinstance(y_true, (pd.Series, np.ndarray)):
|
|
189
|
+
raise TypeError("`y_true` must be a pandas Series or numpy ndarray.")
|
|
190
|
+
if not isinstance(y_pred, (pd.Series, np.ndarray)):
|
|
191
|
+
raise TypeError("`y_pred` must be a pandas Series or numpy ndarray.")
|
|
192
|
+
if not isinstance(y_train, (list, pd.Series, np.ndarray)):
|
|
193
|
+
raise TypeError("`y_train` must be a list, pandas Series or numpy ndarray.")
|
|
194
|
+
if isinstance(y_train, list):
|
|
195
|
+
for x in y_train:
|
|
196
|
+
if not isinstance(x, (pd.Series, np.ndarray)):
|
|
197
|
+
raise TypeError(
|
|
198
|
+
"When `y_train` is a list, each element must be a pandas Series "
|
|
199
|
+
"or numpy ndarray."
|
|
200
|
+
)
|
|
201
|
+
if len(y_true) != len(y_pred):
|
|
202
|
+
raise ValueError("`y_true` and `y_pred` must have the same length.")
|
|
203
|
+
if len(y_true) == 0 or len(y_pred) == 0:
|
|
204
|
+
raise ValueError("`y_true` and `y_pred` must have at least one element.")
|
|
205
|
+
|
|
206
|
+
if isinstance(y_train, list):
|
|
207
|
+
# Flatten list of arrays for naive forecast if meaningful, but MASE usually assumes
|
|
208
|
+
# naive forecast on single series. If list, we might be doing something else.
|
|
209
|
+
# Original code does: np.concatenate([np.diff(x) for x in y_train])
|
|
210
|
+
# This assumes independent series and we average error over all of them.
|
|
211
|
+
naive_forecast = np.concatenate([np.diff(x) for x in y_train])
|
|
212
|
+
else:
|
|
213
|
+
naive_forecast = np.diff(y_train)
|
|
214
|
+
|
|
215
|
+
mase = np.mean(np.abs(y_true - y_pred)) / np.nanmean(np.abs(naive_forecast))
|
|
216
|
+
|
|
217
|
+
return mase
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def root_mean_squared_scaled_error(
|
|
221
|
+
y_true: np.ndarray | pd.Series,
|
|
222
|
+
y_pred: np.ndarray | pd.Series,
|
|
223
|
+
y_train: list[float] | np.ndarray | pd.Series,
|
|
224
|
+
) -> float:
|
|
225
|
+
"""Root Mean Squared Scaled Error (RMSSE).
|
|
226
|
+
|
|
227
|
+
RMSSE is a scale-independent error metric that measures the accuracy of
|
|
228
|
+
a forecast. It is the root mean squared error of the forecast divided by
|
|
229
|
+
the root mean squared error of a naive forecast in the training set. The
|
|
230
|
+
naive forecast is the one obtained by shifting the time series by one period.
|
|
231
|
+
If y_train is a list of numpy arrays or pandas Series, it is considered
|
|
232
|
+
that each element is the true value of the target variable in the training
|
|
233
|
+
set for each time series. In this case, the naive forecast is calculated
|
|
234
|
+
for each time series separately.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
y_true: True values of the target variable.
|
|
238
|
+
y_pred: Predicted values of the target variable.
|
|
239
|
+
y_train: True values of the target variable in the training set. If list, it
|
|
240
|
+
is consider that each element is the true value of the target variable
|
|
241
|
+
in the training set for each time series.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
RMSSE value.
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
>>> from spotforecast2.forecaster.metrics import root_mean_squared_scaled_error
|
|
248
|
+
>>> y_train = np.array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
249
|
+
>>> y_true = np.array([9, 10, 11])
|
|
250
|
+
>>> y_pred = np.array([8.8, 10.2, 10.9])
|
|
251
|
+
>>> rmsse = root_mean_squared_scaled_error(y_true, y_pred, y_train)
|
|
252
|
+
>>> rmsse < 1.0 # Good forecast
|
|
253
|
+
True
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
# NOTE: When using this metric in validation, `y_train` doesn't include
|
|
257
|
+
# the first window_size observations used to create the predictors and/or
|
|
258
|
+
# rolling features.
|
|
259
|
+
|
|
260
|
+
if not isinstance(y_true, (pd.Series, np.ndarray)):
|
|
261
|
+
raise TypeError("`y_true` must be a pandas Series or numpy ndarray.")
|
|
262
|
+
if not isinstance(y_pred, (pd.Series, np.ndarray)):
|
|
263
|
+
raise TypeError("`y_pred` must be a pandas Series or numpy ndarray.")
|
|
264
|
+
if not isinstance(y_train, (list, pd.Series, np.ndarray)):
|
|
265
|
+
raise TypeError("`y_train` must be a list, pandas Series or numpy ndarray.")
|
|
266
|
+
if isinstance(y_train, list):
|
|
267
|
+
for x in y_train:
|
|
268
|
+
if not isinstance(x, (pd.Series, np.ndarray)):
|
|
269
|
+
raise TypeError(
|
|
270
|
+
"When `y_train` is a list, each element must be a pandas Series "
|
|
271
|
+
"or numpy ndarray."
|
|
272
|
+
)
|
|
273
|
+
if len(y_true) != len(y_pred):
|
|
274
|
+
raise ValueError("`y_true` and `y_pred` must have the same length.")
|
|
275
|
+
if len(y_true) == 0 or len(y_pred) == 0:
|
|
276
|
+
raise ValueError("`y_true` and `y_pred` must have at least one element.")
|
|
277
|
+
|
|
278
|
+
if isinstance(y_train, list):
|
|
279
|
+
naive_forecast = np.concatenate([np.diff(x) for x in y_train])
|
|
280
|
+
else:
|
|
281
|
+
naive_forecast = np.diff(y_train)
|
|
282
|
+
|
|
283
|
+
rmsse = np.sqrt(np.mean((y_true - y_pred) ** 2)) / np.sqrt(
|
|
284
|
+
np.nanmean(naive_forecast**2)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return rmsse
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def crps_from_predictions(y_true: float, y_pred: np.ndarray) -> float:
|
|
291
|
+
"""Compute the Continuous Ranked Probability Score (CRPS) from predictions.
|
|
292
|
+
|
|
293
|
+
The CRPS compares the empirical distribution of a set of forecasted values
|
|
294
|
+
to a scalar observation. The smaller the CRPS, the better.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
y_true: The true value of the random variable.
|
|
298
|
+
y_pred: The predicted values of the random variable. These are the multiple
|
|
299
|
+
forecasted values for a single observation.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The CRPS score.
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
>>> from spotforecast2.forecaster.metrics import crps_from_predictions
|
|
306
|
+
>>> y_true = 5.0
|
|
307
|
+
>>> y_pred = np.array([4.5, 5.1, 4.9, 5.3, 4.7])
|
|
308
|
+
>>> crps = crps_from_predictions(y_true, y_pred)
|
|
309
|
+
>>> crps >= 0
|
|
310
|
+
True
|
|
311
|
+
"""
|
|
312
|
+
if not isinstance(y_pred, np.ndarray) or y_pred.ndim != 1:
|
|
313
|
+
raise TypeError("`y_pred` must be a 1D numpy array.")
|
|
314
|
+
|
|
315
|
+
if not isinstance(y_true, (float, int)):
|
|
316
|
+
raise TypeError("`y_true` must be a float or integer.")
|
|
317
|
+
|
|
318
|
+
y_pred = np.sort(y_pred)
|
|
319
|
+
# Define the grid for integration including the true value
|
|
320
|
+
grid = np.concatenate(([y_true], y_pred))
|
|
321
|
+
grid = np.sort(grid)
|
|
322
|
+
cdf_values = np.searchsorted(y_pred, grid, side="right") / len(y_pred)
|
|
323
|
+
indicator = grid >= y_true
|
|
324
|
+
diffs = np.diff(grid)
|
|
325
|
+
crps = np.sum(diffs * (cdf_values[:-1] - indicator[:-1]) ** 2)
|
|
326
|
+
|
|
327
|
+
return crps
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def crps_from_quantiles(
|
|
331
|
+
y_true: float,
|
|
332
|
+
pred_quantiles: np.ndarray,
|
|
333
|
+
quantile_levels: np.ndarray,
|
|
334
|
+
) -> float:
|
|
335
|
+
"""Calculate the Continuous Ranked Probability Score (CRPS) from quantiles.
|
|
336
|
+
|
|
337
|
+
The empirical cdf is approximated using linear interpolation
|
|
338
|
+
between the predicted quantiles.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
y_true: The true value of the random variable.
|
|
342
|
+
pred_quantiles: The predicted quantile values.
|
|
343
|
+
quantile_levels: The quantile levels corresponding to the predicted quantiles.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
The CRPS score.
|
|
347
|
+
|
|
348
|
+
Examples:
|
|
349
|
+
>>> from spotforecast2.forecaster.metrics import crps_from_quantiles
|
|
350
|
+
>>> y_true = 5.0
|
|
351
|
+
>>> pred_quantiles = np.array([4.0, 4.5, 5.0, 5.5, 6.0])
|
|
352
|
+
>>> quantile_levels = np.array([0.1, 0.25, 0.5, 0.75, 0.9])
|
|
353
|
+
>>> crps = crps_from_quantiles(y_true, pred_quantiles, quantile_levels)
|
|
354
|
+
>>> crps >= 0
|
|
355
|
+
True
|
|
356
|
+
"""
|
|
357
|
+
if not isinstance(y_true, (float, int)):
|
|
358
|
+
raise TypeError("`y_true` must be a float or integer.")
|
|
359
|
+
|
|
360
|
+
if not isinstance(pred_quantiles, np.ndarray) or pred_quantiles.ndim != 1:
|
|
361
|
+
raise TypeError("`pred_quantiles` must be a 1D numpy array.")
|
|
362
|
+
|
|
363
|
+
if not isinstance(quantile_levels, np.ndarray) or quantile_levels.ndim != 1:
|
|
364
|
+
raise TypeError("`quantile_levels` must be a 1D numpy array.")
|
|
365
|
+
|
|
366
|
+
if len(pred_quantiles) != len(quantile_levels):
|
|
367
|
+
raise ValueError(
|
|
368
|
+
"The number of predicted quantiles and quantile levels must be equal."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
sorted_indices = np.argsort(pred_quantiles)
|
|
372
|
+
pred_quantiles = pred_quantiles[sorted_indices]
|
|
373
|
+
quantile_levels = quantile_levels[sorted_indices]
|
|
374
|
+
|
|
375
|
+
# Define the empirical CDF function using interpolation
|
|
376
|
+
def empirical_cdf(x):
|
|
377
|
+
return np.interp(x, pred_quantiles, quantile_levels, left=0.0, right=1.0)
|
|
378
|
+
|
|
379
|
+
# Define the CRPS integrand
|
|
380
|
+
def crps_integrand(x):
|
|
381
|
+
return (empirical_cdf(x) - (x >= y_true)) ** 2
|
|
382
|
+
|
|
383
|
+
# Integration bounds: Extend slightly beyond predicted quantiles
|
|
384
|
+
xmin = np.min(pred_quantiles) * 0.9
|
|
385
|
+
xmax = np.max(pred_quantiles) * 1.1
|
|
386
|
+
|
|
387
|
+
# Create a fine grid of x values for integration
|
|
388
|
+
x_values = np.linspace(xmin, xmax, 1000)
|
|
389
|
+
|
|
390
|
+
# Compute the integrand values and integrate using the trapezoidal rule
|
|
391
|
+
integrand_values = crps_integrand(x_values)
|
|
392
|
+
if np.__version__ >= "2.0.0":
|
|
393
|
+
crps = np.trapezoid(integrand_values, x=x_values)
|
|
394
|
+
else:
|
|
395
|
+
crps = np.trapz(integrand_values, x_values)
|
|
396
|
+
|
|
397
|
+
return crps
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def calculate_coverage(
|
|
401
|
+
y_true: np.ndarray | pd.Series,
|
|
402
|
+
lower_bound: np.ndarray | pd.Series,
|
|
403
|
+
upper_bound: np.ndarray | pd.Series,
|
|
404
|
+
) -> float:
|
|
405
|
+
"""Calculate coverage of a given interval.
|
|
406
|
+
|
|
407
|
+
Coverage is the proportion of true values that fall within the interval.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
y_true: True values of the target variable.
|
|
411
|
+
lower_bound: Lower bound of the interval.
|
|
412
|
+
upper_bound: Upper bound of the interval.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Coverage of the interval.
|
|
416
|
+
|
|
417
|
+
Examples:
|
|
418
|
+
>>> from spotforecast2.forecaster.metrics import calculate_coverage
|
|
419
|
+
>>> y_true = np.array([1, 2, 3, 4, 5])
|
|
420
|
+
>>> lower_bound = np.array([0.5, 1.5, 2.5, 3.5, 4.5])
|
|
421
|
+
>>> upper_bound = np.array([1.5, 2.5, 3.5, 4.5, 5.5])
|
|
422
|
+
>>> coverage = calculate_coverage(y_true, lower_bound, upper_bound)
|
|
423
|
+
>>> coverage == 1.0 # All values within bounds
|
|
424
|
+
True
|
|
425
|
+
"""
|
|
426
|
+
if not isinstance(y_true, (np.ndarray, pd.Series)) or y_true.ndim != 1:
|
|
427
|
+
raise TypeError("`y_true` must be a 1D numpy array or pandas Series.")
|
|
428
|
+
|
|
429
|
+
if not isinstance(lower_bound, (np.ndarray, pd.Series)) or lower_bound.ndim != 1:
|
|
430
|
+
raise TypeError("`lower_bound` must be a 1D numpy array or pandas Series.")
|
|
431
|
+
|
|
432
|
+
if not isinstance(upper_bound, (np.ndarray, pd.Series)) or upper_bound.ndim != 1:
|
|
433
|
+
raise TypeError("`upper_bound` must be a 1D numpy array or pandas Series.")
|
|
434
|
+
|
|
435
|
+
y_true = np.asarray(y_true)
|
|
436
|
+
lower_bound = np.asarray(lower_bound)
|
|
437
|
+
upper_bound = np.asarray(upper_bound)
|
|
438
|
+
|
|
439
|
+
if y_true.shape != lower_bound.shape or y_true.shape != upper_bound.shape:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
"`y_true`, `lower_bound` and `upper_bound` must have the same shape."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
coverage = np.mean(np.logical_and(y_true >= lower_bound, y_true <= upper_bound))
|
|
445
|
+
|
|
446
|
+
return coverage
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def create_mean_pinball_loss(alpha: float) -> callable:
|
|
450
|
+
"""Create pinball loss for a given quantile.
|
|
451
|
+
|
|
452
|
+
Also known as quantile loss. Internally, it uses the `mean_pinball_loss`
|
|
453
|
+
function from scikit-learn.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
alpha: Quantile for which the Pinball loss is calculated.
|
|
457
|
+
Must be between 0 and 1, inclusive.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Mean Pinball loss function for the given quantile.
|
|
461
|
+
|
|
462
|
+
Examples:
|
|
463
|
+
>>> from spotforecast2.forecaster.metrics import create_mean_pinball_loss
|
|
464
|
+
>>> pinball_loss_50 = create_mean_pinball_loss(alpha=0.5)
|
|
465
|
+
>>> y_true = np.array([1, 2, 3, 4, 5])
|
|
466
|
+
>>> y_pred = np.array([1.1, 1.9, 3.2, 3.8, 5.1])
|
|
467
|
+
>>> loss = pinball_loss_50(y_true, y_pred)
|
|
468
|
+
>>> loss >= 0
|
|
469
|
+
True
|
|
470
|
+
"""
|
|
471
|
+
if not (0 <= alpha <= 1):
|
|
472
|
+
raise ValueError("alpha must be between 0 and 1, both inclusive.")
|
|
473
|
+
|
|
474
|
+
def mean_pinball_loss_q(y_true, y_pred):
|
|
475
|
+
return mean_pinball_loss(y_true, y_pred, alpha=alpha)
|
|
476
|
+
|
|
477
|
+
return mean_pinball_loss_q
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def symmetric_mean_absolute_percentage_error(
|
|
481
|
+
y_true: np.ndarray | pd.Series, y_pred: np.ndarray | pd.Series
|
|
482
|
+
) -> float:
|
|
483
|
+
"""Compute the Symmetric Mean Absolute Percentage Error (SMAPE).
|
|
484
|
+
|
|
485
|
+
SMAPE is a relative error metric used to measure the accuracy
|
|
486
|
+
of forecasts. Unlike MAPE, it is symmetric and prevents division
|
|
487
|
+
by zero by averaging the absolute values of actual and predicted values.
|
|
488
|
+
|
|
489
|
+
The result is expressed as a percentage and ranges from 0%
|
|
490
|
+
(perfect prediction) to 200% (maximum error).
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
y_true: True values of the target variable.
|
|
494
|
+
y_pred: Predicted values of the target variable.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
SMAPE value as a percentage.
|
|
498
|
+
|
|
499
|
+
Examples:
|
|
500
|
+
>>> from spotforecast2.forecaster.metrics import symmetric_mean_absolute_percentage_error
|
|
501
|
+
>>> y_true = np.array([100, 200, 0])
|
|
502
|
+
>>> y_pred = np.array([110, 180, 10])
|
|
503
|
+
>>> result = symmetric_mean_absolute_percentage_error(y_true, y_pred)
|
|
504
|
+
>>> 0 <= result <= 200
|
|
505
|
+
True
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
if not isinstance(y_true, (pd.Series, np.ndarray)):
|
|
509
|
+
raise TypeError("`y_true` must be a pandas Series or numpy ndarray.")
|
|
510
|
+
if not isinstance(y_pred, (pd.Series, np.ndarray)):
|
|
511
|
+
raise TypeError("`y_pred` must be a pandas Series or numpy ndarray.")
|
|
512
|
+
if len(y_true) != len(y_pred):
|
|
513
|
+
raise ValueError("`y_true` and `y_pred` must have the same length.")
|
|
514
|
+
if len(y_true) == 0 or len(y_pred) == 0:
|
|
515
|
+
raise ValueError("`y_true` and `y_pred` must have at least one element.")
|
|
516
|
+
|
|
517
|
+
numerator = np.abs(y_true - y_pred)
|
|
518
|
+
denominator = (np.abs(y_true) + np.abs(y_pred)) / 2
|
|
519
|
+
|
|
520
|
+
# NOTE: Avoid division by zero
|
|
521
|
+
mask = denominator != 0
|
|
522
|
+
smape_values = np.zeros_like(denominator)
|
|
523
|
+
smape_values[mask] = numerator[mask] / denominator[mask]
|
|
524
|
+
|
|
525
|
+
smape = 100 * np.mean(smape_values)
|
|
526
|
+
|
|
527
|
+
return smape
|