Repository: aeon-toolkit/aeon (python)

Issue:
[ENH] Implement `MockClusterer`
### Describe the feature or idea you want to propose

Add a `MockClusterer` to the `testing` module.

### Describe your proposed solution

This should be simple in line with the `classification` and `regression`, used for unit tests and similar.

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

_No response_

### Additional context

_No response_

Pull request description:
[ENH] Implemented `MockCluster` in testing module
<!--
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

Fixes #1529 

<!--
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
-->

#### What does this implement/fix? Explain your changes.

Implements `MockCluster` class inline with classification and regression, along with test cases.

<!--
A clear and concise description of what you have implemented.
-->

#### Does your contribution introduce a new dependency? If yes, which one?

No

<!--
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.
-->

#### Any other comments?

No

<!--
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.
-->

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [ ] 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. --> Will do this after this PR is reviewed
- [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.


<!--
Thanks for contributing!
-->


Patch:
diff --git a/aeon/transformations/series/__init__.py b/aeon/transformations/series/__init__.py
index d98ff373..cf2ba2bd 100644
--- a/aeon/transformations/series/__init__.py
+++ b/aeon/transformations/series/__init__.py
@@ -9,6 +9,7 @@ __all__ = [
     "Dobin",
     "MatrixProfileSeriesTransformer",
     "PLASeriesTransformer",
+    "SGSeriesTransformer",
     "StatsModelsACF",
     "StatsModelsPACF",
     "ThetaTransformer",
@@ -34,5 +35,6 @@ from aeon.transformations.series._matrix_profile import MatrixProfileSeriesTrans
 from aeon.transformations.series._pca import PCASeriesTransformer
 from aeon.transformations.series._pla import PLASeriesTransformer
 from aeon.transformations.series._scaled_logit import ScaledLogitSeriesTransformer
+from aeon.transformations.series._sg import SGSeriesTransformer
 from aeon.transformations.series._theta import ThetaTransformer
 from aeon.transformations.series.base import BaseSeriesTransformer
diff --git a/aeon/transformations/series/_sg.py b/aeon/transformations/series/_sg.py
new file mode 100644
index 00000000..19000cc0
--- /dev/null
+++ b/aeon/transformations/series/_sg.py
@@ -0,0 +1,75 @@
+"""Savitzky-Golay filter transformation."""
+
+__maintainer__ = ["Cyril-Meyer"]
+__all__ = ["SGSeriesTransformer"]
+
+
+from scipy.signal import savgol_filter
+
+from aeon.transformations.series.base import BaseSeriesTransformer
+
+
+class SGSeriesTransformer(BaseSeriesTransformer):
+    """Filter a times series using Savitzky-Golay (SG).
+
+    Parameters
+    ----------
+    window_length : int, default=5
+        The length of the filter window (i.e., the number of coefficients).
+        window_length must be less than or equal to the size of the input.
+
+    polyorder : int, default=2
+        The order of the polynomial used to fit the samples.
+        polyorder must be less than window_length.
+
+
+    Notes
+    -----
+    More information of the SciPy savgol_filter function used
+    https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.savgol_filter.html
+
+    References
+    ----------
+    .. [1] Savitzky, A., & Golay, M. J. (1964).
+       Smoothing and differentiation of data by simplified least squares procedures.
+       Analytical chemistry, 36(8), 1627-1639.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from aeon.transformations.series._sg import SGSeriesTransformer
+    >>> X = np.random.random((2, 100)) # Random series length 100
+    >>> sg = SGSeriesTransformer()
+    >>> X_ = sg.fit_transform(X)
+    >>> X_.shape
+    (2, 100)
+    """
+
+    _tags = {
+        "capability:multivariate": True,
+        "X_inner_type": "np.ndarray",
+        "fit_is_empty": True,
+    }
+
+    def __init__(self, window_length=5, polyorder=2):
+        self.window_length = window_length
+        self.polyorder = polyorder
+        super().__init__(axis=1)
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            time series in shape (n_channels, n_timepoints)
+        y : ignored argument for interface compatibility
+
+        Returns
+        -------
+        transformed version of X
+        """
+        # Compute SG
+        X_ = savgol_filter(X, self.window_length, self.polyorder)
+
+        return X_
diff --git a/docs/api_reference/transformations.rst b/docs/api_reference/transformations.rst
index d34cc96d..58be8632 100644
--- a/docs/api_reference/transformations.rst
+++ b/docs/api_reference/transformations.rst
@@ -194,6 +194,14 @@ Filtering and denoising
 
     DFTSeriesTransformer
 
+.. currentmodule:: aeon.transformations.series._sg
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    SGSeriesTransformer
+
 Slope
 ~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/examples/classification/shapelet_based.ipynb b/examples/classification/shapelet_based.ipynb
index 42a892b2..4c271f27 100644
--- a/examples/classification/shapelet_based.ipynb
+++ b/examples/classification/shapelet_based.ipynb
@@ -492,7 +492,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Similarly to visualizing transformers, we can also visualize individual shapelets of a shapelet-based classifier, and, when possible, plot the best shapelets based on feature importance criterions of some estimators :"
+    "Similarly to visualizing transformers, we can also visualize individual shapelets of a shapelet-based classifier, and, when possible, plot the best shapelets based on feature importance criterions of some estimators. The scope of these estimators is generally limited to those inheriting from scikit-learn’s BaseDecisionTree, BaseForest, or LinearClassifierMixin classes, as these are known to be among the most interpretable models where feature weights directly relate to the input features.\n",
+    "\n",
+    "For instance, in aeon's Rotational Forest, the application of Principal Component Analysis (PCA) complicates tracing feature importance back to individual shapelets. Since PCA transforms the original feature space into a new set of uncorrelated components, it becomes difficult to directly associate these components with the original shapelet features, reducing the interpretability in terms of feature importance."
    ]
   },
   {


Test patch:
diff --git a/aeon/clustering/tests/test_base.py b/aeon/clustering/tests/test_base.py
index 362ce21e..3d968946 100644
--- a/aeon/clustering/tests/test_base.py
+++ b/aeon/clustering/tests/test_base.py
@@ -2,8 +2,43 @@
 
 import numpy as np
 import numpy.random
+import pytest
 
 from aeon.clustering.base import BaseClusterer
+from aeon.testing.mock_estimators import MockCluster
+
+
+def test_correct_input():
+    """Tests errors raised with wrong inputs: X and/or y."""
+    dummy = MockCluster()
+
+    # list of strings X
+    X = ["list", "of", "invalid", "test", "strings"]
+    msg1 = r"ERROR passed a list containing <class 'str'>"
+    with pytest.raises(TypeError, match=msg1):
+        dummy.fit(X)
+
+    # dict X
+    X = {
+        0: "invalid",
+        1: "input",
+        2: "dict",
+    }
+    msg2 = r"ERROR passed input of type <class 'dict'>"
+    with pytest.raises(TypeError, match=msg2):
+        dummy.fit(X)
+
+    # 2d list of int X
+    X = [[1, 1, 1], [1, 3, 4]]
+    msg3 = r"lists should either 2D numpy arrays or pd.DataFrames"
+    with pytest.raises(TypeError, match=msg3):
+        dummy.fit(X)
+
+    # correct X
+    X = np.random.randn(5, 5)
+    dummy.fit(X)
+    assert (dummy.predict(X)).shape == (5,)
+    assert (dummy.predict_proba(X)).shape == (5,)
 
 
 class _TestClusterer(BaseClusterer):
diff --git a/aeon/testing/mock_estimators/__init__.py b/aeon/testing/mock_estimators/__init__.py
index 2bd1a872..e81543bd 100644
--- a/aeon/testing/mock_estimators/__init__.py
+++ b/aeon/testing/mock_estimators/__init__.py
@@ -8,6 +8,7 @@ __all__ = [
     "MockClassifierPredictProba",
     "MockClassifierFullTags",
     "MockClassifierMultiTestParams",
+    "MockCluster",
     "MockDeepClusterer",
     "MockSegmenter",
     "SupervisedMockSegmenter",
@@ -27,7 +28,7 @@ from aeon.testing.mock_estimators._mock_classifiers import (
     MockClassifierMultiTestParams,
     MockClassifierPredictProba,
 )
-from aeon.testing.mock_estimators._mock_clusterers import MockDeepClusterer
+from aeon.testing.mock_estimators._mock_clusterers import MockCluster, MockDeepClusterer
 from aeon.testing.mock_estimators._mock_collection_transformers import (
     MockCollectionTransformer,
 )
diff --git a/aeon/testing/mock_estimators/_mock_clusterers.py b/aeon/testing/mock_estimators/_mock_clusterers.py
index d846e1ba..ff79c192 100644
--- a/aeon/testing/mock_estimators/_mock_clusterers.py
+++ b/aeon/testing/mock_estimators/_mock_clusterers.py
@@ -1,8 +1,32 @@
 import numpy as np
 
+from aeon.clustering.base import BaseClusterer
 from aeon.clustering.deep_learning.base import BaseDeepClusterer
 
 
+class MockCluster(BaseClusterer):
+    """Mock Cluster for testing base class fit/predict."""
+
+    def __init__(self, n_clusters: int = None):
+        super().__init__(n_clusters)
+
+    def _fit(self, X):
+        """Mock fit."""
+        return self
+
+    def _predict(self, X):
+        """Mock predict."""
+        return np.zeros(len(X))
+
+    def _predict_proba(self, X):
+        """Mock predict proba."""
+        y = np.random.rand(len(X))
+        return y
+
+    def _score(self, X, y):
+        return np.random.randn(1)
+
+
 class MockDeepClusterer(BaseDeepClusterer):
     """Mock Deep Clusterer for testing empty base deep class save utilities."""
 
diff --git a/aeon/transformations/series/tests/test_sg.py b/aeon/transformations/series/tests/test_sg.py
new file mode 100644
index 00000000..7df29700
--- /dev/null
+++ b/aeon/transformations/series/tests/test_sg.py
@@ -0,0 +1,47 @@
+"""Tests for SG transformation."""
+
+__maintainer__ = []
+
+import numpy as np
+import pytest
+
+
+@pytest.mark.parametrize("window_length", [5, 9, 17])
+@pytest.mark.parametrize("polyorder", [2, 3, 4])
+def test_sg(window_length, polyorder):
+    """Test the functionality of SG transformation."""
+    n_samples = 100
+    t = np.linspace(0, 10, n_samples)
+    x1 = (
+        0.5 * np.sin(2 * np.pi * 1 * t)
+        + 0.2 * np.sin(2 * np.pi * 5 * t)
+        + 0.1 * np.sin(2 * np.pi * 10 * t)
+    )
+    x2 = (
+        0.4 * np.sin(2 * np.pi * 1.5 * t)
+        + 0.3 * np.sin(2 * np.pi * 4 * t)
+        + 0.1 * np.sin(2 * np.pi * 8 * t)
+    )
+    x12 = np.array([x1, x2])
+    x12r = x12 + np.random.random((2, n_samples)) * 0.25
+
+    from aeon.transformations.series._sg import SGSeriesTransformer
+
+    sg = SGSeriesTransformer(window_length=window_length, polyorder=polyorder)
+    x_1 = sg.fit_transform(x1)
+    x_2 = sg.fit_transform(x2)
+    x_12 = sg.fit_transform(x12)
+    x_12_r = sg.fit_transform(x12r)
+
+    """
+    # Visualize smoothing
+    import matplotlib.pyplot as plt
+    plt.plot(x12r[0])
+    plt.plot(x_12_r[0])
+    plt.savefig(fname=f'SG_{window_length}_{polyorder}.png')
+    plt.clf()
+    """
+
+    np.testing.assert_almost_equal(x_1[0], x_12[0], decimal=4)
+    np.testing.assert_almost_equal(x_2[0], x_12[1], decimal=4)
+    assert x_12.shape == x_12_r.shape