Repository: sktime/sktime

Issue:
[ENH] Scaled Logit Transformer
A scaled logit transform combined with a `TransformedTargetForecaster` ensures that the forecast stays between the specified lower and upper bounds. The idea is well explained by Rob Hyndman here: https://robjhyndman.com/hyndsight/forecasting-within-limits/

This will be similar to the prophet's logistic growth functionality. Related issue: #1868



Discussion:

Resolving patch:
diff --git a/sktime/transformations/series/scaledlogit.py b/sktime/transformations/series/scaledlogit.py
new file mode 100644
--- /dev/null
+++ b/sktime/transformations/series/scaledlogit.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Implements the scaled logit transformation."""
+
+__author__ = ["ltsaprounis"]
+__all__ = ["ScaledLogitTransformer"]
+
+from copy import deepcopy
+from warnings import warn
+
+import numpy as np
+
+from sktime.transformations.base import BaseTransformer
+
+
+class ScaledLogitTransformer(BaseTransformer):
+    r"""Scaled logit transform or Log transform.
+
+    If both lower_bound and upper_bound are not None, a scaled logit transform is
+    applied to the data. Otherwise, the transform applied is a log transform variation
+    that ensures the resulting values from the inverse transform are bounded
+    accordingly. The transform is applied to all scalar elements of the input array
+    individually.
+
+    Combined with a sktime.forecasting.compose.TransformedTargetForecaster it ensures
+    that the forecast stays between the specified bounds (lower_bound, upper_bound).
+
+    Parameters
+    ----------
+    lower_bound : float
+        lower bound of inverse transform function
+    upper_bound : float
+        upper bound of inverse transform function
+
+    See Also
+    --------
+    sktime.transformations.series.boxcox.LogTransformer :
+        Transformer input data using natural log. Can help normalize data and
+        compress variance of the series.
+    sktime.transformations.series.boxcox.BoxCoxTransformer :
+        Applies Box-Cox power transformation. Can help normalize data and
+        compress variance of the series.
+    sktime.transformations.series.exponent.ExponentTransformer :
+        Transform input data by raising it to an exponent. Can help compress
+        variance of series if a fractional exponent is supplied.
+    sktime.transformations.series.exponent.SqrtTransformer :
+        Transform input data by taking its square root. Can help compress
+        variance of input series.
+
+    Notes
+    -----
+    | The scaled logit transform is applied if both upper_bound and lower_bound are
+    | not None:
+    |   :math:`log(\frac{x - a}{b - x})`, where a is the lower and b is the upper bound.
+
+    | If upper_bound is None and lower_bound is not None the transform applied is
+    | a log transform of the form:
+    |   :math:`log(x - a)`
+
+    | If lower_bound is None and upper_bound is not None the transform applied is
+    | a log transform of the form:
+    |   :math:`- log(b - x)`
+
+    References
+    ----------
+    .. [1] Hyndsight - Forecasting within limits:
+        https://robjhyndman.com/hyndsight/forecasting-within-limits/
+    .. [2] Hyndman, R.J., & Athanasopoulos, G. (2021) Forecasting: principles and
+        practice, 3rd edition, OTexts: Melbourne, Australia. OTexts.com/fpp3.
+        Accessed on January 24th 2022.
+
+
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.transformations.series.scaledlogit import ScaledLogitTransformer
+    >>> from sktime.forecasting.ets import AutoETS
+    >>> from sktime.forecasting.compose import TransformedTargetForecaster
+    >>> y = load_airline()
+    >>> fcaster = TransformedTargetForecaster([
+    ...     ("scaled_logit", ScaledLogitTransformer(0, 650)),
+    ...     ("ets", AutoETS(sp=12, auto=True))
+    ... ])
+    >>> fcaster.fit(y)
+    TransformedTargetForecaster(...)
+    >>> y_pred = fcaster.predict(fh = np.arange(32))
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "X_inner_mtype": "np.ndarray",  # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "transform-returns-same-time-index": True,
+        "fit-in-transform": True,
+        "univariate-only": False,
+        "capability:inverse_transform": True,
+        "skip-inverse-transform": False,
+    }
+
+    def __init__(self, lower_bound=None, upper_bound=None):
+        self.lower_bound = lower_bound
+        self.upper_bound = upper_bound
+
+        super(ScaledLogitTransformer, self).__init__()
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing core logic, called from transform
+
+        Parameters
+        ----------
+        X : 2D np.ndarray
+            Data to be transformed
+        y : Series or Panel of mtype y_inner_mtype, default=None
+            Ignored argument for interface compatibility
+
+        Returns
+        -------
+        transformed version of X
+        """
+        if self.upper_bound is not None and np.any(X >= self.upper_bound):
+            warn(
+                "X in ScaledLogitTransformer should not have values "
+                "greater than upper_bound",
+                RuntimeWarning,
+            )
+
+        if self.lower_bound is not None and np.any(X <= self.lower_bound):
+            warn(
+                "X in ScaledLogitTransformer should not have values "
+                "lower than lower_bound",
+                RuntimeWarning,
+            )
+
+        if self.upper_bound and self.lower_bound:
+            X_transformed = np.log((X - self.lower_bound) / (self.upper_bound - X))
+        elif self.upper_bound is not None:
+            X_transformed = -np.log(self.upper_bound - X)
+        elif self.lower_bound is not None:
+            X_transformed = np.log(X - self.lower_bound)
+        else:
+            X_transformed = deepcopy(X)
+
+        return X_transformed
+
+    def _inverse_transform(self, X, y=None):
+        """Inverse transform, inverse operation to transform.
+
+        private _inverse_transform containing core logic, called from inverse_transform
+
+        Parameters
+        ----------
+        X : 2D np.ndarray
+            Data to be inverse transformed
+        y : Series or Panel of mtype y_inner_mtype, optional (default=None)
+            Ignored argument for interface compatibility
+
+        Returns
+        -------
+        inverse transformed version of X
+        """
+        if self.upper_bound and self.lower_bound:
+            X_inv_transformed = (self.upper_bound * np.exp(X) + self.lower_bound) / (
+                np.exp(X) + 1
+            )
+        elif self.upper_bound is not None:
+            X_inv_transformed = self.upper_bound - np.exp(-X)
+        elif self.lower_bound is not None:
+            X_inv_transformed = np.exp(X) + self.lower_bound
+        else:
+            X_inv_transformed = deepcopy(X)
+
+        return X_inv_transformed


Test patch:
diff --git a/sktime/transformations/series/tests/test_scaledlogit.py b/sktime/transformations/series/tests/test_scaledlogit.py
new file mode 100644
--- /dev/null
+++ b/sktime/transformations/series/tests/test_scaledlogit.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""ScaledLogit transform unit tests."""
+
+__author__ = ["ltsaprounis"]
+
+from warnings import warn
+
+import numpy as np
+import pytest
+from pandas.testing import assert_series_equal
+
+from sktime.datasets import load_airline
+from sktime.transformations.series.scaledlogit import ScaledLogitTransformer
+
+TEST_SERIES = np.array([30, 40, 60])
+
+
+@pytest.mark.parametrize(
+    "lower, upper, output",
+    [
+        (10, 70, np.log((TEST_SERIES - 10) / (70 - TEST_SERIES))),
+        (None, 70, -np.log(70 - TEST_SERIES)),
+        (10, None, np.log(TEST_SERIES - 10)),
+        (None, None, TEST_SERIES),
+    ],
+)
+def test_scaledlogit_transform(lower, upper, output):
+    """Test that we get the right output."""
+    transformer = ScaledLogitTransformer(lower, upper)
+    y_transformed = transformer.fit_transform(TEST_SERIES)
+    assert np.all(output == y_transformed)
+
+
+# Tests that all cases have a consistent inverse transform
+@pytest.mark.parametrize(
+    "lower, upper", [(0, 700), (None, 700), (0, None), (None, None)]
+)
+def test_scaledlogit_consistent_invesre_transform(lower, upper):
+    """Tests that all cases have a consistent inverse transform."""
+    y = load_airline()
+    transformer = ScaledLogitTransformer(lower, upper)
+    y_transformed = transformer.fit_transform(y)
+    y_restored = transformer.inverse_transform(y_transformed)
+    assert_series_equal(y, y_restored, check_names=False)
+
+
+@pytest.mark.parametrize(
+    "lower, upper, message",
+    [
+        (
+            0,
+            300,
+            (
+                "X in ScaledLogitTransformer should not have values greater"
+                "than upper_bound"
+            ),
+        ),
+        (
+            300,
+            700,
+            "X in ScaledLogitTransformer should not have values lower than lower_bound",
+        ),
+    ],
+)
+def test_scaledlogit_bound_errors(lower, upper, message):
+    """Tests all exceptions."""
+    y = load_airline()
+    with pytest.warns(RuntimeWarning):
+        ScaledLogitTransformer(lower, upper).fit_transform(y)
+        warn(message, RuntimeWarning)