Skip to content

Commit 695b5ec

Browse files
authored
Update regression metrics to handle multiseries problems (#4233)
* Update RMSLE and MaxError to accomodate multiseries * Update MAPE to use sktime impl for multiseries support * Add tests for regression+df metrics
1 parent aa17295 commit 695b5ec

File tree

4 files changed

+73
-23
lines changed

4 files changed

+73
-23
lines changed

docs/source/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ Release Notes
22
-------------
33
**Future Releases**
44
* Enhancements
5+
* Updated regression metrics to handle multioutput dataframes as well as single output series :pr:`4233`
56
* Fixes
67
* Changes
78
* Unpinned sktime version :pr:`4214`

evalml/objectives/objective_base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,17 @@ def validate_inputs(self, y_true, y_predicted):
143143
)
144144
if len(y_true) == 0:
145145
raise ValueError("Length of inputs is 0")
146+
147+
if isinstance(y_true, pd.DataFrame):
148+
y_true = y_true.to_numpy().flatten()
146149
if np.isnan(y_true).any() or np.isinf(y_true).any():
147150
raise ValueError("y_true contains NaN or infinity")
148-
# y_predicted could be a 1d vector (predictions) or a 2d vector (classifier predicted probabilities)
149-
y_pred_flat = y_predicted.to_numpy().flatten()
150-
if np.isnan(y_pred_flat).any() or np.isinf(y_pred_flat).any():
151+
152+
if isinstance(y_predicted, pd.DataFrame):
153+
y_predicted = y_predicted.to_numpy().flatten()
154+
if np.isnan(y_predicted).any() or np.isinf(y_predicted).any():
151155
raise ValueError("y_predicted contains NaN or infinity")
152-
if self.score_needs_proba and np.any([(y_pred_flat < 0) | (y_pred_flat > 1)]):
156+
if self.score_needs_proba and np.any([(y_predicted < 0) | (y_predicted > 1)]):
153157
raise ValueError(
154158
"y_predicted contains probability estimates not within [0, 1]",
155159
)

evalml/objectives/standard_metrics.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -721,13 +721,27 @@ class RootMeanSquaredLogError(RegressionObjective):
721721

722722
def objective_function(self, y_true, y_predicted, X=None, sample_weight=None):
723723
"""Objective function for root mean squared log error for regression."""
724-
return np.sqrt(
725-
metrics.mean_squared_log_error(
726-
y_true,
727-
y_predicted,
728-
sample_weight=sample_weight,
729-
),
730-
)
724+
725+
def rmsle(y_true, y_pred):
726+
return np.sqrt(
727+
metrics.mean_squared_log_error(
728+
y_true,
729+
y_pred,
730+
sample_weight=sample_weight,
731+
),
732+
)
733+
734+
# Multiseries time series regression
735+
if isinstance(y_true, pd.DataFrame):
736+
raw_rmsles = []
737+
for i in range(len(y_true.columns)):
738+
y_true_i = y_true.iloc[:, i]
739+
y_predicted_i = y_predicted.iloc[:, i]
740+
raw_rmsles.append(rmsle(y_true_i, y_predicted_i))
741+
return np.mean(raw_rmsles)
742+
743+
# All univariate regression
744+
return rmsle(y_true, y_predicted)
731745

732746
@classproperty
733747
def positive_only(self):
@@ -833,17 +847,13 @@ class MAPE(TimeSeriesRegressionObjective):
833847

834848
def objective_function(self, y_true, y_predicted, X=None, sample_weight=None):
835849
"""Objective function for mean absolute percentage error for time series regression."""
836-
if (y_true == 0).any():
850+
if 0 in y_true.values:
837851
raise ValueError(
838852
"Mean Absolute Percentage Error cannot be used when "
839853
"targets contain the value 0.",
840854
)
841-
if isinstance(y_true, pd.Series):
842-
y_true = y_true.to_numpy()
843-
if isinstance(y_predicted, pd.Series):
844-
y_predicted = y_predicted.to_numpy()
845-
scaled_difference = (y_true - y_predicted) / y_true
846-
return np.abs(scaled_difference).mean() * 100
855+
mape = MeanAbsolutePercentageError()
856+
return mape(y_true, y_predicted) * 100
847857

848858
@classproperty
849859
def positive_only(self):
@@ -871,15 +881,11 @@ class SMAPE(TimeSeriesRegressionObjective):
871881

872882
def objective_function(self, y_true, y_predicted, X=None, sample_weight=None):
873883
"""Objective function for mean absolute percentage error for time series regression."""
874-
if ((abs(y_true) + abs(y_predicted)) == 0).any():
884+
if 0 in (abs(y_true) + abs(y_predicted)).values:
875885
raise ValueError(
876886
"Symmetric Mean Absolute Percentage Error cannot be used when "
877887
"true and predicted targets both contain the value 0.",
878888
)
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()
883889

884890
smape = MeanAbsolutePercentageError(symmetric=True)
885891
return smape(y_true, y_predicted) * 100
@@ -958,6 +964,16 @@ class MaxError(RegressionObjective):
958964

959965
def objective_function(self, y_true, y_predicted, X=None, sample_weight=None):
960966
"""Objective function for maximum residual error for regression."""
967+
# Multiseries time series regression
968+
if isinstance(y_true, pd.DataFrame):
969+
raw_max_errors = []
970+
for i in range(len(y_true.columns)):
971+
y_true_i = y_true.iloc[:, i]
972+
y_predicted_i = y_predicted.iloc[:, i]
973+
raw_max_errors.append(metrics.max_error(y_true_i, y_predicted_i))
974+
return np.mean(raw_max_errors)
975+
976+
# All other regression problems
961977
return metrics.max_error(y_true, y_predicted)
962978

963979

evalml/tests/objective_tests/test_standard_metrics.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
RecallMacro,
3232
RecallMicro,
3333
RecallWeighted,
34+
RegressionObjective,
3435
RootMeanSquaredError,
3536
RootMeanSquaredLogError,
3637
)
@@ -158,6 +159,34 @@ def test_negative_with_log():
158159
objective.score(y_true, y_predicted)
159160

160161

162+
@pytest.mark.parametrize("objective_class", _all_objectives_dict().values())
163+
def test_regression_handles_dataframes(objective_class):
164+
if not issubclass(objective_class, RegressionObjective):
165+
pytest.skip("Skipping non-regression objective")
166+
167+
y_predicted = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
168+
y_true = pd.DataFrame({"a": [1, 2, 3], "b": [4, 6, 6]})
169+
170+
objective = objective_class()
171+
score = objective.score(y_true, y_predicted)
172+
assert isinstance(score, float) # Output should be a float average
173+
174+
175+
@pytest.mark.parametrize("mismatch_dim", ["columns", "rows", "both"])
176+
def test_dataframe_different_dimensions(mismatch_dim):
177+
y_predicted = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
178+
if mismatch_dim == "columns":
179+
y_true = pd.DataFrame({"a": [1, 2, 3]})
180+
if mismatch_dim == "rows":
181+
y_true = pd.DataFrame({"a": [1, 2], "b": [4, 6]})
182+
else:
183+
y_true = pd.DataFrame({"a": [1, 2]})
184+
185+
objective = MAPE()
186+
with pytest.raises(ValueError, match="Inputs have mismatched dimensions"):
187+
objective.score(y_true, y_predicted)
188+
189+
161190
def test_binary_more_than_two_unique_values():
162191
y_predicted = np.array([0, 1, 2])
163192
y_true = np.array([1, 0, 1])

0 commit comments

Comments
 (0)