Repository: alan-turing-institute/sktime
Pull request #3239 (2022-08-10 21:10:24)

[BUG] fix stray args in one `from_multi_index_to_3d_numpy` call
Removes stray args in `from_multi_index_to_3d_numpy` that remained after earlier removal of index name args, in a piece of code not covered by tests (apparently), but covered by new tests here: https://github.com/alan-turing-institute/sktime/issues/3169


Patch:
diff --git a/sktime/datatypes/_panel/_convert.py b/sktime/datatypes/_panel/_convert.py
index a784829f6..597b175bb 100644
--- a/sktime/datatypes/_panel/_convert.py
+++ b/sktime/datatypes/_panel/_convert.py
@@ -847,9 +847,7 @@ def from_nested_to_3d_numpy(X):
     # Then the multi-indexed DataFrame can be converted to 3d NumPy array
     else:
         X_mi = from_nested_to_multi_index(X)
-        X_3d = from_multi_index_to_3d_numpy(
-            X_mi, instance_index="instance", time_index="timepoints"
-        )
+        X_3d = from_multi_index_to_3d_numpy(X_mi)
 
     return X_3d
 
diff --git a/sktime/performance_metrics/forecasting/_classes.py b/sktime/performance_metrics/forecasting/_classes.py
index b50effdbd..436bb021b 100644
--- a/sktime/performance_metrics/forecasting/_classes.py
+++ b/sktime/performance_metrics/forecasting/_classes.py
@@ -8,6 +8,7 @@ Classes named as ``*Error`` or ``*Loss`` return a value to minimize:
 the lower the better.
 """
 from copy import deepcopy
+from inspect import getfullargspec, isfunction, signature
 from warnings import warn
 
 import numpy as np
@@ -121,7 +122,11 @@ class BaseForecastingErrorMetric(BaseMetric):
     def __init__(self, multioutput="uniform_average", multilevel="uniform_average"):
         self.multioutput = multioutput
         self.multilevel = multilevel
-        self.name = type(self).__name__
+
+        if not hasattr(self, "name"):
+            self.name = type(self).__name__
+
+        super(BaseForecastingErrorMetric, self).__init__()
 
     def __call__(self, y_true, y_pred, **kwargs):
         """Calculate metric value using underlying metric function.
@@ -509,10 +514,24 @@ class BaseForecastingErrorMetricFunc(BaseForecastingErrorMetric):
         """Evaluate the desired metric on given inputs."""
         # this dict should contain all parameters
         params = self.get_params()
+
         # adding kwargs to the metric, should not overwrite params (but does if clashes)
         params.update(kwargs)
+
+        # calls class variable func, if available, or dynamic (object) variable
         # we need to call type since we store func as a class attribute
-        res = type(self).func(y_true=y_true, y_pred=y_pred, **params)
+        if hasattr(type(self), "func") and isfunction(type(self).func):
+            func = type(self).func
+        else:
+            func = self.func
+
+        # if func does not catch kwargs, subset to args of func
+        if getfullargspec(func).varkw is None:
+            func_params = signature(func).parameters.keys()
+            func_params = set(func_params).difference(["y_true", "y_pred"])
+            params = {key: params[key] for key in func_params}
+
+        res = func(y_true=y_true, y_pred=y_pred, **params)
         return res
 
 
@@ -531,10 +550,43 @@ class _DynamicForecastingErrorMetric(BaseForecastingErrorMetricFunc):
         self.multilevel = multilevel
         self.func = func
         self.name = name
-        super().__init__()
+        self.lower_is_better = lower_is_better
+
+        super(_DynamicForecastingErrorMetric, self).__init__(
+            multioutput=multioutput, multilevel=multilevel
+        )
+
+        self.set_tags(**{"lower_is_better": lower_is_better})
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+
+        def custom_mape(y_true, y_pred) -> float:
+
+            eps = np.finfo(np.float64).eps
+
+            result = np.mean(np.abs(y_true - y_pred) / np.maximum(np.abs(y_true), eps))
+
+            return float(result)
 
-        if not lower_is_better:
-            self.set_tags(**{"lower_is_better": False})
+        params = {"func": custom_mape, "name": "custom_mape", "lower_is_better": False}
+        return params
 
 
 class _ScaledMetricTags:
diff --git a/sktime/performance_metrics/forecasting/_functions.py b/sktime/performance_metrics/forecasting/_functions.py
index 804d4c89a..252b648c9 100644
--- a/sktime/performance_metrics/forecasting/_functions.py
+++ b/sktime/performance_metrics/forecasting/_functions.py
@@ -54,7 +54,7 @@ def _get_kwarg(kwarg, metric_name="Metric", **kwargs):
     if kwarg_ is None:
         msg = "".join(
             [
-                f"{metric_name} requires `{kwarg}`.",
+                f"{metric_name} requires `{kwarg}`. ",
                 f"Pass `{kwarg}` as a keyword argument when calling the metric.",
             ]
         )
diff --git a/sktime/transformations/series/detrend/_deseasonalize.py b/sktime/transformations/series/detrend/_deseasonalize.py
index f38939e41..906e8c7ce 100644
--- a/sktime/transformations/series/detrend/_deseasonalize.py
+++ b/sktime/transformations/series/detrend/_deseasonalize.py
@@ -20,10 +20,14 @@ from sktime.utils.validation.forecasting import check_sp
 class Deseasonalizer(BaseTransformer):
     """Remove seasonal components from a time series.
 
-    Fit computes :term:`seasonal components <Seasonality>` and
-    stores them in `seasonal_`.
+    Applies `statsmodels.tsa.seasonal.seasonal_compose` and removes the `seasonal`
+    component in `transform`. Adds seasonal component back again in `inverse_transform`.
+    Seasonality removal can be additive or multiplicative.
 
-    Transform aligns seasonal components stored in `_seasonal` with
+    `fit` computes :term:`seasonal components <Seasonality>` and
+    stores them in `seasonal_` attribute.
+
+    `transform` aligns seasonal components stored in `seasonal_` with
     the time index of the passed :term:`series <Time series>` and then
     substracts them ("additive" model) from the passed :term:`series <Time series>`
     or divides the passed series by them ("multiplicative" model).
diff --git a/sktime/utils/estimator_checks.py b/sktime/utils/estimator_checks.py
index c290c3410..77c07898b 100644
--- a/sktime/utils/estimator_checks.py
+++ b/sktime/utils/estimator_checks.py
@@ -4,6 +4,8 @@
 __author__ = ["fkiraly"]
 __all__ = ["check_estimator"]
 
+from inspect import isclass
+
 
 def check_estimator(
     estimator,
@@ -96,7 +98,14 @@ def check_estimator(
         fixtures_to_exclude=fixtures_to_exclude,
     )
 
-    if isinstance(estimator, BaseEstimator) or issubclass(estimator, BaseEstimator):
+    def is_estimator(obj):
+        """Return whether obj is an estimator class or estimator object."""
+        if isclass(obj):
+            return issubclass(obj, BaseEstimator)
+        else:
+            return isinstance(obj, BaseEstimator)
+
+    if is_estimator(estimator):
         results_estimator = TestAllEstimators().run_tests(
             estimator=estimator,
             return_exceptions=return_exceptions,


Test patch:
diff --git a/sktime/performance_metrics/tests/test_metrics_classes.py b/sktime/performance_metrics/tests/test_metrics_classes.py
index 82c2b7a12..a4c436db0 100644
--- a/sktime/performance_metrics/tests/test_metrics_classes.py
+++ b/sktime/performance_metrics/tests/test_metrics_classes.py
@@ -6,7 +6,12 @@ import numpy as np
 import pandas as pd
 import pytest
 
-from sktime.performance_metrics.forecasting import MeanSquaredError, _classes
+from sktime.datasets import load_airline
+from sktime.performance_metrics.forecasting import (
+    MeanSquaredError,
+    _classes,
+    make_forecasting_scorer,
+)
 from sktime.utils._testing.hierarchical import _make_hierarchical
 from sktime.utils._testing.series import _make_series
 
@@ -96,3 +101,32 @@ def test_metric_hierarchical(multioutput, multilevel, n_columns):
             assert isinstance(res, np.ndarray)
             assert res.ndim == 1
             assert len(res) == len(y_true.columns)
+
+
+@pytest.mark.parametrize("greater_is_better", [True, False])
+def test_custom_metric(greater_is_better):
+    """Test custom metric constructor, integration _DynamicForecastingErrorMetric."""
+    from sktime.utils.estimator_checks import check_estimator
+
+    y = load_airline()
+
+    def custom_mape(y_true, y_pred) -> float:
+
+        eps = np.finfo(np.float64).eps
+
+        result = np.mean(np.abs(y_true - y_pred) / np.maximum(np.abs(y_true), eps))
+
+        return float(result)
+
+    fc_scorer = make_forecasting_scorer(
+        func=custom_mape,
+        name="custom_mape",
+        greater_is_better=False,
+    )
+
+    assert isinstance(fc_scorer, _classes._DynamicForecastingErrorMetric)
+
+    score = fc_scorer(y, y)
+    assert isinstance(score, float)
+
+    check_estimator(fc_scorer, return_exceptions=False)