Repository: aeon-toolkit/aeon
Pull request #1010 (2023-12-22 09:53:30)

[ENH] Improvements to scatterplot when lower is better
This PR enhances scatterplots when using performance metrics to be minimised.

For example, in this case we are comparing the results of DrCIF and InceptionE using the relative RMSE (to minimise):
![image](https://github.com/aeon-toolkit/aeon/assets/47889499/b6dbbe1d-b0f8-478f-b374-8eea8a9ac875)
Now, the x-axis is in the top and the y-axis in the right. The reason for these changes are to improve clarification. If they were in their original position, if may be a little bit confusing as reader can interpret that the relative RMSE for DrCIF is in the bottom axis whereas those for Inception are in the left, being the way around. 

The standard case where a maximisation measure is used works as expected. This is an example:
![image](https://github.com/aeon-toolkit/aeon/assets/47889499/e60b182d-1d8c-4c25-87c0-3b3bc9a8b755)
As can be observed, the axis are now where they are expected given that it can not be object of confusion. Ties are shown, though they only exists for accuracy = 1, so can't be easily seen. 


Note that the winning method is shown in green colour (x-axis), whereas losing one is in blue colour and in the y-axis.


Also, now #920 is finished. 
If we run the scatter with three datasets, the result is the following:
![image](https://github.com/aeon-toolkit/aeon/assets/47889499/be0023f6-6a67-4167-8190-3493a2d93769)




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{#264F59}{\\textsf{benchmarking}}$ ]**. Feel free to change these if they do not properly represent the PR.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/1010/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/benchmarking/_critical_difference.py b/aeon/benchmarking/_critical_difference.py
index 0e409f2d..1d7c1acd 100644
--- a/aeon/benchmarking/_critical_difference.py
+++ b/aeon/benchmarking/_critical_difference.py
@@ -3,10 +3,9 @@
 __author__ = ["SveaMeyer13", "dguijo", "TonyBagnall"]
 
 import math
+import warnings
 
 import numpy as np
-
-# import pandas as pd
 from scipy.stats import distributions, find_repeats, rankdata, wilcoxon
 
 from aeon.benchmarking.utils import get_qalpha
@@ -89,7 +88,7 @@ def _nemenyi_test(ordered_avg_ranks, n_datasets, alpha):
     return cliques
 
 
-def _wilcoxon_test(results, lower_better=False):
+def _wilcoxon_test(results, labels, lower_better=False):
     """
     Perform Wilcoxon test.
 
@@ -113,13 +112,22 @@ def _wilcoxon_test(results, lower_better=False):
 
     for i in range(n_estimators - 1):
         for j in range(i + 1, n_estimators):
-            p_values[i, j] = wilcoxon(
-                results[:, i],
-                results[:, j],
-                zero_method="wilcox",
-                alternative="less" if lower_better else "greater",
-            )[1]
-
+            # if the difference is zero, the p-value is 1
+            if np.all(results[:, i] == results[:, j]):
+                p_values[i, j] = 1
+                # raise warning
+                warnings.warn(
+                    f"Estimators {labels[i]} and {labels[j]} have the same performance"
+                    "on all datasets. This may cause problems when forming cliques.",
+                    stacklevel=2,
+                )
+            else:
+                p_values[i, j] = wilcoxon(
+                    results[:, i],
+                    results[:, j],
+                    zero_method="wilcox",
+                    alternative="less" if lower_better else "greater",
+                )[1]
     return p_values
 
 
@@ -351,12 +359,13 @@ def plot_critical_difference(
                 adjusted_alpha = alpha
             else:
                 raise ValueError("correction available are None, Bonferroni and Holm.")
-            p_values = _wilcoxon_test(ordered_scores, lower_better)
+            p_values = _wilcoxon_test(ordered_scores, ordered_labels, lower_better)
             cliques = _build_cliques(p_values > adjusted_alpha)
         else:
             raise ValueError("tests available are only nemenyi and wilcoxon.")
     # If Friedman test is not significant everything has to be one clique
     else:
+        p_values = np.triu(np.ones((n_estimators, n_estimators)))
         cliques = [[1] * n_estimators]
 
     # Step 6 create the diagram:
@@ -442,7 +451,7 @@ def plot_critical_difference(
             str(a),
             ha="center",
             va="bottom",
-            size=16,
+            size=11,
         )
 
     # sort out lines and text based on whether order is reversed or not
diff --git a/aeon/benchmarking/results_plotting.py b/aeon/benchmarking/results_plotting.py
index 031d21ae..8eeb1422 100644
--- a/aeon/benchmarking/results_plotting.py
+++ b/aeon/benchmarking/results_plotting.py
@@ -259,7 +259,6 @@ def plot_scatter(
     >>> plot = plot_scatter(results[0], methods[0], methods[1])  # doctest: +SKIP
     >>> plot.show()  # doctest: +SKIP
     >>> plot.savefig("scatterplot.pdf")  # doctest: +SKIP
-
     """
     _check_soft_dependencies("matplotlib", "seaborn")
     import matplotlib.pyplot as plt
@@ -277,11 +276,11 @@ def plot_scatter(
     min_value = max(results.min() * 0.97, 0)
     max_value = results.max() * 1.03
 
-    if metric == "accuracy":
-        max_value = min(max_value, 1)
+    if metric in ["accuracy", "R2", "CCR", "AUC", "F1", "Kappa", "AUPRC"]:
+        max_value = min(max_value, 1.001)
         if lower_better:
             raise ValueError("lower_better must be False when metric is 'accuracy'.")
-    elif metric == "error":
+    elif metric in ["error", "RMSE", "MSE", "MAE", "AMAE", "MAPE", "SMAPE"]:
         if not lower_better:
             raise ValueError("lower_better must be True when metric is 'error'.")
 
@@ -305,6 +304,12 @@ def plot_scatter(
     differences = [
         0 if i - j == 0 else (1 if i - j > 0 else -1) for i, j in zip(first, second)
     ]
+    # This line helps displaying ties on top of losses and wins, as in general there
+    # are less number of ties than wins/losses.
+    differences, first, second = map(
+        np.array,
+        zip(*sorted(zip(differences, first, second), key=lambda x: -abs(x[0]))),
+    )
 
     first_avg = first.mean()
     second_avg = second.mean()
@@ -313,30 +318,42 @@ def plot_scatter(
         x=second,
         y=first,
         hue=differences,
+        hue_order=[1, 0, -1] if lower_better else [-1, 0, 1],
         palette="pastel",
         zorder=2,
     )
 
     # Draw the average value per method as a dashed line from 0 to the mean value.
     plt.plot(
-        [first_avg, min_value],
+        [first_avg, min_value] if not lower_better else [first_avg, max_value],
         [first_avg, first_avg],
         linestyle="--",
-        color="#ccebc5",
+        color="#8de5a1",
         zorder=3,
     )
 
     plt.plot(
         [second_avg, second_avg],
-        [second_avg, min_value],
+        [second_avg, min_value] if not lower_better else [second_avg, max_value],
         linestyle="--",
-        color="#b3cde3",
+        color="#a1c9f4",
         zorder=3,
     )
 
     # Compute the W, T, and L per methods
     if lower_better:
         differences = [-i for i in differences]
+        ax = plt.gca()
+        ax.xaxis.tick_top()
+        ax.xaxis.set_label_position("top")
+        ax.spines["top"].set_visible(True)
+        ax.yaxis.tick_right()
+        ax.yaxis.set_label_position("right")
+        ax.spines["right"].set_visible(True)
+
+    # Setting labels for x and y axis
+    plot.set_ylabel(f"{first_method} {metric}\n(avg.: {first_avg:.4f})")
+    plot.set_xlabel(f"{second_method} {metric}\n(avg.: {second_avg:.4f})")
 
     wins_A = losses_B = sum(i == 1 for i in differences)
     ties_A = ties_B = sum(i == 0 for i in differences)
@@ -349,17 +366,13 @@ def plot_scatter(
     # Remove legend
     plot.get_legend().remove()
 
-    # Setting labels for x and y axis
-    plot.set_ylabel(f"{first_method} {metric}\n(avg.: {first_avg:.4f})")
-    plot.set_xlabel(f"{second_method} {metric}\n(avg.: {second_avg:.4f})")
-
     # Setting text with W, T and L for each method
     anc = AnchoredText(
         f"{first_method} wins here\n[{wins_A}W, {ties_A}T, {losses_A}L]",
-        loc="upper left",
+        loc="upper left" if not lower_better else "lower right",
         frameon=True,
         prop=dict(
-            color="darkseagreen",
+            color="#8de5a1",
             fontweight="bold",
             fontsize=13,
             ha="center",
@@ -373,10 +386,10 @@ def plot_scatter(
 
     anc = AnchoredText(
         f"{second_method} wins here\n[{wins_B}W, {ties_B}T, {losses_B}L]",
-        loc="lower right",
+        loc="lower right" if not lower_better else "upper left",
         frameon=True,
         prop=dict(
-            color="cornflowerblue",
+            color="#a1c9f4",
             fontweight="bold",
             fontsize=13,
             ha="center",
@@ -414,7 +427,7 @@ def plot_scatter(
 
         plt.figtext(
             0.5,
-            0.03,
+            0.03 if not lower_better else 0.13,
             f"{wil}\n{ttes}",
             fontsize=10,
             wrap=True,
diff --git a/aeon/datatypes/_panel/_convert.py b/aeon/datatypes/_panel/_convert.py
index 7f430db5..fadfeecf 100644
--- a/aeon/datatypes/_panel/_convert.py
+++ b/aeon/datatypes/_panel/_convert.py
@@ -367,41 +367,6 @@ def convert_from_dictionary(ts_dict):
     return from_2d_array_to_nested(panda)
 
 
-def _concat_nested_arrays(arrs, cells_as_numpy=False):
-    """Nest tabular arrays from nested list.
-
-    Helper function to nest tabular arrays from nested list of arrays.
-
-    Parameters
-    ----------
-    arrs : list of numpy arrays
-        Arrays must have the same number of rows, but can have varying
-        number of columns.
-
-    cells_as_numpy : bool, default = False
-        If True, then nested cells contain NumPy array
-        If False, then nested cells contain pandas Series
-
-    Returns
-    -------
-    Xt : pandas DataFrame
-        Transformed dataframe with nested column for each input array.
-    """
-    if cells_as_numpy:
-        Xt = pd.DataFrame(
-            np.column_stack(
-                [pd.Series([np.array(vals) for vals in interval]) for interval in arrs]
-            )
-        )
-    else:
-        Xt = pd.DataFrame(
-            np.column_stack(
-                [pd.Series([pd.Series(vals) for vals in interval]) for interval in arrs]
-            )
-        )
-    return Xt
-
-
 def _get_index(x):
     if hasattr(x, "index"):
         return x.index
@@ -410,35 +375,6 @@ def _get_index(x):
         return pd.RangeIndex(x.shape[-1])
 
 
-def _get_time_index(X):
-    """Get index of time series data, helper function.
-
-    Parameters
-    ----------
-    X : pd.DataFrame
-
-    Returns
-    -------
-    time_index : pandas Index
-        Index of time series
-    """
-    # assumes that all samples share the same the time index, only looks at
-    # first row
-    if isinstance(X, pd.DataFrame):
-        return _get_index(X.iloc[0, 0])
-
-    elif isinstance(X, pd.Series):
-        return _get_index(X.iloc[0])
-
-    elif isinstance(X, np.ndarray):
-        return _get_index(X)
-
-    else:
-        raise ValueError(
-            f"X must be a pandas DataFrame or Series, but found: {type(X)}"
-        )
-
-
 def _make_column_names(column_count):
     return [f"var_{i}" for i in range(column_count)]
 
diff --git a/aeon/transformations/collection/segment.py b/aeon/transformations/collection/segment.py
index dc882a81..eea734a4 100644
--- a/aeon/transformations/collection/segment.py
+++ b/aeon/transformations/collection/segment.py
@@ -5,11 +5,46 @@ import numpy as np
 import pandas as pd
 from sklearn.utils import check_random_state
 
-from aeon.datatypes._panel._convert import _concat_nested_arrays, _get_time_index
 from aeon.transformations.collection import BaseCollectionTransformer
+from aeon.utils.datetime import get_time_index
 from aeon.utils.validation import check_window_length
 
 
+def _concat_nested_arrays(arrs, cells_as_numpy=False):
+    """Nest tabular arrays from nested list.
+
+    Helper function to nest tabular arrays from nested list of arrays.
+
+    Parameters
+    ----------
+    arrs : list of numpy arrays
+        Arrays must have the same number of rows, but can have varying
+        number of columns.
+
+    cells_as_numpy : bool, default = False
+        If True, then nested cells contain NumPy array
+        If False, then nested cells contain pandas Series
+
+    Returns
+    -------
+    Xt : pandas DataFrame
+        Transformed dataframe with nested column for each input array.
+    """
+    if cells_as_numpy:
+        Xt = pd.DataFrame(
+            np.column_stack(
+                [pd.Series([np.array(vals) for vals in interval]) for interval in arrs]
+            )
+        )
+    else:
+        Xt = pd.DataFrame(
+            np.column_stack(
+                [pd.Series([pd.Series(vals) for vals in interval]) for interval in arrs]
+            )
+        )
+    return Xt
+
+
 class IntervalSegmenter(BaseCollectionTransformer):
     """Interval segmentation transformer.
 
@@ -224,7 +259,7 @@ class RandomIntervalSegmenter(IntervalSegmenter):
         self.input_shape_ = X.shape
 
         # Retrieve time-series indexes from each column.
-        self._time_index = _get_time_index(X)
+        self._time_index = get_time_index(X)
 
         # Compute random intervals for each column.
         if self.n_intervals == "random":
diff --git a/aeon/utils/datetime.py b/aeon/utils/datetime.py
index a480bf22..5d0afe6b 100644
--- a/aeon/utils/datetime.py
+++ b/aeon/utils/datetime.py
@@ -12,10 +12,59 @@ import pandas as pd
 from pandas.api.types import is_integer_dtype
 
 from aeon.datatypes import VectorizedDF
-from aeon.datatypes._utilities import get_time_index
 from aeon.utils.validation.series import check_time_index, is_integer_index
 
 
+def _get_index(x):
+    if hasattr(x, "index"):
+        return x.index
+    else:
+        # select last dimension for time index
+        return pd.RangeIndex(x.shape[-1])
+
+
+def get_time_index(X):
+    """Get index of time series data, helper function.
+
+    Parameters
+    ----------
+    X : pd.DataFrame, pd.Series, np.ndarray
+
+    Returns
+    -------
+    time_index : pandas.Index
+        Index of time series
+    """
+    # assumes that all samples share the same the time index, only looks at
+    # first row
+    if isinstance(X, (pd.DataFrame, pd.Series)):
+        # pd-multiindex or pd_multiindex_hier
+        if isinstance(X.index, pd.MultiIndex):
+            index_tuple = tuple(list(X.index[0])[:-1])
+            index = X.loc[index_tuple].index
+            return index
+        # nested_univ
+        elif isinstance(X, pd.DataFrame) and isinstance(X.iloc[0, 0], pd.DataFrame):
+            return _get_index(X.iloc[0, 0])
+        # pd.Series or pd.DataFrame
+        else:
+            return X.index
+    # numpy3D and np.ndarray
+    elif isinstance(X, np.ndarray):
+        # np.ndarray
+        if X.ndim < 3:
+            return pd.RangeIndex(X.shape[0])
+        # numpy3D
+        else:
+            return pd.RangeIndex(X.shape[-1])
+    elif hasattr(X, "X"):
+        return get_time_index(X.X)
+    else:
+        raise ValueError(
+            f"X must be pd.DataFrame, pd.Series, or np.ndarray, but found: {type(X)}"
+        )
+
+
 def _coerce_duration_to_int(
     duration: Union[int, pd.Timedelta, pd.tseries.offsets.BaseOffset, pd.Index],
     freq: str = None,
diff --git a/aeon/utils/validation/__init__.py b/aeon/utils/validation/__init__.py
index 80d22a96..0e946155 100644
--- a/aeon/utils/validation/__init__.py
+++ b/aeon/utils/validation/__init__.py
@@ -14,6 +14,7 @@ __all__ = [
     "is_equal_length",
     "has_missing",
     "is_univariate",
+    "is_nested_univ_dataframe",
 ]
 __author__ = ["mloning", "Taiwo Owoseni", "khrapovs", "TonyBagnall"]
 
@@ -24,6 +25,7 @@ from typing import Union
 import numpy as np
 import pandas as pd
 
+from aeon.utils.validation._convert_collection import is_nested_univ_dataframe
 from aeon.utils.validation.collection import (
     get_n_cases,
     get_type,
diff --git a/aeon/utils/validation/_convert_collection.py b/aeon/utils/validation/_convert_collection.py
index 11dc4dc5..7191a808 100644
--- a/aeon/utils/validation/_convert_collection.py
+++ b/aeon/utils/validation/_convert_collection.py
@@ -21,6 +21,18 @@ import numpy as np
 import pandas as pd
 
 
+def is_nested_univ_dataframe(X):
+    """Check if X is nested dataframe."""
+    # Otherwise check all entries are pd.Series
+    if not isinstance(X, pd.DataFrame):
+        return False
+    for _, series in X.items():
+        for cell in series:
+            if not isinstance(cell, pd.Series):
+                return False
+    return True
+
+
 def _nested_univ_is_equal(X):
     """Check whether series in a nested DataFrame are of equal length.
 
@@ -54,23 +66,11 @@ def _nested_univ_is_equal(X):
     return True
 
 
-def _is_nested_univ_dataframe(X):
-    """Check if X is nested dataframe."""
-    # Otherwise check all entries are pd.Series
-    if not isinstance(X, pd.DataFrame):
-        return False
-    for _, series in X.items():
-        for cell in series:
-            if not isinstance(cell, pd.Series):
-                return False
-    return True
-
-
 def _is_pd_wide(X):
     """Check whether the input nested DataFrame is "pd-wide" type."""
     # only test is if all values are float.
     if isinstance(X, pd.DataFrame) and not isinstance(X.index, pd.MultiIndex):
-        if _is_nested_univ_dataframe(X):
+        if is_nested_univ_dataframe(X):
             return False
         float_cols = X.select_dtypes(include=[float]).columns
         for col in float_cols:
diff --git a/aeon/utils/validation/collection.py b/aeon/utils/validation/collection.py
index 987d6aae..82056980 100644
--- a/aeon/utils/validation/collection.py
+++ b/aeon/utils/validation/collection.py
@@ -230,7 +230,7 @@ def get_type(X):
                 f"lists should either 2D numpy arrays or pd.DataFrames."
             )
     elif isinstance(X, pd.DataFrame):  # Nested univariate, hierachical or pd-wide
-        if conv._is_nested_univ_dataframe(X):
+        if conv.is_nested_univ_dataframe(X):
             return "nested_univ"
         if isinstance(X.index, pd.MultiIndex):
             return "pd-multiindex"
diff --git a/aeon/utils/validation/panel.py b/aeon/utils/validation/panel.py
index 45b84588..cb429de7 100644
--- a/aeon/utils/validation/panel.py
+++ b/aeon/utils/validation/panel.py
@@ -11,11 +11,8 @@ import numpy as np
 import pandas as pd
 from sklearn.utils.validation import check_consistent_length
 
-from aeon.datatypes._panel._check import is_nested_dataframe
-from aeon.datatypes._panel._convert import (
-    from_3d_numpy_to_nested,
-    from_nested_to_3d_numpy,
-)
+from aeon.utils.validation import is_nested_univ_dataframe
+from aeon.utils.validation.collection import convert_collection
 
 VALID_X_TYPES = (pd.DataFrame, np.ndarray)  # nested pd.DataFrame, 2d or 3d np.array
 VALID_Y_TYPES = (pd.Series, np.ndarray)  # 1-d vector
@@ -79,7 +76,7 @@ def check_X(
                 f"array, but found shape: {X.shape}"
             )
         if coerce_to_pandas:
-            X = from_3d_numpy_to_nested(X)
+            X = convert_collection(X, "nested_univ")
 
     # enforce minimum number of columns
     n_columns = X.shape[1]
@@ -102,14 +99,14 @@ def check_X(
 
     # check pd.DataFrame
     if isinstance(X, pd.DataFrame):
-        if not is_nested_dataframe(X):
+        if not is_nested_univ_dataframe(X):
             raise ValueError(
                 "If passed as a pd.DataFrame, X must be a nested "
                 "pd.DataFrame, with pd.Series or np.arrays inside cells."
             )
         # convert pd.DataFrame
         if coerce_to_numpy:
-            X = from_nested_to_3d_numpy(X)
+            X = convert_collection(X, "numpy3D")
 
     return X
 
diff --git a/aeon/utils/validation/series.py b/aeon/utils/validation/series.py
index 5697762b..1e0733c3 100644
--- a/aeon/utils/validation/series.py
+++ b/aeon/utils/validation/series.py
@@ -23,6 +23,32 @@ assert set(RELATIVE_INDEX_TYPES).issubset(VALID_INDEX_TYPES)
 assert set(ABSOLUTE_INDEX_TYPES).issubset(VALID_INDEX_TYPES)
 
 
+def get_index_for_series(obj, cutoff=0):
+    """Get pandas index for a Series object.
+
+    Returns index even for numpy array, in that case a RangeIndex.
+
+    Assumptions on obj are not checked, these should be validated separately.
+    Function may return unexpected results without prior validation.
+
+    Parameters
+    ----------
+    obj : aeon data container
+        must be of one of the following mtypes:
+            pd.Series, pd.DataFrame, np.ndarray, of Series scitype
+    cutoff : int, or pd.datetime, optional, default=0
+        current cutoff, used to offset index if obj is np.ndarray
+
+    Returns
+    -------
+    index : pandas.Index, index for obj
+    """
+    if hasattr(obj, "index"):
+        return obj.index
+    # now we know the object must be an np.ndarray
+    return pd.RangeIndex(cutoff, cutoff + obj.shape[0])
+
+
 def is_integer_index(x) -> bool:
     """Check that the input is an integer pd.Index."""
     return isinstance(x, pd.Index) and is_integer_dtype(x)
@@ -254,9 +280,6 @@ def check_equal_time_index(*ys, mode="equal"):
             such that ys[i].index is not contained in ys[o].index
         np.ndarray are considered having (pandas) integer range index on axis 0
     """
-    from aeon.datatypes._utilities import get_index_for_series
-
-    # None entries are ignored
     y_not_None = [y for y in ys if y is not None]
 
     # if there is no or just one element, there is nothing to compare


Test patch:
diff --git a/aeon/benchmarking/tests/test_critical_difference.py b/aeon/benchmarking/tests/test_critical_difference.py
index ec47e57a..4dc2a525 100644
--- a/aeon/benchmarking/tests/test_critical_difference.py
+++ b/aeon/benchmarking/tests/test_critical_difference.py
@@ -64,9 +64,15 @@ def test_wilcoxon_test():
     res = get_estimator_results_as_array(
         estimators=cls, datasets=data_full, path=data_path, include_missing=True
     )
-    p_vals = _wilcoxon_test(res)
+    p_vals = _wilcoxon_test(res, cls)
     assert_almost_equal(p_vals[0], np.array([1.0, 0.0, 0.0, 0.0]), decimal=2)
 
+    res = get_estimator_results_as_array(
+        estimators=cls, datasets=data_full, path=data_path, include_missing=True
+    )
+    p_vals = _wilcoxon_test(res, cls, lower_better=True)
+    assert_almost_equal(p_vals[0], np.array([1, 0.99, 0.99, 0.99]), decimal=2)
+
 
 def test__check_friedman():
     """Test Friedman test for overall difference in estimators."""
@@ -200,3 +206,8 @@ def test_plot_critical_difference():
         reverse=True,
     )
     assert isinstance(plot, Figure)
+
+
+if __name__ == "__main__":
+    test_wilcoxon_test()
+    test_plot_critical_difference()
diff --git a/aeon/utils/_testing/estimator_checks.py b/aeon/utils/_testing/estimator_checks.py
index 4b366185..4bd1efc9 100644
--- a/aeon/utils/_testing/estimator_checks.py
+++ b/aeon/utils/_testing/estimator_checks.py
@@ -13,11 +13,11 @@ from aeon.base import BaseEstimator, BaseObject
 from aeon.classification.base import BaseClassifier
 from aeon.classification.early_classification import BaseEarlyClassifier
 from aeon.clustering.base import BaseClusterer
-from aeon.datatypes._panel._check import is_nested_dataframe
 from aeon.forecasting.base import BaseForecaster
 from aeon.regression.base import BaseRegressor
 from aeon.tests.test_config import VALID_ESTIMATOR_TYPES
 from aeon.transformations.base import BaseTransformer
+from aeon.utils.validation import is_nested_univ_dataframe
 
 
 def _get_err_msg(estimator):
@@ -110,7 +110,7 @@ def _compare_nested_frame(func, x, y, **kwargs):
     if x.empty:
         assert_frame_equal(x, y)
 
-    elif is_nested_dataframe(x):
+    elif is_nested_univ_dataframe(x):
         # Check if both inputs have the same shape
         if not x.shape == y.shape:
             raise ValueError("Found inputs with different shapes")
diff --git a/aeon/utils/tests/test_datetime.py b/aeon/utils/tests/test_datetime.py
index 14298234..594cbab1 100644
--- a/aeon/utils/tests/test_datetime.py
+++ b/aeon/utils/tests/test_datetime.py
@@ -9,11 +9,11 @@ import pandas as pd
 
 from aeon.datasets import load_airline
 from aeon.datatypes import VectorizedDF
-from aeon.datatypes._utilities import get_time_index
 from aeon.utils._testing.hierarchical import _bottom_hier_datagen
 from aeon.utils.datetime import (
     _coerce_duration_to_int,
     _get_freq,
+    get_time_index,
     infer_freq,
     set_hier_freq,
 )
diff --git a/aeon/utils/validation/tests/test_collection.py b/aeon/utils/validation/tests/test_collection.py
index f3dcf0ea..67f0c6d7 100644
--- a/aeon/utils/validation/tests/test_collection.py
+++ b/aeon/utils/validation/tests/test_collection.py
@@ -5,6 +5,7 @@ import pytest
 
 from aeon.datasets import make_example_multi_index_dataframe
 from aeon.utils._testing.tests.test_collection import make_nested_dataframe_data
+from aeon.utils.validation import is_nested_univ_dataframe
 from aeon.utils.validation._convert_collection import (
     _equal_length,
     _from_nested_univ_to_numpy2d,
@@ -21,7 +22,6 @@ from aeon.utils.validation._convert_collection import (
     _from_numpy3d_to_numpy2d,
     _from_numpy3d_to_pd_multiindex,
     _from_numpy3d_to_pd_wide,
-    _is_nested_univ_dataframe,
     _is_pd_wide,
     _nested_univ_is_equal,
 )
@@ -222,9 +222,9 @@ def test_is_univariate(data):
 @pytest.mark.parametrize("data", COLLECTIONS_DATA_TYPES)
 def test__is_nested_univ_dataframe(data):
     if data == "nested_univ":
-        assert _is_nested_univ_dataframe(EQUAL_LENGTH_UNIVARIATE[data])
+        assert is_nested_univ_dataframe(EQUAL_LENGTH_UNIVARIATE[data])
     else:
-        assert not _is_nested_univ_dataframe(EQUAL_LENGTH_UNIVARIATE[data])
+        assert not is_nested_univ_dataframe(EQUAL_LENGTH_UNIVARIATE[data])
 
 
 @pytest.mark.parametrize("data", COLLECTIONS_DATA_TYPES)