Skip to content

Commit 60db76c

Browse files
Add SMAPE metric (#4220)
* add SMAPE metric * update comments * add smape metric tests * update num of objectives * bound to 200 and revise 0 targets * bound smape
1 parent c30c86b commit 60db76c

6 files changed

Lines changed: 90 additions & 9 deletions

File tree

docs/source/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Release Notes
33
**Future Releases**
44
* Enhancements
55
* Add run_feature_selection to AutoMLSearch and Default Algorithm :pr:`4210`
6+
* Added ``SMAPE`` to the standard metrics for time series problems :pr:`4220`
67
* Fixes
78
* `IDColumnsDataCheck` now works with Unknown data type :pr:`4203`
89
* Changes

evalml/objectives/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
F1,
1717
MAE,
1818
MAPE,
19+
SMAPE,
1920
MSE,
2021
MeanSquaredLogError,
2122
R2,

evalml/objectives/standard_metrics.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pandas as pd
66
from sklearn import metrics
77
from sklearn.preprocessing import label_binarize
8+
from sktime.performance_metrics.forecasting import MeanAbsolutePercentageError
89

910
from evalml.objectives.binary_classification_objective import (
1011
BinaryClassificationObjective,
@@ -850,6 +851,45 @@ def positive_only(self):
850851
return True
851852

852853

854+
class SMAPE(TimeSeriesRegressionObjective):
855+
"""Mean absolute percentage error for time series regression. Scaled by 100 to return a percentage.
856+
857+
Only valid for nonzero inputs. Otherwise, will throw a ValueError.
858+
859+
Example:
860+
>>> y_true = pd.Series([1.5, 2, 3, 1, 0.5, 1, 2.5, 2.5, 1, 0.5, 2])
861+
>>> y_pred = pd.Series([1.5, 2.5, 2, 1, 0.5, 1, 3, 2.25, 0.75, 0.25, 1.75])
862+
>>> np.testing.assert_almost_equal(SMAPE().objective_function(y_true, y_pred), 18.13652589)
863+
"""
864+
865+
name = "Symmetric Mean Absolute Percentage Error"
866+
greater_is_better = False
867+
score_needs_proba = False
868+
perfect_score = 0.0
869+
is_bounded_like_percentage = True # Range [0, 200]
870+
expected_range = [0, 200]
871+
872+
def objective_function(self, y_true, y_predicted, X=None, sample_weight=None):
873+
"""Objective function for mean absolute percentage error for time series regression."""
874+
if ((abs(y_true) + abs(y_predicted)) == 0).any():
875+
raise ValueError(
876+
"Symmetric Mean Absolute Percentage Error cannot be used when "
877+
"true and predicted targets both contain the value 0.",
878+
)
879+
if isinstance(y_true, pd.Series):
880+
y_true = y_true.to_numpy()
881+
if isinstance(y_predicted, pd.Series):
882+
y_predicted = y_predicted.to_numpy()
883+
884+
smape = MeanAbsolutePercentageError(symmetric=True)
885+
return smape(y_true, y_predicted) * 100
886+
887+
@classproperty
888+
def positive_only(self):
889+
"""If True, this objective is only valid for positive data."""
890+
return True
891+
892+
853893
class MSE(RegressionObjective):
854894
"""Mean squared error for regression.
855895

evalml/tests/data_checks_tests/test_invalid_target_data_check.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
InvalidTargetDataCheck,
1616
)
1717
from evalml.exceptions import DataCheckInitError
18-
from evalml.objectives import MAPE, MeanSquaredLogError, RootMeanSquaredLogError
18+
from evalml.objectives import MAPE, SMAPE, MeanSquaredLogError, RootMeanSquaredLogError
1919
from evalml.problem_types import ProblemTypes, is_binary, is_multiclass, is_regression
2020
from evalml.utils.woodwork_utils import numeric_and_boolean_ww
2121

@@ -397,7 +397,7 @@ def test_invalid_target_data_check_invalid_labels_for_nonnegative_objective_name
397397

398398
@pytest.mark.parametrize(
399399
"objective",
400-
[RootMeanSquaredLogError(), MeanSquaredLogError(), MAPE()],
400+
[RootMeanSquaredLogError(), MeanSquaredLogError(), MAPE(), SMAPE()],
401401
)
402402
def test_invalid_target_data_check_invalid_labels_for_nonnegative_objective_instances(
403403
objective,

evalml/tests/objective_tests/test_objectives.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
)
2828
from evalml.objectives.fraud_cost import FraudCost
2929
from evalml.objectives.objective_base import ObjectiveBase
30-
from evalml.objectives.standard_metrics import MAPE
30+
from evalml.objectives.standard_metrics import MAPE, SMAPE
3131
from evalml.objectives.utils import _all_objectives_dict
3232
from evalml.problem_types import ProblemTypes
3333

@@ -111,21 +111,21 @@ def test_get_core_objectives_types():
111111
assert len(get_core_objectives(ProblemTypes.MULTICLASS)) == 13
112112
assert len(get_core_objectives(ProblemTypes.BINARY)) == 8
113113
assert len(get_core_objectives(ProblemTypes.REGRESSION)) == 7
114-
assert len(get_core_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 7
114+
assert len(get_core_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 8
115115

116116

117117
def test_get_optimization_objectives_types():
118118
assert len(get_optimization_objectives(ProblemTypes.MULTICLASS)) == 13
119119
assert len(get_optimization_objectives(ProblemTypes.BINARY)) == 8
120120
assert len(get_optimization_objectives(ProblemTypes.REGRESSION)) == 7
121-
assert len(get_optimization_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 7
121+
assert len(get_optimization_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 8
122122

123123

124124
def test_get_ranking_objectives_types():
125125
assert len(get_ranking_objectives(ProblemTypes.MULTICLASS)) == 16
126126
assert len(get_ranking_objectives(ProblemTypes.BINARY)) == 9
127127
assert len(get_ranking_objectives(ProblemTypes.REGRESSION)) == 9
128-
assert len(get_ranking_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 10
128+
assert len(get_ranking_objectives(ProblemTypes.TIME_SERIES_REGRESSION)) == 11
129129

130130

131131
def test_optimization_excludes_ranking():
@@ -135,7 +135,7 @@ def test_optimization_excludes_ranking():
135135

136136

137137
def test_get_time_series_objectives_types(time_series_objectives):
138-
assert len(time_series_objectives) == 10
138+
assert len(time_series_objectives) == 11
139139

140140

141141
def test_objective_outputs(
@@ -229,9 +229,9 @@ def test_objectives_support_nullable_types(
229229
if isinstance(obj, FraudCost):
230230
# FraudCost needs an "amount" column
231231
X = pd.DataFrame({"amount": [100, 5, 250, 89] * 5})
232-
elif isinstance(obj, MAPE):
232+
elif isinstance(obj, (MAPE, SMAPE)):
233233
if isinstance(y_true.ww.logical_type, BooleanNullable):
234-
pytest.skip("MAPE doesn't support inputs containing 0")
234+
pytest.skip("MAPE and SMAPE don't support inputs containing 0")
235235
# Replace numeric inputs containing 0
236236
y_true = y_true.ww.replace({0: 10})
237237
y_pred = y_pred.replace({0: 10})

evalml/tests/objective_tests/test_standard_metrics.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
F1,
99
MAPE,
1010
MSE,
11+
SMAPE,
1112
AccuracyBinary,
1213
AccuracyMulticlass,
1314
BalancedAccuracyBinary,
@@ -711,6 +712,44 @@ def test_mape_time_series_model():
711712
) == pytest.approx(8 / 4 * 100)
712713

713714

715+
def test_smape_time_series_model():
716+
obj = SMAPE()
717+
718+
s1_actual = np.array([0, 0, 1, 1, 1, 1, 2, 0, 2])
719+
s1_predicted = np.array([0, 1, 0, 1, 1, 2, 1, 2, 0])
720+
721+
s2_actual = np.array([-1, -2, 1, 3])
722+
s2_predicted = np.array([1, 2, -1, -3])
723+
724+
s3_actual = np.array([1, 2, 4, 2, 1, 2])
725+
s3_predicted = np.array([0, 2, 2, 0, 3, 2])
726+
727+
s4_actual = np.array([0, 2, 0, 2, 1, 2])
728+
s4_predicted = np.array([1, 2, 2, 1, 3, 2])
729+
730+
with pytest.raises(
731+
ValueError,
732+
match="Symmetric Mean Absolute Percentage Error cannot be used when "
733+
"true and predicted targets both contain the value 0.",
734+
):
735+
obj.score(s1_actual, s1_predicted)
736+
assert obj.score(s2_actual, s2_predicted) == pytest.approx(8 / 4 * 100)
737+
assert obj.score(s3_actual, s3_predicted) == pytest.approx((17 / 6) / 3 * 100)
738+
assert obj.score(s4_actual, s4_predicted) == pytest.approx((17 / 6) / 3 * 100)
739+
assert obj.score(
740+
pd.Series(s3_actual, index=range(-12, -6)),
741+
s3_predicted,
742+
) == pytest.approx((17 / 6) / 3 * 100)
743+
assert obj.score(
744+
pd.Series(s2_actual, index=range(10, 14)),
745+
pd.Series(s2_predicted, index=range(20, 24)),
746+
) == pytest.approx(8 / 4 * 100)
747+
assert obj.score(
748+
pd.Series(s4_actual, index=range(-12, -6)),
749+
s4_predicted,
750+
) == pytest.approx((17 / 6) / 3 * 100)
751+
752+
714753
@pytest.mark.parametrize("objective_class", _all_objectives_dict().values())
715754
def test_calculate_percent_difference(objective_class):
716755
score = 5

0 commit comments

Comments
 (0)