Repository: aeon-toolkit/aeon (python)

Issue:
[ENH] Implement Yeo-Johnson Series Transformer
### Describe the feature or idea you want to propose

a long time ago I experimented with the Yeo-Johnson regressor transform.

https://ieeexplore.ieee.org/document/1716845

Yeo-Johnson is an extension of Box-Cox that allows xero and negative regressor values. We have an implementation of Box Cox, it would be nice to include Yeo-Johnson 


### Describe your proposed solution

either extend Box Cox once it is moved into series tranformers, or implement a new BaseSeriesTransformer

### Describe alternatives you've considered, if relevant

_No response_

### Additional context

_No response_

Pull request:
[ENH] Add Yeo Johnson Transform
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at our
contribution guide: https://www.aeon-toolkit.org/en/stable/contributing.html

Feel free to delete sections of this template if they do not apply to your PR,
avoid submitting a blank template or empty sections.
-->

#### Reference Issues/PRs

<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->

Fixes #1475

#### What does this implement/fix? Explain your changes.

<!--
A clear and concise description of what you have implemented.
-->

I have added the basic YeoJohnson Transform to the series transform section. This uses the scipy implementation to compute the transform, but implements the aeon interface on top of this.

#### Does your contribution introduce a new dependency? If yes, which one?

<!--
If your contribution does add a dependency, we may suggest adding it as an
optional/soft dependency to keep external dependencies of the core aeon package
to a minimum.
-->

No new dependencies

#### Any other comments?

<!--
Please be aware that we are a loose team of volunteers so patience is
necessary; assistance handling other issues is very welcome. We value all
user contributions, no matter how minor they are. If we are slow to
review, either the pull request needs some benchmarking, tinkering,
convincing, etc. or more likely the reviewers are simply busy. In either
case, we ask for your understanding during the review process.
-->

N/A

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/aeon-toolkit/aeon/blob/main/.all-contributorsrc). Alternatively, you can use the [@all-contributors](https://allcontributors.org/docs/en/bot/usage) bot to do this for you.
- [x] The PR title starts with either [ENH], [MNT], [DOC], [BUG], [REF], [DEP] or [GOV] indicating whether the PR topic is related to enhancement, maintenance, documentation, bugs, refactoring, deprecation or governance.

##### For new estimators and functions
- [x] I've added the estimator to the online [API documentation](https://www.aeon-toolkit.org/en/latest/api_reference.html).
- [x] (OPTIONAL) I've added myself as a `__maintainer__` at the top of relevant files and want to be contacted regarding its maintenance. Unmaintained files may be removed. This is for the full file, and you should not add yourself if you are just making minor changes or do not want to help maintain its contents.


<!--
Thanks for contributing!
-->


Patch:
diff --git a/aeon/transformations/series/_yeojohnson.py b/aeon/transformations/series/_yeojohnson.py
new file mode 100644
index 00000000..edc046bb
--- /dev/null
+++ b/aeon/transformations/series/_yeojohnson.py
@@ -0,0 +1,143 @@
+"""Implemenents Yeo-Johnson Transformation."""
+
+__maintainer__ = []
+__all__ = ["YeoJohnsonTransformer"]
+
+import numpy as np
+from numba import njit
+from scipy.stats import yeojohnson_normmax
+
+from aeon.transformations.series.base import BaseSeriesTransformer
+
+
+class YeoJohnsonTransformer(BaseSeriesTransformer):
+    r"""Yeo-Johnson power transform.
+
+    Yeo-Johnson transformation is related to the BoxCox transformation, and is
+    a power transformation that is used to make data more normally distributed
+    and stabilize its variance based on the hyperparameter lambda. [1]_
+
+    The YeoJohnsonTransformer solves for the lambda parameter used in the
+    Yeo-Johnson transformation using maximum likelihood estimation,
+    on input data provided to `fit`.
+
+    The Yeo-Johnson transformation is defined as :math:`\[
+        \phi(\lambda,y) =
+        \begin{cases}
+        log(y+1) & \text{if $\lambda=0, y\geq0$} \\
+        \frac{(y+1)^\lambda - 1}{\lambda} & \text{if $\lambda\neq0, y\geq0$} \\
+        -log(1-y) & \text{if $\lambda=2, y<0$} \\
+        -\frac{(1-y)^{2-\lambda}-1)}{2-\lambda} & \text{if $\lambda\neq2, y<0$}
+        \end{cases}
+    \]`.
+
+    Parameters
+    ----------
+    bounds : tuple
+        Lower and upper bounds used to restrict the feasible range
+        when solving for the value of lambda.
+    lambda_ : float
+        The Yeo-Johnson lambda parameter. If not supplied, it is solved for based
+        on the data provided in `fit`.
+
+    See Also
+    --------
+    aeon.transformations.boxcox.BoxCoxTransformer :
+        Transform input data by using the Box-Cox power transform. Used to
+        make data more normally distributed and stabilize its variance based
+        on the hyperparameter lambda.
+    aeon.transformations.boxcox.LogTransformer :
+        Transform input data using natural log. Can help normalize data and
+        compress variance of the series.
+    aeon.transformations.exponent.ExponentTransformer :
+        Transform input data by raising it to an exponent. Can help compress
+        variance of series if a fractional exponent is supplied.
+    aeon.transformations.exponent.SqrtTransformer :
+        Transform input data by taking its square root. Can help compress
+        variance of input series.
+
+    References
+    ----------
+    .. [1] Yeo and R.A. Johnson, “A New Family of Power Transformations to
+        Improve Normality or Symmetry”, Biometrika 87.4 (2000).
+
+    Examples
+    --------
+    >>> from aeon.transformations.series._yeojohnson import YeoJohnsonTransformer
+    >>> from aeon.datasets import load_airline
+    >>> y = load_airline()
+    >>> transformer = YeoJohnsonTransformer()
+    >>> y_hat = transformer.fit_transform(y)
+    """
+
+    # tag values specific to SeriesTransformers
+    _tags = {
+        "capability:univariate": True,
+        "capability:multivariate": False,
+    }
+
+    def __init__(self, lmbda=None, bounds=None):
+        self.bounds = bounds
+        self.lmbda = lmbda
+        super().__init__(axis=1)
+
+    def _fit(self, X, y=None):
+        """
+        Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : 2D np.ndarray (n x 1)
+            Data to be transformed
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+        """
+        if self.lmbda is None:
+            X = X.flatten()
+            self._lambda = yeojohnson_normmax(X, brack=self.bounds)
+        else:
+            self._lambda = self.lmbda
+        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 : 2D np.ndarray (1 x n_timepoints)
+            Data to be transformed
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : 2D np.ndarray
+            transformed version of X
+        """
+        return _yeo_johnson_transform(X, self._lambda)
+
+
+@njit(fastmath=True, cache=True)
+def _yeo_johnson_transform(X, lmbda):
+    X_shape = X.shape
+    Xt = X.flatten()
+    X_gte_0 = Xt >= 0
+    X_lt_0 = Xt < 0
+    if lmbda != 0:
+        Xt[X_gte_0] = (np.power(Xt[X_gte_0] + 1, lmbda) - 1) / lmbda
+    elif lmbda == 0:
+        Xt[X_gte_0] = np.log(Xt[X_gte_0] + 1)
+    if lmbda != 2:
+        Xt[X_lt_0] = -(np.power(-Xt[X_lt_0] + 1, 2 - lmbda) - 1) / (2 - lmbda)
+    elif lmbda == 2:
+        Xt[X_lt_0] = -np.log(-Xt[X_lt_0] + 1)
+    Xt = Xt.reshape(X_shape)
+    return Xt
diff --git a/docs/api_reference/transformations.rst b/docs/api_reference/transformations.rst
index d1c01552..d471923f 100644
--- a/docs/api_reference/transformations.rst
+++ b/docs/api_reference/transformations.rst
@@ -189,6 +189,7 @@ Series transforms
     StatsModelsPACF
     BKFilter
     BoxCoxTransformer
+    YeoJohnsonTransformer
     Dobin
     ScaledLogitSeriesTransformer
     SIVSeriesTransformer


Test patch:
diff --git a/aeon/transformations/series/tests/test_yeojohnson.py b/aeon/transformations/series/tests/test_yeojohnson.py
new file mode 100644
index 00000000..ddbf3e2f
--- /dev/null
+++ b/aeon/transformations/series/tests/test_yeojohnson.py
@@ -0,0 +1,21 @@
+"""Tests for YeoJohnsonTransformer."""
+
+__maintainer__ = []
+__all__ = []
+
+import numpy as np
+from scipy.stats import yeojohnson
+
+from aeon.datasets import load_airline
+from aeon.transformations.series._yeojohnson import YeoJohnsonTransformer
+
+
+def test_yeojohnson_against_scipy():
+    y = load_airline()
+
+    t = YeoJohnsonTransformer()
+    actual = t.fit_transform(y)
+
+    excepted, expected_lambda = yeojohnson(y.values)
+    np.testing.assert_almost_equal(actual, excepted, decimal=12)
+    assert t._lambda == expected_lambda