Repository: aeon-toolkit/aeon
Pull request #1432 (2024-04-16 13:08:28)

[MNT] split up and simplified ba test
<!--
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
-->

#### What does this implement/fix? Explain your changes.
This PR splits up and makes the kmeans-ba tests run faster and are easier to read.

<!--
A clear and concise description of what you have implemented.
-->
The updated test was timing out in the nightly CI runs. While I don't think it was directly the cause (think the runner was dodgy) this test should be split up and simplified anyway so this PR fixes that.

#### 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.
-->

#### 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.
-->

### 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.
- [ ] 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
- [ ] I've added the estimator to the online [API documentation](https://www.aeon-toolkit.org/en/latest/api_reference.html).
- [ ] (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.

##### For developers with write access
- [ ] (OPTIONAL) I've updated aeon's [CODEOWNERS](https://github.com/aeon-toolkit/aeon/blob/main/CODEOWNERS) to receive notifications about future changes to these files.


<!--
Thanks for contributing!
-->


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

I have added the following labels to this PR based on the title: **[ $\\color{#EC843A}{\\textsf{maintenance}}$ ]**.
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/1432/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.
    

Patch:
diff --git a/aeon/datatypes/_vec_df.py b/aeon/datatypes/_vec_df.py
index 1249bd66..7a2c7571 100644
--- a/aeon/datatypes/_vec_df.py
+++ b/aeon/datatypes/_vec_df.py
@@ -13,7 +13,7 @@ from aeon.utils.multiindex import flatten_multiindex
 
 
 class _VectorizedDF:
-    """Wrapper for easy vectorization/iteration over instances.
+    """Wrapper for easy iteration over instances.
 
     VectorizedDF is an iterable that returns pandas.DataFrame
         in aeon Series or Panel format.
diff --git a/aeon/utils/conversion/_convert_series.py b/aeon/utils/conversion/_convert_series.py
index 6f2abe24..be6db5bb 100644
--- a/aeon/utils/conversion/_convert_series.py
+++ b/aeon/utils/conversion/_convert_series.py
@@ -12,6 +12,58 @@ import numpy as np
 import pandas as pd
 
 
+def _resolve_input_type(y):
+    """Resolve the input type of y.
+
+    Parameters
+    ----------
+    y : np.ndarray, pd.Series, pd.DataFrame
+        Time series to be converted.
+
+    Returns
+    -------
+    str
+        String identifier of the input type.
+    """
+    if isinstance(y, pd.Series):
+        return "pd.Series"
+    if isinstance(y, pd.DataFrame):
+        return "pd.DataFrame"
+    if isinstance(y, np.ndarray):
+        return "np.ndarray"
+    raise ValueError(f"Unknown input type: {type(y)}")
+
+
+def _resolve_output_type(output_type):
+    """Resolve the output type of y.
+
+    Parameters
+    ----------
+    output_type : str or list of str
+        The type to convert y to. If a list of string, either the type of y is in the
+        list, in which case y is returned, or conversion is attempted on the first
+        entry in the list of a valid output_type ["np.ndarray", "pd.Series",
+        "pd.DataFrame"].
+
+    Returns
+    -------
+    str
+        String identifier of the output type.
+    """
+    valid_types = ["np.ndarray", "pd.Series", "pd.DataFrame"]
+    # Check if output_type is a list
+    if isinstance(output_type, list):
+        # Iterate over the list and find the first valid type
+        for typ in output_type:
+            if typ in valid_types:
+                return typ
+        # If none in the list are valid, raise an error
+        raise ValueError("None of the types in the list are valid output types")
+    if isinstance(output_type, str) and output_type in valid_types:
+        return output_type
+    raise ValueError(f"Unknown output type {output_type}")
+
+
 def convert_series(y, output_type):
     """Convert series y to the specified output_type.
 
@@ -20,46 +72,44 @@ def convert_series(y, output_type):
     Parameters
     ----------
     y : np.ndarray, pd.Series, pd.DataFrame
-        Time series to be converted
-    output_type : string
-        The type to convert y to
+        Time series to be converted.
+    output_type : string or list of string
+        The type to convert y to. If a list of string, either the type of y is in the
+        list, in which case y is returned, or conversion is attempted on the first
+        entry in the list of a valid output_type ["np.ndarray", "pd.Series",
+        "pd.DataFrame"].
 
     Returns
     -------
     converted version of y
     """
-    if output_type == "np.ndarray":
-        if isinstance(y, (pd.Series, pd.DataFrame)):
-            return y.to_numpy()
-        elif isinstance(y, np.ndarray):
+    input_type = _resolve_input_type(y)
+    # If input type in the list, return it
+    if isinstance(output_type, list):
+        if input_type in output_type:
             return y
-        else:
-            raise ValueError(f"Cannot convert: {type(y)} to np.ndarray")
-    elif output_type == "pd.Series":
-        if isinstance(y, pd.Series):
+    elif isinstance(output_type, str):
+        if input_type == output_type:
             return y
-        elif isinstance(y, pd.DataFrame):
-            if y.shape[1] > 1:
-                raise ValueError(
-                    "DataFrame more than one column, cannot convert to Series"
-                )
-            y = y.iloc[:, 0]
-            return y
-        elif isinstance(y, np.ndarray):
-            y = y.squeeze()
-            if y.ndim == 1:
-                # Convert 1D numpy array to pandas Series
-                return pd.Series(y)
-            else:
-                raise ValueError(
-                    "Cannot convert array with more than one dimension to a pd.Series"
-                )
-    elif output_type == "pd.DataFrame":
-        if isinstance(y, pd.DataFrame):
-            return y
-        elif isinstance(y, pd.Series):
+    else:
+        raise ValueError("Unknown output type must be list or string")
+    output_type = _resolve_output_type(output_type)
+    if input_type == output_type:
+        return y
+    if output_type == "np.ndarray":
+        return y.to_numpy()
+    if output_type == "pd.Series":
+        y = y.squeeze()
+        if y.ndim > 1:
+            raise ValueError(
+                "DataFrame of more than one row or column, cannot convert to "
+                "pd.Series"
+            )
+        return pd.Series(y)
+    if output_type == "pd.DataFrame":
+        if input_type == "pd.Series":
             return y.to_frame()
-        elif isinstance(y, np.ndarray):
+        elif input_type == "np.ndarray":
             return pd.DataFrame(y)
         else:
             raise ValueError(
diff --git a/aeon/utils/index_functions.py b/aeon/utils/index_functions.py
index 0c09fdf1..867727c3 100644
--- a/aeon/utils/index_functions.py
+++ b/aeon/utils/index_functions.py
@@ -4,6 +4,7 @@ import numpy as np
 import pandas as pd
 
 from aeon.datatypes import convert_to
+from aeon.datatypes._vec_df import _VectorizedDF
 from aeon.utils.validation import (
     is_collection,
     is_hierarchical,
@@ -96,20 +97,6 @@ def get_index_for_series(obj, cutoff=0):
     return pd.RangeIndex(cutoff, cutoff + obj.shape[0])
 
 
-GET_CUTOFF_SUPPORTED_TYPES = [
-    "pd.DataFrame",
-    "pd.Series",
-    "np.ndarray",
-    "pd-multiindex",
-    "numpy3D",
-    "nested_univ",
-    "df-list",
-    "pd_multiindex_hier",
-    "np.ndarray",
-    "numpy3D",
-]
-
-
 def _get_cutoff_from_index(idx, return_index=False, reverse_order=False):
     """Get cutoff = latest time point of pandas index.
 
@@ -180,10 +167,8 @@ def get_cutoff(
     cutoff=0,
     return_index=False,
     reverse_order=False,
-    check_input=False,
-    convert_input=True,
 ):
-    """Get cutoff = latest time point of time series or time series panel.
+    """Get the latest time point of time series or collection of time series.
 
     Assumptions on obj are not checked, these should be validated separately.
     Function may return unexpected results without prior validation.
@@ -191,34 +176,28 @@ def get_cutoff(
     Parameters
     ----------
     obj : aeon compatible time series data container or pandas.Index
-        if aeon time series, must be of Series, Panel, or Hierarchical scitype
-        all mtypes are supported via conversion to internally supported types
-        to avoid conversions, pass data in one of GET_CUTOFF_SUPPORTED_TYPES
-        if pandas.Index, it is assumed that last level is time-like or integer,
-        e.g., as in the pd.DataFrame, pd-multiindex, or pd_multiindex_hier mtypes
-    cutoff : int, optional, default=0
-        current cutoff, used to offset index if obj is np.ndarray
-    return_index : bool, optional, default=False
-        whether a pd.Index object should be returned (True)
-            or a pandas compatible index element (False)
+        if aeon time series, must be of Series, Collection, or Hierarchical abstract
+        type. if ``pandas.Index``, it is assumed that last level is time-like or integer
+        e.g., as in the pd.DataFrame, pd-multiindex, or pd_multiindex_hier internal
+        types.
+    cutoff : int, default=0
+        Current cutoff, used to offset index if obj is np.ndarray
+    return_index : bool, default=False
+        Whether a pd.Index object should be returned (True) or a pandas compatible
+        index element (False).
         note: return_index=True may set freq attribute of time types to None
-            return_index=False will typically preserve freq attribute
-    reverse_order : bool, optional, default=False
-        if False, returns largest time index. If True, returns smallest time index
-    check_input : bool, optional, default=False
-        whether to check input for validity, i.e., is it one of the scitypes
-    convert_input : bool, optional, default=True
-        whether to convert the input (True), or skip conversion (False)
-        if skipped, function assumes that inputs are one of GET_CUTOFF_SUPPORTED_TYPES
+            return_index=False will typically preserve freq attribute.
+    reverse_order : bool, default=False
+        if False, returns largest time index. If True, returns smallest time index.
 
     Returns
     -------
     cutoff_index : pandas compatible index element (if return_index=False)
-        pd.Index of length 1 (if return_index=True)
+        pd.Index of length 1 (if return_index=True).
 
     Raises
     ------
-    ValueError, TypeError, if check_input or convert_input are True
+    ValueError, TypeError, if check_input or convert_input are True.
     """
     # deal with VectorizedDF
     if hasattr(obj, "X"):
@@ -228,13 +207,8 @@ def get_cutoff(
         return _get_cutoff_from_index(
             idx=obj, return_index=return_index, reverse_order=reverse_order
         )
-
-    if check_input:
-        if not (is_hierarchical(obj) or is_collection(obj) or is_single_series(obj)):
-            raise ValueError("obj must be of Series, Panel, or Hierarchical scitype")
-
-    if convert_input:
-        obj = convert_to(obj, GET_CUTOFF_SUPPORTED_TYPES)
+    if not (is_hierarchical(obj) or is_collection(obj) or is_single_series(obj)):
+        raise ValueError("obj must be of Series, Panel, or Hierarchical abstract type")
 
     if cutoff is None:
         cutoff = 0
@@ -343,9 +317,6 @@ def update_data(X, X_new=None):
         numpy based containers will always be interpreted as having new row index
         if one of X, X_new is None, returns the other; if both are None, returns None
     """
-    from aeon.datatypes._convert import convert_to
-    from aeon.datatypes._vec_df import _VectorizedDF
-
     # if X or X_new is vectorized, unwrap it first
     if isinstance(X, _VectorizedDF):
         X = X.X
@@ -478,7 +449,7 @@ def get_window(obj, window_length=None, lag=None):
         return convert_to(obj_subset, obj_in_mtype)
 
     raise ValueError(
-        "bug in get_window, unreachable condition, ifs should be exhaustive"
+        "passed get_window an object that is not of type np.ndarray or pd.DataFrame"
     )
 
 


Test patch:
diff --git a/aeon/clustering/tests/test_k_means.py b/aeon/clustering/tests/test_k_means.py
index 7baa98ad..38445d20 100644
--- a/aeon/clustering/tests/test_k_means.py
+++ b/aeon/clustering/tests/test_k_means.py
@@ -8,6 +8,7 @@ from sklearn.utils import check_random_state
 from aeon.clustering._k_means import TimeSeriesKMeans
 from aeon.datasets import load_basic_motions, load_gunpoint
 from aeon.distances import euclidean_distance
+from aeon.testing.utils.data_gen import make_example_3d_numpy
 from aeon.utils.validation._dependencies import _check_estimator_deps
 
 expected_results = {
@@ -199,12 +200,9 @@ def _get_model_centres(data, distance, average_params=None, distance_params=None
     return model.cluster_centers_
 
 
-def test_different_ba():
-    """Test different ba methods."""
-    X_train, y_train = load_basic_motions(split="train")
-
-    num_test_values = 5
-    data = X_train[0:num_test_values]
+def test_petitjean_ba_params():
+    """Test different petitjean ba params."""
+    data = make_example_3d_numpy(5, 2, 10, random_state=1, return_y=False)
 
     # Test passing distance param
     default_dba = _get_model_centres(data, distance="dtw")
@@ -235,7 +233,7 @@ def test_different_ba():
 
     # Test passing multiple params
     mba_custom_params_window_and_indep = _get_model_centres(
-        data, distance="msm", average_params={"window": 0.2, "independent": False}
+        data, distance="msm", average_params={"window": 0.1, "independent": False}
     )
 
     # Check not equal to just when indep set
@@ -247,13 +245,17 @@ def test_different_ba():
     # Check not equal to default
     assert not np.array_equal(default_mba, mba_custom_params_window_and_indep)
 
+
+def test_ssg_ba_params():
+    """Test different ssg-ba params."""
+    data = make_example_3d_numpy(5, 2, 10, random_state=1, return_y=False)
+
     # Test subgradient ba
     subgradient_dba_specified = _get_model_centres(
         data,
         distance="dtw",
         average_params={"distance": "dtw", "method": "subgradient"},
     )
-    assert not np.array_equal(dba_specified, subgradient_dba_specified)
 
     # Test another distance and check passing custom distance param
     subgradient_mba_specified = _get_model_centres(
@@ -261,7 +263,6 @@ def test_different_ba():
         distance="msm",
         average_params={"distance": "msm", "method": "subgradient"},
     )
-    assert not np.array_equal(mba_specified, subgradient_mba_specified)
     assert not np.array_equal(subgradient_dba_specified, subgradient_mba_specified)
 
 
diff --git a/aeon/utils/conversion/tests/test_convert_series.py b/aeon/utils/conversion/tests/test_convert_series.py
new file mode 100644
index 00000000..a950f5b7
--- /dev/null
+++ b/aeon/utils/conversion/tests/test_convert_series.py
@@ -0,0 +1,108 @@
+"""Tests for convert series."""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from aeon.utils.conversion import convert_series
+from aeon.utils.conversion._convert_series import (
+    _resolve_input_type,
+    _resolve_output_type,
+)
+
+TO_TYPE = ["np.ndarray", "pd.Series", "pd.DataFrame"]
+uni = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
+uni2 = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])
+UNIVARIATE = [uni, pd.Series(uni), pd.DataFrame(uni)]
+UNIVARIATE2 = [uni2, pd.DataFrame(uni2), uni2.T, pd.DataFrame(uni2.T)]
+multi = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])
+MULTIVARIATE = [multi, pd.DataFrame(multi)]
+
+
+@pytest.mark.parametrize("data", UNIVARIATE)
+@pytest.mark.parametrize("to_type", TO_TYPE)
+def test_convert_univariate_series(data, to_type):
+    """Test convert series with single univariate input of each type."""
+    x = convert_series(data, to_type)
+    assert isinstance(x, eval(to_type))
+
+
+@pytest.mark.parametrize("data", UNIVARIATE2)
+@pytest.mark.parametrize("to_type", TO_TYPE)
+def test_convert_univariate_as_multivariate(data, to_type):
+    """Test convert a univariate series stored in 2D data structure."""
+    x = convert_series(data, to_type)
+    assert isinstance(x, eval(to_type))
+
+
+@pytest.mark.parametrize("data", MULTIVARIATE)
+@pytest.mark.parametrize("to_type", TO_TYPE)
+def test_convert_multivariate_series(data, to_type):
+    """Test convert series with single input of each type."""
+    if to_type == "pd.Series":
+        with pytest.raises(ValueError, match="cannot convert to pd.Series"):
+            convert_series(data, to_type)
+        return
+    x = convert_series(data, to_type)
+    assert isinstance(x, eval(to_type))
+
+
+def test_convert_series_wrong_type():
+    """Test convert series with wrong input type."""
+    with pytest.raises(ValueError, match="Unknown input type"):
+        convert_series("string", "np.ndarray")
+    with pytest.raises(ValueError, match="Unknown input type"):
+        convert_series([1, 2, 3], "np.ndarray")
+    with pytest.raises(ValueError, match="Unknown output type"):
+        convert_series(np.array([[[1, 2, 3]]]), "numpy3D")
+    with pytest.raises(ValueError, match="Unknown output type"):
+        convert_series(np.ndarray([1, 2, 3]), "np-list")
+    with pytest.raises(ValueError, match="Unknown output type"):
+        convert_series(np.ndarray([1, 2, 3]), "pd_multiindex_hier")
+
+
+def test__resolve_input_type():
+    """Test _input_type function."""
+    for i in range(len(UNIVARIATE)):
+        assert _resolve_input_type(UNIVARIATE[i]) == TO_TYPE[i]
+    with pytest.raises(ValueError, match="Unknown input type"):
+        _resolve_input_type("string")
+
+
+def test__resolve_output_type():
+    """Test resolve output type."""
+    for i in range(len(TO_TYPE)):
+        assert _resolve_output_type(TO_TYPE[i]) == TO_TYPE[i]
+    with pytest.raises(ValueError, match="Unknown output type"):
+        _resolve_output_type("string")
+    test = ["np.ndarray", "pd.Series", "pd.DataFrame"]
+    assert _resolve_output_type(test) == "np.ndarray"
+    test = ["FOO", "pd.Series", "pd.DataFrame"]
+    assert _resolve_output_type(test) == "pd.Series"
+    test = ["FOO", "BAR", "pd.DataFrame"]
+    assert _resolve_output_type(test) == "pd.DataFrame"
+    test = ["FOO", "BAR", "ARSENAL"]
+    with pytest.raises(ValueError, match="Unknown input type"):
+        _resolve_input_type(test)
+
+
+def test_convert_series_lists():
+    """Test series conversion with list type inputs."""
+    # Test returns self if in list
+    for i in range(len(UNIVARIATE)):
+        test = ["np.ndarray", "pd.Series", "pd.DataFrame"]
+        x = convert_series(UNIVARIATE[i], test)
+        assert type(x) is type(UNIVARIATE[i])
+        # Test with invalid names at start
+        test.insert(0, "FOO")
+        x = convert_series(UNIVARIATE[i], test)
+        assert type(x) is type(UNIVARIATE[i])
+        # Test when type not present
+        test = ["np.ndarray", "pd.Series", "pd.DataFrame"]
+        test.remove(TO_TYPE[i])
+        x = convert_series(UNIVARIATE[i], test)
+        assert x.__class__.__name__ == test[0].split(".")[1]
+        # Test with invalid names at start
+        test.insert(0, "FOO")
+        x = convert_series(UNIVARIATE[i], test)
+        assert x.__class__.__name__ == test[1].split(".")[1]
diff --git a/aeon/utils/tests/test_index_functions.py b/aeon/utils/tests/test_index_functions.py
index 7431f9ca..4caf3a3d 100644
--- a/aeon/utils/tests/test_index_functions.py
+++ b/aeon/utils/tests/test_index_functions.py
@@ -84,11 +84,10 @@ def test_get_time_index(datatype):
         assert (idx == exp_idx).all()
 
 
-@pytest.mark.parametrize("convert_input", [True, False])
 @pytest.mark.parametrize("reverse_order", [True, False])
 @pytest.mark.parametrize("return_index", [True, False])
 @pytest.mark.parametrize("datatype", EXAMPLE_DATA.keys())
-def test_get_cutoff(datatype, return_index, reverse_order, convert_input):
+def test_get_cutoff(datatype, return_index, reverse_order):
     """Tests that get_cutoff has correct output.
 
     Parameters
@@ -96,7 +95,6 @@ def test_get_cutoff(datatype, return_index, reverse_order, convert_input):
     datatype : str - datatype of input
     return_index : bool - whether index (True) or index element is returned (False)
     reverse_order : bool - whether first (True) or last index (False) is retrieved
-    convert_input : bool - whether input is converted (True) or passed through (False)
 
     Raises
     ------
@@ -110,7 +108,6 @@ def test_get_cutoff(datatype, return_index, reverse_order, convert_input):
         data,
         return_index=return_index,
         reverse_order=reverse_order,
-        convert_input=convert_input,
     )
 
     if return_index:
@@ -217,7 +214,7 @@ def test_get_cutoff_wrong_input(bad_inputs):
     Exception (from pytest) if the error is not raised as expected
     """
     with pytest.raises(Exception, match="must be of Series, Panel, or Hierarchical"):
-        get_cutoff(bad_inputs, check_input=True)
+        get_cutoff(bad_inputs)
 
 
 @pytest.mark.parametrize("window_length, lag", [(2, 0), (None, 0), (4, 1)])