Repository: aeon-toolkit/aeon
Pull request #2041 (2024-09-08 11:02:26)

Added tags for clustering
What does this implement/fix? Explain your changes.
I've added the necessary tags for some files under clustering.
#2035 

aeon-actions-bot[bot]: 
## Thank you for contributing to `aeon`

I did not find any labels to add based on the title. Please add the [ENH], [MNT], [BUG], [DOC], [REF], [DEP] and/or [GOV] tags to your pull requests titles. For now you can add the labels manually.
I have added the following labels to this PR based on the changes made: **[ $\\color{#4011F3}{\\textsf{clustering}}$ ]**. Feel free to change these if they do not properly represent the PR.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/2041/checks) tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.

If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR unless it is a draft.

Don't hesitate to ask questions on the `aeon` [Slack](https://join.slack.com/t/aeon-toolkit/shared_invite/zt-22vwvut29-HDpCu~7VBUozyfL_8j3dLA) channel if you have any.

### PR CI actions

These checkboxes will add labels to enable/disable CI functionality for this PR. This may not take effect immediately, and a new commit may be required to run the new configuration.

- [ ] Run `pre-commit` checks for all files
- [ ] Run all `pytest` tests and configurations
- [ ] Run all notebook example tests
- [ ] Run numba-disabled `codecov` tests
- [ ] Stop automatic `pre-commit` fixes (always disabled for drafts)
- [ ] Push an empty commit to re-run CI checks
    
aryanpola: @chrisholder I've run the pre-commit and showed no errors. Can you help me with the errors.
TonyBagnall: thanks for this, but it not what quite what I meant. If you look at classification,  we have a tag on the type of algorithm, e.g

"algorithm_type": "distance"

or

"algorithm_type": "deeplearning",

this is the only tag we want adding, not all the constructor arguments. 

Patch:
diff --git a/CODEOWNERS b/CODEOWNERS
index 7c517134..10bbd619 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -15,15 +15,10 @@ aeon/clustering/ @chrisholder @TonyBagnall
 
 aeon/distances/ @chrisholder @TonyBagnall
 
-aeon/forecasting/ @aiwalter @guzalbulatova @ltsaprounis
-
 aeon/networks/ @hadifawaz1999
 
-aeon/performance_metrics/forecasting/ @aiwalter
 aeon/performance_metrics/anomaly_detection/ @codelionx @MatthewMiddlehurst
 
-aeon/pipeline/ @aiwalter
-
 aeon/regression/ @MatthewMiddlehurst @TonyBagnall @dguijo
 aeon/regression/deep_learning @hadifawaz1999 @MatthewMiddlehurst @TonyBagnall @dguijo
 
@@ -34,18 +29,16 @@ aeon/similarity_search/ @baraline
 aeon/transformations/collection/ @MatthewMiddlehurst @TonyBagnall
 aeon/transformations/collection/dictionary_based/ @patrickzib @MatthewMiddlehurst
 aeon/transformations/collection/dictionary_based/_sax.py @patrickzib @MatthewMiddlehurst @hadifawaz1999
-aeon/transformations/collection/shapelet_based/dilated_shapelet_transform.py @baraline
+aeon/transformations/collection/shapelet_based/_dilated_shapelet_transform.py @baraline
 
 aeon/transformations/series/ @TonyBagnall
-
-aeon/transformations/ @aiwalter
-aeon/transformations/clasp.py @patrickzib
-aeon/transformations/scaledlogit.py @ltsaprounis
-aeon/transformations/theta.py @GuzalBulatova
+aeon/transformations/series/_clasp.py @patrickzib
+aeon/transformations/series/_scaled_logit.py @ltsaprounis
+aeon/transformations/series/_theta.py @GuzalBulatova
 
 aeon/utils/numba/ @baraline @MatthewMiddlehurst
 
-aeon/visualisation/ @baraline
+aeon/visualisation/ @baraline @MatthewMiddlehurst
 
 .github/ @aeon-toolkit/aeon-infrastructure-workgroup
 build_tools/ @aeon-toolkit/aeon-infrastructure-workgroup
diff --git a/aeon/clustering/_k_means.py b/aeon/clustering/_k_means.py
index 8c46a5df..8f46993b 100644
--- a/aeon/clustering/_k_means.py
+++ b/aeon/clustering/_k_means.py
@@ -40,7 +40,7 @@ class TimeSeriesKMeans(BaseClusterer):
     ----------
     n_clusters : int, default=8
         The number of clusters to form as well as the number of centroids to generate.
-    init_algorithm : str or np.ndarray, default='random'
+    init : str or np.ndarray, default='random'
         Random is the default and simply chooses k time series at random as
         centroids. It is fast but sometimes yields sub-optimal clustering.
         Kmeans++ [2] and is slower but often more
@@ -151,12 +151,13 @@ class TimeSeriesKMeans(BaseClusterer):
 
     _tags = {
         "capability:multivariate": True,
+        "algorithm_type": "distance",
     }
 
     def __init__(
         self,
         n_clusters: int = 8,
-        init_algorithm: Union[str, np.ndarray] = "random",
+        init: Union[str, np.ndarray] = "random",
         distance: Union[str, Callable] = "msm",
         n_init: int = 10,
         max_iter: int = 300,
@@ -166,7 +167,20 @@ class TimeSeriesKMeans(BaseClusterer):
         averaging_method: Union[str, Callable[[np.ndarray], np.ndarray]] = "ba",
         distance_params: dict = None,
         average_params: dict = None,
+        init_algorithm: Union[str, np.ndarray] = None,
     ):
+        if init_algorithm is not None:
+            import warnings
+
+            warnings.warn(
+                "The 'init_algorithm' parameter is deprecated and will be "
+                "removed in a future. Version Use 'init' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            self.init = self.init_algorithm
+        else:
+            self.init = init
         self.init_algorithm = init_algorithm
         self.distance = distance
         self.n_init = n_init
@@ -184,7 +198,7 @@ class TimeSeriesKMeans(BaseClusterer):
         self.n_iter_ = 0
 
         self._random_state = None
-        self._init_algorithm = None
+        self._init = None
         self._averaging_method = None
         self._average_params = None
 
@@ -225,10 +239,10 @@ class TimeSeriesKMeans(BaseClusterer):
         self.n_iter_ = best_iters
 
     def _fit_one_init(self, X: np.ndarray) -> tuple:
-        if isinstance(self._init_algorithm, Callable):
-            cluster_centres = self._init_algorithm(X)
+        if isinstance(self._init, Callable):
+            cluster_centres = self._init(X)
         else:
-            cluster_centres = self._init_algorithm.copy()
+            cluster_centres = self._init.copy()
         prev_inertia = np.inf
         prev_labels = None
         for i in range(self.max_iter):
@@ -287,22 +301,19 @@ class TimeSeriesKMeans(BaseClusterer):
     def _check_params(self, X: np.ndarray) -> None:
         self._random_state = check_random_state(self.random_state)
 
-        if isinstance(self.init_algorithm, str):
-            if self.init_algorithm == "random":
-                self._init_algorithm = self._random_center_initializer
-            elif self.init_algorithm == "kmeans++":
-                self._init_algorithm = self._kmeans_plus_plus_center_initializer
-            elif self.init_algorithm == "first":
-                self._init_algorithm = self._first_center_initializer
+        if isinstance(self.init, str):
+            if self.init == "random":
+                self._init = self._random_center_initializer
+            elif self.init == "kmeans++":
+                self._init = self._kmeans_plus_plus_center_initializer
+            elif self.init == "first":
+                self._init = self._first_center_initializer
         else:
-            if (
-                isinstance(self.init_algorithm, np.ndarray)
-                and len(self.init_algorithm) == self.n_clusters
-            ):
-                self._init_algorithm = self.init_algorithm.copy()
+            if isinstance(self.init, np.ndarray) and len(self.init) == self.n_clusters:
+                self._init = self.init.copy()
             else:
                 raise ValueError(
-                    f"The value provided for init_algorithm: {self.init_algorithm} is "
+                    f"The value provided for init: {self.init} is "
                     f"invalid. The following are a list of valid init algorithms "
                     f"strings: random, kmedoids++, first. You can also pass a"
                     f"np.ndarray of size (n_clusters, n_channels, n_timepoints)"
diff --git a/aeon/clustering/_k_medoids.py b/aeon/clustering/_k_medoids.py
index 3fdd719f..021f20d7 100644
--- a/aeon/clustering/_k_medoids.py
+++ b/aeon/clustering/_k_medoids.py
@@ -144,6 +144,7 @@ class TimeSeriesKMedoids(BaseClusterer):
 
     _tags = {
         "capability:multivariate": True,
+        "algorithm_type": "distance",
     }
 
     def __init__(
diff --git a/aeon/clustering/_k_shape.py b/aeon/clustering/_k_shape.py
index 92282b37..8331533a 100644
--- a/aeon/clustering/_k_shape.py
+++ b/aeon/clustering/_k_shape.py
@@ -71,6 +71,7 @@ class TimeSeriesKShape(BaseClusterer):
     _tags = {
         "capability:multivariate": True,
         "python_dependencies": "tslearn",
+        "algorithm_type": "distance",
     }
 
     def __init__(
diff --git a/aeon/clustering/_k_shapes.py b/aeon/clustering/_k_shapes.py
index 95951229..31dfcd6d 100644
--- a/aeon/clustering/_k_shapes.py
+++ b/aeon/clustering/_k_shapes.py
@@ -72,6 +72,7 @@ class TimeSeriesKShapes(BaseClusterer):
     _tags = {
         "capability:multivariate": True,
         "python_dependencies": "tslearn",
+        "algorithm_type": "distance",
     }
 
     def __init__(
diff --git a/aeon/datasets/tsc_datasets.py b/aeon/datasets/tsc_datasets.py
index 53c5f7ab..105aa6d7 100644
--- a/aeon/datasets/tsc_datasets.py
+++ b/aeon/datasets/tsc_datasets.py
@@ -27,6 +27,10 @@ Ensembles Version 1.0 (HIVE-COTE v1.0). Lecture Notes in Computer Science. in pr
     The great multivariate time series classification bake off: a review and
     experimental evaluation of recent algorithmic advances,
     Data Mining and Knowledge Discovery, 2020.
+[5] Middlehurst, M., Schäfer, P. & Bagnall, A.
+    Bake off redux: a review and experimental evaluation of recent time series
+    classification algorithms. Data Min Knowl Disc 38, 1958–2031 (2024).
+    https://doi.org/10.1007/s10618-024-01022-1
 
 """
 
@@ -628,11 +632,38 @@ tsc_zenodo = {
     "UWaveGestureLibrary": 11206282,
 }
 
-
-# TODO: Add the following datasets:
 # 30 new univariate classification problems used in the bake off [5]. Some are new,
 # some are discrete versions of regression problems, some are equal length versions
 # of the current UCR problems and some are no missing versions of the current 128 UCR.
-# All can be loaded from the timeseriesclassification.com archive directly using the
-# function load_classification. This will download all available versions of the
-# problem in one directory. You can then set which version through function arguments.
+univariate_bake_off_2024 = {
+    "AconityMINIPrinterLarge",  # AconityMINIPrinterLarge_eq
+    "AconityMINIPrinterSmall",  # AconityMINIPrinterSmall_eq
+    "AllGestureWiimoteX",  # AllGestureWiimoteX_eq
+    "AllGestureWiimoteY",  # AllGestureWiimoteY_eq
+    "AllGestureWiimoteZ",  # AllGestureWiimoteZ_eq
+    "AsphaltObstacles",  # AsphaltObstaclesUni_eq
+    "AsphaltPavementType",  # AsphaltPavementTypeUni_eq
+    "AsphaltRegularity",  # AsphaltRegularityUni_eq
+    "Colposcopy",  # Colposcopy
+    "Covid3Month",  # Covid3Month_disc
+    "DodgerLoopDay",  # DodgerLoopDay_nmv
+    "DodgerLoopGame",  # DodgerLoopGame_nmv
+    "DodgerLoopWeekend",  # DodgerLoopWeekend_nmv
+    "ElectricDeviceDetection",  # ElectricDeviceDetection
+    "FloodModeling1",  # FloodModeling1_disc
+    "FloodModeling2",  # FloodModeling2_disc
+    "FloodModeling3",  # FloodModeling3_disc
+    "GestureMidAirD1",  # GestureMidAirD1_eq
+    "GestureMidAirD2",  # GestureMidAirD2_eq
+    "GestureMidAirD3",  # GestureMidAirD3_eq
+    "GesturePebbleZ1",  # GesturePebbleZ1_eq
+    "GesturePebbleZ2",  # GesturePebbleZ2_eq
+    "KeplerLightCurves",  # KeplerLightCurves
+    "MelbournePedestrian",  # MelbournePedestrian_nmv
+    "PhoneHeartbeatSound",  # PhoneHeartbeatSound
+    "PickupGestureWiimoteZ",  # PickupGestureWiimoteZ_eq
+    "PLAID",  # PLAID_eq
+    "ShakeGestureWiimoteZ",  # ShakeGestureWiimoteZ_eq
+    "SharePriceIncrease",  # SharePriceIncrease
+    "Tools",  # Tools
+}
diff --git a/aeon/transformations/collection/__init__.py b/aeon/transformations/collection/__init__.py
index d503ad73..ea013148 100644
--- a/aeon/transformations/collection/__init__.py
+++ b/aeon/transformations/collection/__init__.py
@@ -7,6 +7,7 @@ __all__ = [
     # transformers
     "AutocorrelationFunctionTransformer",
     "ARCoefficientTransformer",
+    "DownsampleTransformer",
     "ElbowClassSum",
     "ElbowClassPairwise",
     "DWTTransformer",
@@ -27,6 +28,7 @@ __all__ = [
 from aeon.transformations.collection._collection_wrapper import (
     CollectionToSeriesWrapper,
 )
+from aeon.transformations.collection._downsample import DownsampleTransformer
 from aeon.transformations.collection.acf import AutocorrelationFunctionTransformer
 from aeon.transformations.collection.ar_coefficient import ARCoefficientTransformer
 from aeon.transformations.collection.base import BaseCollectionTransformer
diff --git a/aeon/transformations/collection/_downsample.py b/aeon/transformations/collection/_downsample.py
new file mode 100644
index 00000000..a6db5c09
--- /dev/null
+++ b/aeon/transformations/collection/_downsample.py
@@ -0,0 +1,124 @@
+"""Downsample transformer."""
+
+__maintainer__ = []
+__all__ = ["DownsampleTransformer"]
+
+import numpy as np
+
+from aeon.transformations.collection.base import BaseCollectionTransformer
+
+
+class DownsampleTransformer(BaseCollectionTransformer):
+    """Downsample the time dimension of a collection of time series.
+
+    Parameters
+    ----------
+    downsample_by : str, optional
+        The method to downsample by, either "frequency" or "proportion",
+        by default "frequency".
+    source_sfreq : int or float, optional
+        The source sampling frequency in Hz.
+        Required if `downsample_by = "frequency"`, by default 2.0.
+    target_sfreq : int or float, optional
+        The target sampling frequency in Hz.
+        Required if `downsample_by = "frequency"`, by default 1.0.
+    proportion : float, optional
+        The proportion between 0-1 to downsample by.
+        Required if `downsample_by = "proportion"`, by default None.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from aeon.transformations.collection import DownsampleTransformer
+    >>> # unequal length collection of time series
+    >>> X = [np.array([[1, 2, 3, 4, 5, 6]]), np.array([[7, 8, 9, 10]])]
+    >>> transformer = DownsampleTransformer(
+    ...     downsample_by="frequency",
+    ...     source_sfreq=2.0,
+    ...     target_sfreq=1.0,
+    ... )
+    >>> transformer.fit_transform(X)
+    [array([[1, 3, 5]]), array([[7, 9]])]
+    >>> # same downsampling, but by proportion instead of frequency
+    >>> transformer.set_params(downsample_by="proportion", proportion=0.5)
+    DownsampleTransformer(downsample_by='proportion', proportion=0.5)
+    >>> transformer.fit_transform(X)
+    [array([[1, 3, 5]]), array([[7, 9]])]
+    """
+
+    _tags = {
+        "X_inner_type": ["np-list", "numpy3D"],
+        "capability:multivariate": True,
+        "capability:unequal_length": True,
+        "fit_is_empty": True,
+    }
+
+    def __init__(
+        self,
+        downsample_by="frequency",
+        source_sfreq=2.0,
+        target_sfreq=1.0,
+        proportion=None,
+    ):
+        self.downsample_by = downsample_by
+        self.source_sfreq = source_sfreq
+        self.target_sfreq = target_sfreq
+        self.proportion = proportion
+        super().__init__()
+
+    def _transform(self, X, y=None):
+        """Transform the input collection to downsampled collection.
+
+        Parameters
+        ----------
+        X : list or np.ndarray of shape (n_cases, n_channels, n_timepoints)
+            Input time series collection where n_timepoints can vary over cases.
+        y : None
+            Ignored for interface compatibility, by default None.
+
+        Returns
+        -------
+        list of 2D numpy arrays of shape [(n_channels, n_timepoints_downsampled), ...]
+        or np.ndarray of shape (n_cases, n_channels, n_timepoints_downsampled)
+            Downsampled time series collection.
+        """
+        self._check_parameters()
+
+        if self.downsample_by == "frequency":
+            step = int(self.source_sfreq / self.target_sfreq)
+        elif self.downsample_by == "proportion":
+            step = int(1 / (1 - self.proportion))
+
+        X_downsampled = []
+        for x in X:
+            n_timepoints = x.shape[-1]
+            indices = np.arange(0, n_timepoints, step)
+            X_downsampled.append(x[:, indices])
+
+        if isinstance(X, np.ndarray):
+            return np.asarray(X_downsampled)
+        else:
+            return X_downsampled
+
+    def _check_parameters(self):
+        """Check the values of parameters passed to DownsampleTransformer.
+
+        Raises
+        ------
+        ValueError
+            If `downsample_by` is not "frequency" or "proportion".
+            If `source_sfreq` < `target_sfreq` when `downsample_by = "frequency"`.
+            If `proportion` is not between 0-1 when `downsample_by = "proportion"`.
+        """
+        if self.downsample_by not in ["frequency", "proportion"]:
+            raise ValueError('downsample_by must be either "frequency" or "proportion"')
+
+        if self.downsample_by == "frequency":
+            if self.source_sfreq is None or self.target_sfreq is None:
+                raise ValueError("source_sfreq and target_sfreq must be provided")
+            if self.source_sfreq < self.target_sfreq:
+                raise ValueError("source_sfreq must be > target_sfreq")
+
+        if self.downsample_by == "proportion":
+            if self.proportion is None or not (0 < self.proportion < 1):
+                raise ValueError("proportion must be provided and between 0-1.")
diff --git a/aeon/visualisation/estimator/_shapelets.py b/aeon/visualisation/estimator/_shapelets.py
index 9bcfafaa..43e670d1 100644
--- a/aeon/visualisation/estimator/_shapelets.py
+++ b/aeon/visualisation/estimator/_shapelets.py
@@ -647,7 +647,7 @@ class ShapeletClassifierVisualizer:
 
     def _get_shp_importance(self, class_id):
         """
-        Return the shapelet importance for a speficied class.
+        Return the shapelet importance for a specified class.
 
         Parameters
         ----------
@@ -697,7 +697,6 @@ class ShapeletClassifierVisualizer:
                     class_0_coefs[:, mask] = -class_0_coefs[:, mask]
                     class_1_coefs[:, ::3] = -class_1_coefs[:, ::3]
 
-                    # Append the two modified coefs arrays along axis 0
                     coefs = np.append(class_0_coefs, class_1_coefs, axis=0)
                     warnings.warn(
                         "Shapelet importance ranking may be unreliable "
@@ -708,10 +707,24 @@ class ShapeletClassifierVisualizer:
                         "or using an alternative method.",
                         stacklevel=1,
                     )
+                    coefs = coefs[class_id]
                 else:
                     coefs = np.append(coefs, -coefs, axis=0)
-            coefs = coefs[class_id]
-
+                    coefs = coefs[class_id]
+            elif isinstance(self.estimator, RDSTClassifier):
+                coefs = coefs[class_id]
+                coefs[::3] = -coefs[::3]
+                warnings.warn(
+                    "Shapelet importance ranking may be unreliable "
+                    "when using linear classifiers with RDST. "
+                    "This is due to the interaction between argmin "
+                    "and shapelet occurrence features, which can distort "
+                    "the rankings. Consider evaluating the results carefully "
+                    "or using an alternative method.",
+                    stacklevel=1,
+                )
+            else:
+                coefs = -coefs[class_id]
         elif isinstance(classifier, (BaseForest, BaseDecisionTree)):
             coefs = classifier.feature_importances_
 


Test patch:
diff --git a/aeon/clustering/tests/test_k_means.py b/aeon/clustering/tests/test_k_means.py
index 139c4a1e..50ac19e4 100644
--- a/aeon/clustering/tests/test_k_means.py
+++ b/aeon/clustering/tests/test_k_means.py
@@ -124,7 +124,7 @@ def test_kmeans():
         random_state=1,
         n_init=2,
         n_clusters=2,
-        init_algorithm="kmeans++",
+        init="kmeans++",
         distance="euclidean",
     )
     train_predict = kmeans.fit_predict(X_train)
@@ -162,7 +162,7 @@ def test_kmeans_dba():
         random_state=1,
         n_init=2,
         n_clusters=2,
-        init_algorithm="kmeans++",
+        init="kmeans++",
         distance="dtw",
     )
     train_predict = kmeans.fit_predict(X_train[0:num_test_values])
@@ -191,7 +191,7 @@ def _get_model_centres(data, distance, average_params=None, distance_params=None
         random_state=1,
         n_init=2,
         n_clusters=2,
-        init_algorithm="random",
+        init="random",
         distance=distance,
         average_params=average_params,
         distance_params=distance_params,
@@ -292,7 +292,7 @@ def test_means_init():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm="first",
+        init="first",
         distance="euclidean",
         n_clusters=num_clusters,
     )
@@ -312,7 +312,7 @@ def test_means_init():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm=custom_init_centres,
+        init=custom_init_centres,
         distance="euclidean",
         n_clusters=num_clusters,
     )
@@ -359,7 +359,7 @@ def test_empty_cluster():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm=init_centres,
+        init=init_centres,
         distance="euclidean",
         averaging_method="mean",
         n_clusters=3,
@@ -377,7 +377,7 @@ def test_empty_cluster():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm=init_centres,
+        init=init_centres,
         distance="euclidean",
         averaging_method="mean",
         n_clusters=3,
@@ -394,7 +394,7 @@ def test_empty_cluster():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm=init_centres,
+        init=init_centres,
         distance="euclidean",
         averaging_method="mean",
         n_clusters=2,
@@ -411,7 +411,7 @@ def test_empty_cluster():
         random_state=1,
         n_init=1,
         max_iter=5,
-        init_algorithm=init_centres,
+        init=init_centres,
         distance="euclidean",
         averaging_method="mean",
         n_clusters=3,
diff --git a/aeon/transformations/collection/tests/test_downsample.py b/aeon/transformations/collection/tests/test_downsample.py
new file mode 100644
index 00000000..cb85da0b
--- /dev/null
+++ b/aeon/transformations/collection/tests/test_downsample.py
@@ -0,0 +1,105 @@
+"""Tests for DownsampleTransformer."""
+
+import numpy as np
+import pytest
+
+from aeon.transformations.collection import DownsampleTransformer
+
+# list of 2D numpy arrays, unequal lengths
+X = [
+    np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]]),
+    np.array([[10, 20, 30, 40, 50, 60, 70, 80], [100, 90, 80, 70, 60, 50, 40, 30]]),
+]
+
+test_cases = [
+    (
+        10,  # source_sfreq
+        5,  # target_sfreq
+        0.5,  # proportion
+        X,  # X
+        [
+            np.array([[1, 3, 5, 7, 9], [10, 8, 6, 4, 2]]),
+            np.array([[10, 30, 50, 70], [100, 80, 60, 40]]),
+        ],  # X_expected
+    ),
+    (
+        10,
+        3,
+        0.7,
+        X,
+        [
+            np.array([[1, 4, 7, 10], [10, 7, 4, 1]]),
+            np.array([[10, 40, 70], [100, 70, 40]]),
+        ],
+    ),
+    (
+        10,
+        2,
+        0.8,
+        X,
+        [
+            np.array([[1, 6], [10, 5]]),
+            np.array([[10, 60], [100, 50]]),
+        ],
+    ),
+]
+
+
+@pytest.mark.parametrize("downsample_by", ["frequency", "proportion"])
+@pytest.mark.parametrize(
+    "source_sfreq, target_sfreq, proportion, X, X_expected", test_cases
+)
+def test_downsample_series(
+    downsample_by, source_sfreq, target_sfreq, proportion, X, X_expected
+):
+    """Test the downsampling of a time series."""
+    if downsample_by == "frequency":
+        transformer = DownsampleTransformer(
+            downsample_by="frequency",
+            source_sfreq=source_sfreq,
+            target_sfreq=target_sfreq,
+        )
+    elif downsample_by == "proportion":
+        transformer = DownsampleTransformer(
+            downsample_by="proportion", proportion=proportion
+        )
+
+    X_downsampled = transformer.fit_transform(X)
+
+    assert isinstance(X_downsampled, list)
+    for idx in range(len(X_downsampled)):
+        np.testing.assert_array_equal(X_expected[idx], X_downsampled[idx])
+
+
+def test_value_errors():
+    """Test DowmsampleTransformer ValueErrors."""
+    # default initialization
+    rng = np.random.default_rng(seed=0)
+    X = rng.random((32, 1000))
+    transformer = DownsampleTransformer()
+    transformer.fit_transform(X)
+
+    # invalid initializations
+    with pytest.raises(
+        ValueError, match='downsample_by must be either "frequency" or "proportion"'
+    ):
+        transformer = DownsampleTransformer(downsample_by="invalid")
+        transformer.fit_transform(X)
+
+    with pytest.raises(ValueError, match="source_sfreq must be > target_sfreq"):
+        transformer = DownsampleTransformer(
+            downsample_by="frequency", source_sfreq=1.0, target_sfreq=2.0
+        )
+        transformer.fit_transform(X)
+
+    with pytest.raises(
+        ValueError, match="proportion must be provided and between 0-1."
+    ):
+        transformer = DownsampleTransformer(downsample_by="proportion", proportion=1)
+        transformer.fit_transform(X)
+
+    with pytest.raises(
+        ValueError, match="proportion must be provided and between 0-1."
+    ):
+        transformer = DownsampleTransformer(downsample_by="proportion", proportion=0)
+        transformer.fit_transform(X)