Repository: alan-turing-institute/sktime
Pull request #3272 (2022-08-14 17:56:42)

[ENH] refactor of `OptionalPassthrough` as a delegator
This PR refactors `OptionalPassthrough` as a delegator, and adds an identity transformer `Id` which is used as part of this.

Features added through this approach:
* `self.transformer_` is consistent, always a transformer
* `update` is now properly delegated (previously, it was not), more generally, any delegation logic or bugfixes are delegated (pun intended) to `_DelegatedTransformer`

fkiraly: Say, @aiwalter, are there any specific tests of `OptionalPassthrough`? I.e., that it works as the transformer or identity?
aiwalter: > Say, @aiwalter, are there any specific tests of `OptionalPassthrough`? I.e., that it works as the transformer or identity?

Oh yep, I just created a PR: #3276

Patch:
diff --git a/sktime/datasets/_data_io.py b/sktime/datasets/_data_io.py
index 4106cf731..dbe764114 100644
--- a/sktime/datasets/_data_io.py
+++ b/sktime/datasets/_data_io.py
@@ -1573,9 +1573,9 @@ def write_ndarray_to_tsfile(
     # write class label line
     if class_label is not None:
         space_separated_class_label = " ".join(str(label) for label in class_label)
-        file.write(f"@classLabel true {space_separated_class_label}\n")
+        file.write(f"@classlabel true {space_separated_class_label}\n")
     else:
-        file.write("@class_label false\n")
+        file.write("@classlabel false\n")
     # begin writing the core data for each case
     # which are the series and the class value list if there is any
     file.write("@data\n")
diff --git a/sktime/transformations/base.py b/sktime/transformations/base.py
index eb1bef898..93f7ebd05 100644
--- a/sktime/transformations/base.py
+++ b/sktime/transformations/base.py
@@ -278,6 +278,20 @@ class BaseTransformer(BaseEstimator):
         else:
             return NotImplemented
 
+    def __neg__(self):
+        """Magic unary - (negation) method, return OptionalPassthrough of self.
+
+        Intuition: `OptionalPassthrough` is "not having transformer", as an option.
+
+        Returns
+        -------
+        `OptionalPassthrough` object, containing `self`, with `passthrough=False`.
+            The `passthrough` parameter can be set via `set_params`.
+        """
+        from sktime.transformations.series.compose import OptionalPassthrough
+
+        return OptionalPassthrough(self, passthrough=False)
+
     def __getitem__(self, key):
         """Magic [...] method, return column subsetted transformer.
 
diff --git a/sktime/transformations/series/compose.py b/sktime/transformations/series/compose.py
index 47399917e..6e75ec0e0 100644
--- a/sktime/transformations/series/compose.py
+++ b/sktime/transformations/series/compose.py
@@ -4,13 +4,14 @@
 """Meta-transformers for building composite transformers."""
 
 __author__ = ["aiwalter", "SveaMeyer13", "fkiraly"]
-__all__ = ["OptionalPassthrough", "ColumnwiseTransformer", "YtoX"]
+__all__ = ["Id", "OptionalPassthrough", "ColumnwiseTransformer", "YtoX"]
 
 from warnings import warn
 
 import pandas as pd
 from sklearn.utils.metaestimators import if_delegate_has_method
 
+from sktime.transformations._delegate import _DelegatedTransformer
 from sktime.transformations.base import BaseTransformer
 from sktime.utils.validation.series import check_series
 
@@ -36,11 +37,61 @@ CORE_MTYPES = [
 ]
 
 
-class OptionalPassthrough(BaseTransformer):
+class Id(_DelegatedTransformer):
+    """Identity transformer, returns data unchanged in transform/inverse_transform."""
+
+    _tags = {
+        "capability:inverse_transform": True,  # can the transformer inverse transform?
+        "univariate-only": False,  # can the transformer handle multivariate X?
+        "X_inner_mtype": CORE_MTYPES,  # which mtypes do _fit/_predict support for X?
+        # this can be a Panel mtype even if transform-input is Series, vectorized
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "fit_is_empty": True,  # is fit empty and can be skipped? Yes = True
+        "transform-returns-same-time-index": True,
+        # does transform return have the same time index as input X
+        "handles-missing-data": True,  # can estimator handle missing data?
+    }
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Parameters
+        ----------
+        X : any sktime compatible data, Series, Panel, or Hierarchical
+        y : optional, default=None
+            ignored, argument present for interface conformance
+
+        Returns
+        -------
+        X, identical to input
+        """
+        return X
+
+    def _inverse_transform(self, X, y=None):
+        """Inverse transform X and return an inverse transformed version.
+
+        private _inverse_transform containing core logic, called from inverse_transform
+
+        Parameters
+        ----------
+        X : any sktime compatible data, Series, Panel, or Hierarchical
+        y : optional, default=None
+            ignored, argument present for interface conformance
+
+        Returns
+        -------
+        X, identical to input
+        """
+        return X
+
+
+class OptionalPassthrough(_DelegatedTransformer):
     """Wrap an existing transformer to tune whether to include it in a pipeline.
 
     Allows tuning the implicit hyperparameter whether or not to use a
-    particular transformer inside a pipeline (e.g. TranformedTargetForecaster)
+    particular transformer inside a pipeline (e.g. TransformedTargetForecaster)
     or not. This is achieved by the hyperparameter `passthrough`
     which can be added to a tuning grid then (see example).
 
@@ -48,12 +99,20 @@ class OptionalPassthrough(BaseTransformer):
     ----------
     transformer : Estimator
         scikit-learn-like or sktime-like transformer to fit and apply to series.
+        this is a "blueprint" transformer, state does not change when `fit` is called
     passthrough : bool, default=False
        Whether to apply the given transformer or to just
         passthrough the data (identity transformation). If, True the transformer
         is not applied and the OptionalPassthrough uses the identity
         transformation.
 
+    Attributes
+    ----------
+    transformer_: transformer,
+        this clone is fitted when `fit` is called and provides `transform` and inverse
+        if passthrough = False, a clone of `transformer`passed
+        if passthrough = True, the identity transformer `Id`
+
     Examples
     --------
     >>> from sktime.datasets import load_airline
@@ -106,9 +165,8 @@ class OptionalPassthrough(BaseTransformer):
 
     def __init__(self, transformer, passthrough=False):
         self.transformer = transformer
-        self.transformer_ = None
         self.passthrough = passthrough
-        self._is_fitted = False
+
         super(OptionalPassthrough, self).__init__()
 
         # should be all tags, but not fit_is_empty
@@ -127,69 +185,12 @@ class OptionalPassthrough(BaseTransformer):
         ]
         self.clone_tags(transformer, tag_names=tags_to_clone)
 
-    def _fit(self, X, y=None):
-        """Fit transformer to X and y.
-
-        private _fit containing the core logic, called from fit
+        if passthrough:
+            self.transformer_ = Id()
+        else:
+            self.transformer_ = transformer.clone()
 
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _fit must support all types in it
-            Data to fit transform to
-        y : Series or Panel of mtype y_inner_mtype, default=None
-            Additional data, e.g., labels for tarnsformation
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-        """
-        if not self.passthrough:
-            self.transformer_ = self.transformer.clone()
-            self.transformer_._fit(X, y)
-        return self
-
-    def _transform(self, X, y=None):
-        """Transform X and return a transformed version.
-
-        private _transform containing the core logic, called from transform
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _transform must support all types in it
-            Data to be transformed
-        y : Series or Panel of mtype y_inner_mtype, default=None
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        transformed version of X
-        """
-        if not self.passthrough:
-            X = self.transformer_._transform(X, y)
-        return X
-
-    def _inverse_transform(self, X, y=None):
-        """Inverse transform, inverse operation to transform.
-
-        core logic
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _inverse_transform must support all types in it
-            Data to be inverse transformed
-        y : Series or Panel of mtype y_inner_mtype, optional (default=None)
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        inverse transformed version of X
-        """
-        if not self.passthrough:
-            X = self.transformer_._inverse_transform(X, y)
-        return X
+    _delegate_name = "transformer_"
 
     @classmethod
     def get_test_params(cls, parameter_set="default"):


Test patch:
diff --git a/sktime/transformations/tests/test_compose.py b/sktime/transformations/tests/test_compose.py
index 24be02488..7acd7e8c0 100644
--- a/sktime/transformations/tests/test_compose.py
+++ b/sktime/transformations/tests/test_compose.py
@@ -13,6 +13,7 @@ from sktime.datatypes import get_examples
 from sktime.transformations.compose import FeatureUnion, TransformerPipeline
 from sktime.transformations.panel.padder import PaddingTransformer
 from sktime.transformations.series.boxcox import LogTransformer
+from sktime.transformations.series.compose import OptionalPassthrough
 from sktime.transformations.series.exponent import ExponentTransformer
 from sktime.transformations.series.impute import Imputer
 from sktime.transformations.series.subset import ColumnSelect
@@ -224,3 +225,18 @@ def test_subset_getitem():
     )
     _assert_array_almost_equal(t_both.fit_transform(X), X_theta[["b__0", "b__2"]])
     _assert_array_almost_equal(t_none.fit_transform(X), X_theta)
+
+
+def test_dunder_neg():
+    """Test the neg dunder method, for wrapping in OptionalPassthrough."""
+    X = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+
+    t = ExponentTransformer(power=2)
+
+    tp = -t
+
+    assert isinstance(tp, OptionalPassthrough)
+    assert not tp.get_params()["passthrough"]
+    assert isinstance(tp.get_params()["transformer"], ExponentTransformer)
+
+    _assert_array_almost_equal(tp.fit_transform(X), X)