Repository: aeon-toolkit/aeon
Pull request #1394 (2024-04-09 15:56:04)

[ENH] `BaseSeriesEstimator` rework
This should all be internal, and does not impact any input types by the user. Testing, documentation and the notebook have been updated to match these changes.

- No longer converts `pd.DataFrame` to `pd.Series` if univariate
- Converts `np.ndarray` to a 2d array regardless of whether it's univariate or multivariate 

This should stop the swapping between data types and array shapes depending on input data and tags other than `X_inner_type`. This puts a bit more importance on the axis parameter, as 2 univariate types are now 2d.

- `X_inner_type` can be a list now

This is mostly for compatibility and parity with other usages of the tag. @TonyBagnall has mentioned they don't see a use-case for multiple datatypes, and I don't really either except for OTT optimisation.

- removes default for `axis` parameters and adds an `axis` parameter to `_check_y`

Setting the class and check function `axis` parameters correctly is very important to get correct conversions. I have removed the defaults, so hopefully it is not overlooked anywhere in the future. 

- Parameter for updating `metadata_` in `_preprocess_series`

General usage in this PR: overwrite in `fit` always, only overwrite in `predict`/`transform` if `fit` is empty.

- Moves `_check_y` to segmentation

This is specific to segmentation, labels will be different in other learning tasks i.e. anomaly detection.

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


I would have added the following labels to this PR based on the changes made: **[ $\\color{#0e8a16}{\\textsf{segmentation}}$, $\\color{#41A8F6}{\\textsf{transformations}}$ ]**, however some package labels are already present.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/1394/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.
    
review-notebook-app[bot]: Check out this pull request on&nbsp; <a href="https://app.reviewnb.com/aeon-toolkit/aeon/pull/1394"><img align="absmiddle"  alt="ReviewNB" height="28" class="BotMessageButtonImage" src="https://raw.githubusercontent.com/ReviewNB/support/master/images/button_reviewnb.png"/></a> 

 See visual diffs & provide feedback on Jupyter Notebooks. 

---

 <i>Powered by <a href='https://www.reviewnb.com/?utm_source=gh'>ReviewNB</a></i>
MatthewMiddlehurst: @TonyBagnall @baraline likely to impact your stuff
MatthewMiddlehurst: I have updated the notebook.
MatthewMiddlehurst: I'm fine with defaulting the multivariate capability to true.
TonyBagnall: its quite hard to review on files changed tab, so will do it here, re _convert_X. Some minor points

if pd.Series is passed we have this
```
            elif inner_type[0] == "Series":
                if self.get_tag("capability:multivariate"):
                    raise ValueError(
                        "Cannot convert to pd.Series for multivariate capable "
                        "estimators"
                    )
                if X.ndim > 1:
                    n_channels = X.shape[0] if axis == 1 else X.shape[1]
                    if n_channels > 1:
                        raise ValueError(
                            "Cannot convert to pd.Series for multivariate data. Found "
                            f"{n_channels} channels"
                        )
```
1. Why cant we convert a series to multivariate capable? We do with 1D numpy. You can just put it in a DataFrame? It seems inconsistent to me. 
2. If you squeeze a DataFrame with 1D it becomes a pd.Series, so this conversion is redundant
```
                X = X.squeeze()
                X = pd.Series(X)
```
e.g.
```import pandas as pd
X = pd.DataFrame([1,2,3,4,5])
y = X.squeeze()
print(type(y)) 
```
is pd.Series
3. So figured this out, but it seems a bit twisty. 
```
            elif inner_type[0] == "DataFrame":
                # converting a 1d array will create a 2d array in axis 0 format
                transpose = False
                if X.ndim == 1 and axis == 1:
                    transpose = True

                X = pd.DataFrame(X)

                if transpose:
                    X = X.T
```
cant you just have
```
                X = pd.DataFrame(X)
               if X.ndim == 1 and axis == 1:
                    X = X.T
```
it all looks like it could be condensed to fewer lines of code tbh. 

4. Why do we have to call get tag again? Already called it earlier,
```
            else:
                tag = self.get_tag("X_inner_type")
                raise ValueError(
                    f"Unknown inner type {inner_type[0]} derived from {tag}"
                )
```
5. Multiple values of inner_type seem to just be ignored, since you only test inner_type[0], so why have them? I still dont see the use case, and this means any list is ignored I think, which could be very confusing. if my inner type were ["np.ndarray", "pd.Series"], my expectation would be that if either were passed, the base class would not convert them. However, atm, it would I think get converted to np.ndarray?   
MatthewMiddlehurst: > Why cant we convert a series to multivariate capable? We do with 1D numpy. You can just put it in a DataFrame? It seems inconsistent to me.

We can, but that would involve removing `pd.Series` as an inner type altogether essentially. Not opposed.

> If you squeeze a DataFrame with 1D it becomes a pd.Series, so this conversion is redundant

This is also used for `np.ndarray` > `pd.Series`, where squeeze turns a 2D array into a 1D one (it would all go if we do the above).

> So figured this out, but it seems a bit twisty. cant you just have
```
X = pd.DataFrame(X)
if X.ndim == 1 and axis == 1:
    X = X.T
```

`pd.DataFrame` is always `X.ndim==2`, no such thing as a 1D `pd.DataFrame`, that's just a `pd.Series`. No idea why. What I want to do there is check if we have a 1D `np.ndarray` or `pd.Series` and see if we have to transpose the new `pd.DataFrame` (as it will always be `(n_timepoints, n_channels)` if given a 1D input).

> Why do we have to call get tag again? Already called it earlier,

I edit the output of the previous call. Can just use a different variable name for the edited version and ditch this line.

> Multiple values of inner_type seem to just be ignored, since you only test inner_type[0], so why have them? I still dont see the use case, and this means any list is ignored I think, which could be very confusing. if my inner type were ["np.ndarray", "pd.Series"], my expectation would be that if either were passed, the base class would not convert them. However, atm, it would I think get converted to np.ndarray?

This is mostly for compatibility with the tag. It says it (or other usages can) can take an array. The following line does what you suggest `if input not in inner_type:`, if input is already of an accepted type no conversion is done. Though now that I say that, it seems to be skipping some bits we wouldn't want it too currently. `inner_type[0]` is only used if we have to convert to something different.
TonyBagnall: yeah ok, can put this in if you resolve conflicts and we can discuss 1D options after
MatthewMiddlehurst: I have merged main into this. Please do double-check the postprocess function, some changes to match the new contents. Also changes to include docs and to testing.
TonyBagnall: weirdly, code cov is failing with these errors
<img width="626" alt="image" src="https://github.com/aeon-toolkit/aeon/assets/9594042/e83724b7-9f52-42d6-bb8c-9fb1dc802e08">

MatthewMiddlehurst: unrelated
TonyBagnall: Unrelated codecov fails


Patch:
diff --git a/aeon/base/_base_series.py b/aeon/base/_base_series.py
index 79433a7b..9a3f94e7 100644
--- a/aeon/base/_base_series.py
+++ b/aeon/base/_base_series.py
@@ -1,4 +1,11 @@
-"""Base class for estimators that fit single (possibly multivariate) time series."""
+"""Base class for estimators that fit single time series.
+
+This time series can be univariate or multivariate. The time series can potentially
+contain missing values.
+"""
+
+__maintainer__ = ["TonyBagnall", "MatthewMiddlehurst"]
+__all__ = ["BaseSeriesEstimator"]
 
 import numpy as np
 import pandas as pd
@@ -18,40 +25,33 @@ VALID_INPUT_TYPES = [pd.DataFrame, pd.Series, np.ndarray]
 class BaseSeriesEstimator(BaseEstimator):
     """Base class for estimators that use single (possibly multivariate) time series.
 
-    Provides functions that are common to BaseSeriesEstimator objects, including
-    BaseSeriesTransformer and BaseSegmenter, for the checking and
-    conversion of input to fit, predict and transform, where relevant.
+    Provides functions that are common to BaseSeriesEstimator objects for the checking
+    and conversion of input to fit, predict and transform, where relevant.
 
-    It also stores the common default tags used by all the subclasses and meta data
-    describing the characteristics of time series passed to ``fit``.
+    It also stores the common default tags used by all the subclasses and metadata
+    describing the characteristics of time series passed to ``fit`` (or another method
+    if fit does not exist).
 
-    input and internal data format
+    Input and internal data format (where m is the number of time points and d is the
+    number of channels):
         Univariate series:
-            Numpy array:
-            shape `(m,)`, `(m, 1)` or `(1, m)`. if ``self`` has no multivariate
-            capability, i.e.``self.get_tag(
-            ""capability:multivariate") == False``, all are converted to 1D
-            numpy `(m,)`
-            if ``self`` has multivariate capability, converted to 2D numpy `(m,1)` or
-            `(1, m)` depending on axis
-            pandas DataFrame or Series:
-            DataFrame single column shape `(m,1)`, `(1,m)` or Series shape `(m,)`
-            if ``self`` has no multivariate capability, all converted to Series `(m,)`
-            if ``self`` has multivariate capability, all converted to Pandas DataFrame
-            shape `(m,1)`, `(1,m)` depending on axis
-
+            np.ndarray, shape ``(m,)``, ``(m, 1)`` or ``(1, m)`` depending on axis.
+            This is converted to a 2D np.ndarray internally.
+            pd.DataFrame, shape ``(m, 1)`` or ``(1, m)`` depending on axis.
+            pd.Series, shape ``(m,)``.
         Multivariate series:
-            Numpy array, shape `(m,d)` or `(d,m)`.
-            pandas DataFrame `(m,d)` or `(d,m)`
+            np.ndarray array, shape ``(m, d)`` or ``(d, m)`` depending on axis.
+            pd.DataFrame ``(m, d)`` or ``(d, m)`` depending on axis.
 
     Parameters
     ----------
-    axis : int, default = 0
-        Axis along which to segment if passed a multivariate series (2D input). If axis
-        is 0, it is assumed each column is a time series and each row is a
-        timepoint. i.e. the shape of the data is ``(n_timepoints,n_channels)``.
-        ``axis == 1`` indicates the time series are in rows, i.e. the shape of the data
-        is ``(n_channels, n_timepoints)``.
+    axis : int
+        The time point axis of the input series if it is 2D. If ``axis==0``, it is
+        assumed each column is a time series and each row is a time point. i.e. the
+        shape of the data is ``(n_timepoints, n_channels)``. ``axis==1`` indicates
+        the time series are in rows, i.e. the shape of the data is
+        ``(n_channels, n_timepoints)``.
+        Setting this class variable will convert the input data to the chosen axis.
     """
 
     _tags = {
@@ -61,153 +61,183 @@ class BaseSeriesEstimator(BaseEstimator):
         "X_inner_type": "np.ndarray",  # one of VALID_INNER_TYPES
     }
 
-    def __init__(self, axis=0):
+    def __init__(self, axis):
         self.axis = axis
-        self.metadata_ = {}  # metadata/properties of data seen in fit
+        self.metadata_ = {}  # metadata/properties of data seen in fit/predict/transform
+
         super().__init__()
         _check_estimator_deps(self)
 
-    def _check_X(self, X):
-        """Check classifier input X is valid.
+    def _check_X(self, X, axis):
+        """Check input X is valid.
 
         Check if the input data is a compatible type, and that this estimator is
         able to handle the data characteristics. This is done by matching the
         capabilities of the estimator against the metadata for X for
-        univariate/multivariate, equal length/unequal length and no missing
-        values/missing values.
+        univariate/multivariate and no missing values/missing values.
 
         Parameters
         ----------
-        X : data structure
-           A valid aeon collection data structure. See
-           aeon.registry.COLLECTIONS_DATA_TYPES for details
-           on aeon supported data structures.
+        X: one of aeon.base._base_series.VALID_INPUT_TYPES
+            A valid aeon time series data structure. See
+            aeon.base._base_series.VALID_INPUT_TYPES for aeon supported types.
+        axis: int
+            The time point axis of the input series if it is 2D. If ``axis==0``, it is
+            assumed each column is a time series and each row is a time point. i.e. the
+            shape of the data is ``(n_timepoints,n_channels)``. ``axis==1`` indicates
+            the time series are in rows, i.e. the shape of the data is
+            ``(n_channels,n_timepoints)``.
 
         Returns
         -------
-        dict
-            Meta data about X, with flags:
-            metadata["missing_values"] : whether X has missing values or not
-            metadata["multivariate"] : whether X has more than one channel or not
-
-        See Also
-        --------
-        _convert_X : function that converts X after it has been checked.
+        metadata: dict
+            Metadata about X, with flags:
+            metadata["multivariate"]: whether X has more than one channel or not
+            metadata["n_channels"]: number of channels in X
+            metadata["missing_values"]: whether X has missing values or not
         """
-        # Checks: check valid type and axis
-        if type(X) not in VALID_INPUT_TYPES:
-            raise ValueError(
-                f"Error in input type should be one of "
-                f" {VALID_INNER_TYPES}, saw {type(X)}"
-            )
+        if axis > 1 or axis < 0:
+            raise ValueError(f"Input axis should be 0 or 1, saw {axis}")
+
+        # Checks: check valid dtype
         if isinstance(X, np.ndarray):
-            # Check valid shape
-            if X.ndim > 2:
-                raise ValueError("Should be 1D or 2D")
             if not (
                 issubclass(X.dtype.type, np.integer)
                 or issubclass(X.dtype.type, np.floating)
             ):
-                raise ValueError("np.ndarray must contain floats or ints")
+                raise ValueError("dtype for np.ndarray must be float or int")
         elif isinstance(X, pd.Series):
             if not pd.api.types.is_numeric_dtype(X):
-                raise ValueError("pd.Series must be numeric")
-        else:
+                raise ValueError("pd.Series dtype must be numeric")
+        elif isinstance(X, pd.DataFrame):
             if not all(pd.api.types.is_numeric_dtype(X[col]) for col in X.columns):
-                raise ValueError("pd.DataFrame must be numeric")
-        # If X is a single series dataframe, we squeeze it into Series in convert_X
-        X = X.squeeze()
+                raise ValueError("pd.DataFrame dtype must be numeric")
+        else:
+            raise ValueError(
+                f"Input type of X should be one of {VALID_INNER_TYPES}, saw {type(X)}"
+            )
+
         metadata = {}
-        metadata["multivariate"] = False
-        # Need to differentiate because a 1D series stored in a dataframe will have
-        # ndim=2. This case is dealt with in convert through squeezing to 1D
-        if X.ndim > 1:
+
+        # check if multivariate
+        channel_idx = 0 if axis == 1 else 1
+        if X.ndim > 2:
+            raise ValueError(
+                "X must have at most 2 dimensions for multivariate data, optionally 1 "
+                f"for univarate data. Found {X.ndim} dimensions"
+            )
+        elif X.ndim > 1 and X.shape[channel_idx] > 1:
             metadata["multivariate"] = True
+        else:
+            metadata["multivariate"] = False
+
+        metadata["n_channels"] = X.shape[channel_idx] if X.ndim > 1 else 1
+
+        # check if has missing values
         if isinstance(X, np.ndarray):
             metadata["missing_values"] = np.isnan(X).any()
         elif isinstance(X, pd.Series):
             metadata["missing_values"] = X.isna().any()
-        elif isinstance(X, pd.DataFrame):
+        else:
             metadata["missing_values"] = X.isna().any().any()
+
         allow_multivariate = self.get_tag("capability:multivariate")
         allow_univariate = self.get_tag("capability:univariate")
         allow_missing = self.get_tag("capability:missing_values")
         if metadata["missing_values"] and not allow_missing:
-            raise ValueError("Missing values not supported")
+            raise ValueError(
+                f"Missing values not supported by {self.__class__.__name__}"
+            )
         if metadata["multivariate"] and not allow_multivariate:
-            raise ValueError("Multivariate data not supported")
+            raise ValueError(
+                f"Multivariate data not supported by {self.__class__.__name__}"
+            )
         if not metadata["multivariate"] and not allow_univariate:
-            raise ValueError("Univariate data not supported")
+            raise ValueError(
+                f"Univariate data not supported by {self.__class__.__name__}"
+            )
+
         return metadata
 
-    def _check_y(self, y: VALID_INPUT_TYPES):
-        """Check y specific to segmentation.
+    def _convert_X(self, X, axis):
+        """Convert input X to internal estimator datatype.
+
+        Converts input X to the internal data type of the estimator using
+        self.get_tag("X_inner_type"). 1D numpy arrays are converted to 2D,
+        and the data will be transposed if the input axis does not match that of the
+        estimator.
+
+        Attempting to convert to a pd.Series for multivariate data or estimators will
+        raise an error.
 
-        y must be a univariate series
+        Parameters
+        ----------
+        X: one of aeon.base._base_series.VALID_INPUT_TYPES
+            A valid aeon time series data structure. See
+            aeon.base._base_series.VALID_INPUT_TYPES for aeon supported types.
+        axis: int
+            The time point axis of the input series if it is 2D. If ``axis==0``, it is
+            assumed each column is a time series and each row is a time point. i.e. the
+            shape of the data is ``(n_timepoints, n_channels)``. ``axis==1`` indicates
+            the time series are in rows, i.e. the shape of the data is
+            ``(n_channels, n_timepoints)``.
+
+        Returns
+        -------
+        X: one of aeon.base._base_series.VALID_INPUT_TYPES
+            Input time series with data structure of type self.get_tag("X_inner_type").
         """
-        if type(y) not in VALID_INPUT_TYPES:
-            raise ValueError(
-                f"Error in input type for y: it should be one of "
-                f"{VALID_INPUT_TYPES}, saw {type(y)}"
-            )
-        if isinstance(y, np.ndarray):
-            # Check valid shape
-            if y.ndim > 1:
-                raise ValueError(
-                    "Error in input type for y: y input as np.ndarray " "should be 1D"
-                )
-            if not (
-                issubclass(y.dtype.type, np.integer)
-                or issubclass(y.dtype.type, np.floating)
-            ):
-                raise ValueError(
-                    "Error in input type for y: y input must contain " "floats or ints"
-                )
-        elif isinstance(y, pd.Series):
-            if not pd.api.types.is_numeric_dtype(y):
-                raise ValueError(
-                    "Error in input type for y: y input as pd.Series must be numeric"
-                )
-        else:  # pd.DataFrame
-            if y.shape[1] > 2:
-                raise ValueError(
-                    "Error in input type for y: y input as pd.DataFrame "
-                    "should have a single "
-                    "column series"
-                )
+        if axis > 1 or axis < 0:
+            raise ValueError(f"Input axis should be 0 or 1, saw {axis}")
 
-            if not all(pd.api.types.is_numeric_dtype(y[col]) for col in y.columns):
-                raise ValueError(
-                    "Error in input type for y: y input as pd.DataFrame "
-                    "must be numeric"
-                )
+        inner_type = self.get_tag("X_inner_type")
+        if not isinstance(inner_type, list):
+            inner_type = [inner_type]
+        inner_names = [i.split(".")[-1] for i in inner_type]
 
-    def _convert_X(self, X, axis):
-        inner = self.get_tag("X_inner_type").split(".")[-1]
         input = type(X).__name__
-        if inner != input:
-            if inner == "ndarray":
+        if input not in inner_names:
+            if inner_names[0] == "ndarray":
                 X = X.to_numpy()
-            elif inner == "Series":
-                if input == "ndarray":
-                    X = pd.Series(X)
-            elif inner == "DataFrame":
+            elif inner_names[0] == "Series":
+                if self.get_tag("capability:multivariate"):
+                    raise ValueError(
+                        "Cannot convert to pd.Series for multivariate capable "
+                        "estimators"
+                    )
+                if X.ndim > 1:
+                    n_channels = X.shape[0] if axis == 1 else X.shape[1]
+                    if n_channels > 1:
+                        raise ValueError(
+                            "Cannot convert to pd.Series for multivariate data. Found "
+                            f"{n_channels} channels"
+                        )
+
+                X = X.squeeze()
+                X = pd.Series(X)
+            elif inner_names[0] == "DataFrame":
+                # converting a 1d array will create a 2d array in axis 0 format
+                transpose = False
+                if X.ndim == 1 and axis == 1:
+                    transpose = True
+
                 X = pd.DataFrame(X)
+
+                if transpose:
+                    X = X.T
             else:
-                tag = self.get_tag("X_inner_type")
-                raise ValueError(f"Unsupported inner type {inner} derived from {tag}")
-        if axis > 1 or axis < 0:
-            raise ValueError("Axis should be 0 or 1")
-        if not self.get_tag("capability:multivariate"):
-            X = X.squeeze()
-        elif X.ndim == 1:  # np.ndarray case make 2D
-            X = X.reshape(1, -1)
-        if X.ndim > 1:
-            if self.axis != axis:
-                X = X.T
+                raise ValueError(
+                    f"Unsupported inner type {inner_names[0]} derived from {inner_type}"
+                )
+
+        if X.ndim > 1 and self.axis != axis:
+            X = X.T
+        elif X.ndim == 1 and isinstance(X, np.ndarray):
+            X = X[np.newaxis, :] if self.axis == 1 else X[:, np.newaxis]
+
         return X
 
-    def _preprocess_series(self, X, axis=None):
+    def _preprocess_series(self, X, axis, store_metadata):
         """Preprocess input X prior to call to fit.
 
         Checks the characteristics of X, store metadata, checks self can handle
@@ -215,49 +245,64 @@ class BaseSeriesEstimator(BaseEstimator):
 
         Parameters
         ----------
-        X : one of VALID_INNER_TYPES
+        X: one of aeon.base._base_series.VALID_INPUT_TYPES
+            A valid aeon time series data structure. See
+            aeon.base._base_series.VALID_INPUT_TYPES for aeon supported types.
         axis: int or None
+            The time point axis of the input series if it is 2D. If ``axis==0``, it is
+            assumed each column is a time series and each row is a time point. i.e. the
+            shape of the data is ``(n_timepoints, n_channels)``. ``axis==1`` indicates
+            the time series are in rows, i.e. the shape of the data is
+            ``(n_channels, n_timepoints)``.
+            If None, the default class axis is used.
+        store_metadata: bool
+            If True, overwrite metadata with the new metadata from X.
 
         Returns
         -------
-        Data structure of type self.tags["X_inner_type"]
-
-        See Also
-        --------
-        _check_X : function that checks X is valid before conversion.
-        _convert_X : function that converts to inner type.
-        pass
+        X: one of aeon.base._base_series.VALID_INPUT_TYPES
+            Input time series with data structure of type self.get_tag("X_inner_type").
         """
         if axis is None:
             axis = self.axis
-        meta = self._check_X(X)
-        if len(self.metadata_) == 0:
+
+        meta = self._check_X(X, axis)
+        if store_metadata:
             self.metadata_ = meta
+
         return self._convert_X(X, axis)
 
-    def _postprocess_series(self, X, axis=None):
-        """Postprocess data X to revert to original shape."""
-        # If a univariate only transformer, return a univariate series
-        if not self.get_tag("capability:multivariate"):
-            return X.squeeze()
-        # If passed an axis, return with that axis
-        if axis is None or axis == self.axis:
-            return X
-        return X.T
-
-    @classmethod
-    def get_test_params(cls, parameter_set="default"):
-        """
-        Return testing parameter settings for the estimator.
+    def _postprocess_series(self, Xt, axis):
+        """Postprocess data Xt to revert to original shape.
 
         Parameters
         ----------
-        parameter_set : str, default="default"
+        Xt: one of aeon.base._base_series.VALID_INPUT_TYPES
+            A valid aeon time series data structure. See
+            aeon.base._base_series.VALID_INPUT_TYPES for aeon supported types.
+            Intended for algorithms which have another series as output.
+        axis: int or None
+            The time point axis of the input series if it is 2D. If ``axis==0``, it is
+            assumed each column is a time series and each row is a time point. i.e. the
+            shape of the data is ``(n_timepoints, n_channels)``. ``axis==1`` indicates
+            the time series are in rows, i.e. the shape of the data is
+            ``(n_channels, n_timepoints)``.
+            If None, the default class axis is used.
 
         Returns
         -------
-        params : dict or list of dict, default = {}
-            Parameters to create testing instances of the class.
+        Xt: one of aeon.base._base_series.VALID_INPUT_TYPES
+            New time series input reshaped to match the original input.
         """
-        # default parameters = empty dict
-        return {"axis": 0}
+        if axis is None:
+            axis = self.axis
+
+        # If a univariate only transformer, return a univariate series
+        if not self.get_tag("capability:multivariate"):
+            Xt = Xt.squeeze()
+
+        # return with input axis
+        if Xt.ndim == 1 or axis == self.axis:
+            return Xt
+        else:
+            return Xt.T
diff --git a/aeon/segmentation/_clasp.py b/aeon/segmentation/_clasp.py
index eb44680f..65696ddf 100644
--- a/aeon/segmentation/_clasp.py
+++ b/aeon/segmentation/_clasp.py
@@ -225,6 +225,7 @@ class ClaSPSegmenter(BaseSegmenter):
         list
             List of change points found in X.
         """
+        X = X.squeeze()
         if len(X) - self.period_length < 2 * self.exclusion_radius * len(X):
             warnings.warn(
                 "Period-Length is larger than size of the time series", stacklevel=1
diff --git a/aeon/segmentation/_hmm.py b/aeon/segmentation/_hmm.py
index a8ab1b11..5d772a5b 100644
--- a/aeon/segmentation/_hmm.py
+++ b/aeon/segmentation/_hmm.py
@@ -363,6 +363,7 @@ class HMMSegmenter(BaseSegmenter):
         annotated_x : array-like, shape = [num_observations]
             Array of predicted class labels, same size as input.
         """
+        X = X.squeeze()
         self.num_states = len(self.emission_funcs)
         self.states = list(range(self.num_states))
         self.num_obs = len(X)
diff --git a/aeon/segmentation/base.py b/aeon/segmentation/base.py
index 9c265474..3ac70dad 100644
--- a/aeon/segmentation/base.py
+++ b/aeon/segmentation/base.py
@@ -7,8 +7,10 @@ from abc import ABC, abstractmethod
 from typing import List, final
 
 import numpy as np
+import pandas as pd
 
 from aeon.base import BaseSeriesEstimator
+from aeon.base._base_series import VALID_INPUT_TYPES
 
 
 class BaseSegmenter(BaseSeriesEstimator, ABC):
@@ -150,7 +152,7 @@ class BaseSegmenter(BaseSeriesEstimator, ABC):
         self.reset()
         if axis is None:  # If none given, assume it is correct.
             axis = self.axis
-        X = self._preprocess_series(X, axis)
+        X = self._preprocess_series(X, axis, True)
         if y is not None:
             y = self._check_y(y)
         self._fit(X=X, y=y)
@@ -184,7 +186,7 @@ class BaseSegmenter(BaseSeriesEstimator, ABC):
         self.check_is_fitted()
         if axis is None:
             axis = self.axis
-        X = self._preprocess_series(X, axis)
+        X = self._preprocess_series(X, axis, self.get_class_tag("fit_is_empty"))
         return self._predict(X)
 
     def fit_predict(self, X, y=None, axis=None):
@@ -203,6 +205,48 @@ class BaseSegmenter(BaseSeriesEstimator, ABC):
         """Create and return a segmentation of X."""
         ...
 
+    def _check_y(self, y: VALID_INPUT_TYPES):
+        """Check y specific to segmentation.
+
+        y must be a univariate series
+        """
+        if type(y) not in VALID_INPUT_TYPES:
+            raise ValueError(
+                f"Error in input type for y: it should be one of "
+                f"{VALID_INPUT_TYPES}, saw {type(y)}"
+            )
+        if isinstance(y, np.ndarray):
+            # Check valid shape
+            if y.ndim > 1:
+                raise ValueError(
+                    "Error in input type for y: y input as np.ndarray " "should be 1D"
+                )
+            if not (
+                issubclass(y.dtype.type, np.integer)
+                or issubclass(y.dtype.type, np.floating)
+            ):
+                raise ValueError(
+                    "Error in input type for y: y input must contain " "floats or ints"
+                )
+        elif isinstance(y, pd.Series):
+            if not pd.api.types.is_numeric_dtype(y):
+                raise ValueError(
+                    "Error in input type for y: y input as pd.Series must be numeric"
+                )
+        else:  # pd.DataFrame
+            if y.shape[1] > 2:
+                raise ValueError(
+                    "Error in input type for y: y input as pd.DataFrame "
+                    "should have a single "
+                    "column series"
+                )
+
+            if not all(pd.api.types.is_numeric_dtype(y[col]) for col in y.columns):
+                raise ValueError(
+                    "Error in input type for y: y input as pd.DataFrame "
+                    "must be numeric"
+                )
+
     @classmethod
     def to_classification(cls, change_points: List[int], length: int):
         """Convert change point locations to a classification vector.
diff --git a/aeon/transformations/series/_matrix_profile.py b/aeon/transformations/series/_matrix_profile.py
index 965d8f77..9cc1d6ce 100644
--- a/aeon/transformations/series/_matrix_profile.py
+++ b/aeon/transformations/series/_matrix_profile.py
@@ -67,6 +67,8 @@ class MatrixProfileSeriesTransformer(BaseSeriesTransformer):
         _check_soft_dependencies("stumpy", severity="error")
         import stumpy
 
+        X = X.squeeze()
+
         self.matrix_profile_ = stumpy.stump(X, self.window_length)
         return self.matrix_profile_[:, 0].astype("float")
 
diff --git a/aeon/transformations/series/base.py b/aeon/transformations/series/base.py
index 878dc2c6..209a2984 100644
--- a/aeon/transformations/series/base.py
+++ b/aeon/transformations/series/base.py
@@ -70,7 +70,7 @@ class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer, metaclass=ABCM
         self.reset()
         if axis is None:  # If none given, assume it is correct.
             axis = self.axis
-        X = self._preprocess_series(X, axis=axis)
+        X = self._preprocess_series(X, axis=axis, store_metadata=True)
         if y is not None:
             self._check_y(y)
         self._fit(X=X, y=y)
@@ -109,7 +109,9 @@ class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer, metaclass=ABCM
 
         if axis is None:
             axis = self.axis
-        X = self._preprocess_series(X, axis=axis)
+        X = self._preprocess_series(
+            X, axis=axis, store_metadata=self.get_class_tag("fit_is_empty")
+        )
         Xt = self._transform(X)
         return self._postprocess_series(Xt, axis=axis)
 
@@ -145,7 +147,7 @@ class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer, metaclass=ABCM
         """
         # input checks and datatype conversion, to avoid doing in both fit and transform
         self.reset()
-        X = self._preprocess_series(X, axis=axis)
+        X = self._preprocess_series(X, axis=axis, store_metadata=True)
         Xt = self._fit_transform(X=X, y=y)
         self._is_fitted = True
         return self._postprocess_series(Xt, axis=axis)
@@ -179,7 +181,9 @@ class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer, metaclass=ABCM
 
         # check whether is fitted
         self.check_is_fitted()
-        X = self._preprocess_series(X, axis=axis)
+        X = self._preprocess_series(
+            X, axis=axis, store_metadata=self.get_class_tag("fit_is_empty")
+        )
         Xt = self._inverse_transform(X=X, y=y)
         return self._postprocess_series(Xt, axis=axis)
 
@@ -204,7 +208,7 @@ class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer, metaclass=ABCM
         """
         # check whether is fitted
         self.check_is_fitted()
-        X = self._preprocess_series(X, axis=axis)
+        X = self._preprocess_series(X, axis, self.get_class_tag("fit_is_empty"))
         return self._update(X=X, y=y, update_params=update_params)
 
     def _fit(self, X, y=None):
diff --git a/examples/base/series_estimator.ipynb b/examples/base/series_estimator.ipynb
index 91e63d4b..50702eca 100644
--- a/examples/base/series_estimator.ipynb
+++ b/examples/base/series_estimator.ipynb
@@ -5,198 +5,458 @@
    "source": [
     "# BaseSeriesEstimator\n",
     "\n",
-    "The ``BaseSeriesEstimator``class is a base class for estimators that take a single\n",
-    "series as input rather than a collection of time series (see\n",
-    "``BaseCollectionEstimator``). This notebook describes the major design issues to bare\n",
-    " in mind if using any class that inherits from ``BaseSeriesEstimator``. To use any\n",
-    " base estimator all you need to understand is the meaning of ``axis`` and the capability\n",
-    "  tags.\n",
+    "The `BaseSeriesEstimator` class is a base class for estimators that take a single series (both univariate and multivariate) as input rather than a collection of time series (see `BaseCollectionEstimator`). This notebook describes the major design issues to keep in mind if using any class that inherits from `BaseSeriesEstimator`. These are:\n",
+    "- `BaseSeriesTransformer` for single series transformations\n",
+    "- `BaseSegmenter` for segmentation\n",
+    "- `BaseAnomalyDetector` for anomaly detection\n",
     "\n",
-    "``BaseSeriesEstimator`` handles the preprocessing required for a single before being\n",
-    "used in a method such as ``fit``. These are used in base classes by\n",
-    "applying the protected method ``preprocess_series``. The key steps to note are:\n",
-    "1. Input data type should be a ``np.ndarray``, a ``pd.Series`` or a ``pd.DataFrame``.\n",
-    "2. The input data will be transformed into the type required by the estimator as\n",
-    "determined by the tag ``X_inner_type``.\n",
-    "3. If the estimator can only work with univariate time series\n",
-    "(``capability:multivariate`` set to False) then the input data will be converted to a\n",
-    " 1D numpy array or a pandas Series.\n",
-    "4. If the estimator has the ability to handle multivariate time series as determined\n",
-    " by the tag ``capability:multivariate``, then the input data will stored in either a 2\n",
-    "  D numpy array or a pandas DataFrames.\n",
-    "5. If the data is multivariate, then the ``axis`` varaible of the estimator controls\n",
-    "how it is interpreted. If ``axis==0`` then each column is a time series, and\n",
-    "each row is a time point: i.e. the shape of the data is ``(n_timepoints,n_channels)``\n",
-    ". If ``axis == 1`` indicates the time series are in rows, i.e. the shape of the data\n",
-    "is ``(n_channels, n_timepoints)``.\n",
+    "To use any algorithms extending the base estimator all you need to understand is the meaning of the `axis` parameter and the capability tags. `BaseSeriesEstimator` handles the preprocessing required before being used in methods such as `fit` and `predict`. These are used in inheriting base classes by applying the protected method `_preprocess_series`. The key steps to note are:\n",
+    "1. Input data type should be a `np.ndarray`, a `pd.Series` or a `pd.DataFrame`.\n",
+    "2. Unless the `X_inner_type` of the estimator is `pd.Series`, the `axis` variable of the estimator controls how the input data is interpreted in methods such as `fit`, `predict` and `transform`. If `axis==0` then each column is a time series, and each row is a time point: i.e. the shape of the input data is `(n_timepoints, n_channels)`. If `axis==1` indicates the time series are in rows, i.e. the shape of the data is `(n_channels, n_timepoints)`. It is important to set this correctly or check the default used, otherwise your data may be processed incorrectly.\n",
+    "3. The input data will be transformed into the type required by the estimator as determined by the tag `X_inner_type`. This will also reshape the array to use the correct time point `axis` and expand the input to 2D if it is a 1D `np.ndarray`.\n",
+    "4. If the estimator can only work with univariate time series (`capability:multivariate` set to False) then the input data shape must be 1D or 2D with the selected channel `axis` being size 1.\n",
+    "5. If the estimator can only work with multivariate time series (`capability:univariate` set to False), then the input data must be 2D, with the selected channel `axis` greater than 1. `pd.Series` is not supported in this case.\n",
     "\n",
-    "We demonstrate this with calls to private methods. This is purely to aide understanding\n",
-    " and should not be used in practice."
+    "We demonstrate this with calls to private methods. This is purely to aid understanding and should not be used in practice."
    ],
    "metadata": {
     "collapsed": false
    }
   },
   {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.444241Z",
+     "start_time": "2024-04-10T15:04:55.744636Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "\n",
+    "from aeon.base import BaseSeriesEstimator\n",
+    "\n",
+    "# We use the abstract base class for example purposes, regular classes will not\n",
+    "# have a class axis parameter.\n",
+    "bs = BaseSeriesEstimator(axis=0)"
+   ],
+   "outputs": [],
+   "execution_count": 1
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": "## Univariate examples"
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.453217Z",
+     "start_time": "2024-04-10T15:04:57.445239Z"
+    }
+   },
    "cell_type": "code",
-   "execution_count": 28,
+   "source": [
+    "# By default, \"capability:multivariate\" is False, axis is 0 and X_inner_type is\n",
+    "# np.ndarray\n",
+    "# With this config, the output should always be an np.ndarray shape (100, 1)\n",
+    "d1 = np.random.random(size=(100))\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
+    "print(\n",
+    "    f\"1. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
+   ],
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "1. Input shape =  (100,)  output type =  <class 'numpy.ndarray'>  output shape =  (100,)\n",
-      "2. Input shape =  (1, 100)  output type =  <class 'numpy.ndarray'>  output shape =  (100,)\n",
-      "3. Input shape =  (100,)  output type =  <class 'numpy.ndarray'>  output shape =  (100,)\n",
-      "4. Input shape =  (100,)  output type =  <class 'numpy.ndarray'>  output shape =  (100,)\n",
-      "5. Input shape =  (100, 1)  output type =  <class 'numpy.ndarray'>  output shape =  (100,)\n"
+      "1. Input type = <class 'numpy.ndarray'>, input shape = (100,), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
      ]
     }
    ],
+   "execution_count": 2
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.461197Z",
+     "start_time": "2024-04-10T15:04:57.455212Z"
+    }
+   },
+   "cell_type": "code",
    "source": [
-    "# Univariate examples\n",
-    "import numpy as np\n",
-    "import pandas as pd\n",
-    "import pytest\n",
-    "\n",
-    "from aeon.base import BaseSeriesEstimator\n",
-    "\n",
-    "bs = BaseSeriesEstimator()\n",
-    "# By default, \"capability:multivariate\" is False, axis is 0 and\n",
-    "# X_inner_type is np.ndarray\n",
+    "# The axis parameter will not change the output shape of 1D inputs such as pd.Series\n",
+    "# or univariate np.ndarray\n",
     "d1 = np.random.random(size=(100))\n",
-    "# With this config, the output should always be an np.ndarray\n",
-    "# shape (100,)\n",
-    "d2 = bs._preprocess_series(d1, axis=0)\n",
+    "d2 = bs._preprocess_series(d1, axis=1, store_metadata=True)\n",
     "print(\n",
-    "    \"1. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
-    ")\n",
-    "# 2D numpy shape (m,1) or (1,m) get converted to 1D numpy array\n",
-    "# if multivariate is False\n",
+    "    f\"2. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "2. Input type = <class 'numpy.ndarray'>, input shape = (100,), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
+     ]
+    }
+   ],
+   "execution_count": 3
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.468177Z",
+     "start_time": "2024-04-10T15:04:57.462193Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# A 2D array with the channel axis of size 1 will produce the same result\n",
+    "d1 = np.random.random(size=(100, 1))\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
+    "print(\n",
+    "    f\"3. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "3. Input type = <class 'numpy.ndarray'>, input shape = (100, 1), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
+     ]
+    }
+   ],
+   "execution_count": 4
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.474979Z",
+     "start_time": "2024-04-10T15:04:57.470430Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# The shape used can be swapped, but the axis parameter must be set correctly\n",
     "d1 = np.random.random(size=(1, 100))\n",
-    "d2 = bs._preprocess_series(d1, axis=0)\n",
+    "d2 = bs._preprocess_series(d1, axis=1, store_metadata=True)\n",
     "print(\n",
-    "    \"2. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
-    ")\n",
+    "    f\"4. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "4. Input type = <class 'numpy.ndarray'>, input shape = (1, 100), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
+     ]
+    }
+   ],
+   "execution_count": 5
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.481960Z",
+     "start_time": "2024-04-10T15:04:57.475976Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# Other types will be converted to X_inner_type\n",
     "d1 = pd.Series(np.random.random(size=(100)))\n",
-    "d2 = bs._preprocess_series(d1, axis=0)\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
     "print(\n",
-    "    \"3. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
-    ")\n",
-    "# Axis is irrelevant for univariate data\n",
-    "d2 = bs._preprocess_series(d1, axis=1)\n",
-    "print(\n",
-    "    \"4. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
-    ")\n",
+    "    f\"5. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "5. Input type = <class 'pandas.core.series.Series'>, input shape = (100,), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
+     ]
+    }
+   ],
+   "execution_count": 6
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.489939Z",
+     "start_time": "2024-04-10T15:04:57.482958Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
     "d1 = pd.DataFrame(np.random.random(size=(100, 1)))\n",
-    "d2 = bs._preprocess_series(d1, axis=0)\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
     "print(\n",
-    "    \"5. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
-    ")\n",
-    "\n",
-    "# Passing a multivariate array will raise an error\n",
-    "with pytest.raises(ValueError, match=r\"Multivariate data not supported\"):\n",
-    "    bs._check_X(np.random.random(size=(4, 100)))"
+    "    f\"6. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")"
    ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "6. Input type = <class 'pandas.core.frame.DataFrame'>, input shape = (100, 1), output type = <class 'numpy.ndarray'>, output shape = (100, 1)\n"
+     ]
+    }
+   ],
+   "execution_count": 7
+  },
+  {
    "metadata": {
-    "collapsed": false
-   }
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.497917Z",
+     "start_time": "2024-04-10T15:04:57.490936Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "bs = bs.set_tags(**{\"X_inner_type\": \"pd.Series\"})\n",
+    "d1 = np.random.random(size=(100))\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
+    "print(\n",
+    "    f\"7. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")\n",
+    "bs = bs.set_tags(**{\"X_inner_type\": \"np.ndarray\"})"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "7. Input type = <class 'numpy.ndarray'>, input shape = (100,), output type = <class 'pandas.core.series.Series'>, output shape = (100,)\n"
+     ]
+    }
+   ],
+   "execution_count": 8
   },
   {
    "cell_type": "code",
-   "execution_count": 25,
+   "source": [
+    "# Passing a multivariate array will raise an error if capability:multivariate is False\n",
+    "d1 = np.random.random(size=(100, 5))\n",
+    "try:\n",
+    "    bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
+    "except ValueError as e:\n",
+    "    print(f\"8. {e}\")"
+   ],
+   "metadata": {
+    "collapsed": false,
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.504899Z",
+     "start_time": "2024-04-10T15:04:57.498915Z"
+    }
+   },
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "1. Input shape =  (4, 100)  output type =  <class 'numpy.ndarray'>  output shape =  (4, 100)\n",
-      "2. Input shape =  (4, 100)  output type =  <class 'numpy.ndarray'>  output shape =  (100, 4)\n",
-      "2. Input type = <class 'pandas.core.frame.DataFrame'> Input shape =  (4, 100)  output type =  <class 'numpy.ndarray'> output shape =  (100, 4)\n"
+      "8. Multivariate data not supported by BaseSeriesEstimator\n"
      ]
     }
    ],
+   "execution_count": 9
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": "## Multivariate examples"
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.510883Z",
+     "start_time": "2024-04-10T15:04:57.505896Z"
+    }
+   },
+   "cell_type": "code",
    "source": [
-    "# Multivariate examples\n",
-    "# Set tags\n",
-    "bs.set_tags(**{\"capability:multivariate\": True})\n",
-    "d1 = np.random.random(size=(4, 100))\n",
-    "# Axis 0 means each row is a time series\n",
-    "d2 = bs._preprocess_series(d1, axis=0)\n",
+    "# The capability:multivariate tag must be set to True to work with multivariate series\n",
+    "# If the estimator does not have this tag, then the implementation cannot handle the\n",
+    "# input\n",
+    "bs = bs.set_tags(**{\"capability:multivariate\": True})\n",
+    "# Both of these can be True at the same time, but for examples sake we disable\n",
+    "# univariate\n",
+    "bs = bs.set_tags(**{\"capability:univariate\": False})"
+   ],
+   "outputs": [],
+   "execution_count": 10
+  },
+  {
+   "cell_type": "code",
+   "source": [
+    "# axis 0 means each row is a time series\n",
+    "d1 = np.random.random(size=(100, 5))\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
     "print(\n",
-    "    \"1. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
+    "    f\"1. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
     ")\n",
-    "# Axis 1 means each column is a time series\n",
-    "d2 = bs._preprocess_series(d1, axis=1)\n",
+    "n_channels = bs.metadata_[\"n_channels\"]\n",
+    "print(f\"n_channels: {n_channels}\")"
+   ],
+   "metadata": {
+    "collapsed": false,
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.517865Z",
+     "start_time": "2024-04-10T15:04:57.511880Z"
+    }
+   },
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "1. Input type = <class 'numpy.ndarray'>, input shape = (100, 5), output type = <class 'numpy.ndarray'>, output shape = (100, 5)\n",
+      "n_channels: 5\n"
+     ]
+    }
+   ],
+   "execution_count": 11
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.524846Z",
+     "start_time": "2024-04-10T15:04:57.518861Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# axis 1 means each column is a time series. If the axis is set incorrectly, the\n",
+    "# output shape will be wrong\n",
+    "d1 = np.random.random(size=(100, 5))\n",
+    "d2 = bs._preprocess_series(d1, axis=1, store_metadata=True)\n",
     "print(\n",
-    "    \"2. Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \" output shape = \",\n",
-    "    d2.shape,\n",
+    "    f\"2. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
     ")\n",
-    "d1 = pd.DataFrame(d1)\n",
-    "d2 = bs._preprocess_series(d1, axis=1)\n",
+    "n_channels = bs.metadata_[\"n_channels\"]\n",
+    "print(f\"n_channels: {n_channels}\")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "2. Input type = <class 'numpy.ndarray'>, input shape = (100, 5), output type = <class 'numpy.ndarray'>, output shape = (5, 100)\n",
+      "n_channels: 100\n"
+     ]
+    }
+   ],
+   "execution_count": 12
+  },
+  {
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.532825Z",
+     "start_time": "2024-04-10T15:04:57.525844Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# Conversions work similar to univariate series, but there is more emphasis on correctly\n",
+    "# setting the axis parameter\n",
+    "d1 = pd.DataFrame(np.random.random(size=(100, 5)))\n",
+    "d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
     "print(\n",
-    "    \"2. Input type =\",\n",
-    "    type(d1),\n",
-    "    \"Input shape = \",\n",
-    "    d1.shape,\n",
-    "    \" output type = \",\n",
-    "    type(d2),\n",
-    "    \"output shape = \",\n",
-    "    d2.shape,\n",
-    ")"
+    "    f\"3. Input type = {type(d1)}, input shape = {d1.shape}, \"\n",
+    "    f\"output type = {type(d2)}, output shape = {d2.shape}\"\n",
+    ")\n",
+    "n_channels = bs.metadata_[\"n_channels\"]\n",
+    "print(f\"n_channels: {n_channels}\")"
    ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "3. Input type = <class 'pandas.core.frame.DataFrame'>, input shape = (100, 5), output type = <class 'numpy.ndarray'>, output shape = (100, 5)\n",
+      "n_channels: 5\n"
+     ]
+    }
+   ],
+   "execution_count": 13
+  },
+  {
    "metadata": {
-    "collapsed": false
-   }
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.540803Z",
+     "start_time": "2024-04-10T15:04:57.535816Z"
+    }
+   },
+   "cell_type": "code",
+   "source": [
+    "# As pd.Series is univariate only, it is not allowed as an inner type for multivariate\n",
+    "# This should not be an issue for usage, just for development\n",
+    "bs = bs.set_tags(**{\"X_inner_type\": \"pd.Series\"})\n",
+    "d1 = np.random.random(size=(100, 5))\n",
+    "try:\n",
+    "    d2 = bs._preprocess_series(d1, axis=1, store_metadata=True)\n",
+    "except ValueError as e:\n",
+    "    print(f\"4. {e}\")\n",
+    "bs = bs.set_tags(**{\"X_inner_type\": \"np.ndarray\"})"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "4. Cannot convert to pd.Series for multivariate capable estimators\n"
+     ]
+    }
+   ],
+   "execution_count": 14
   },
   {
-   "cell_type": "markdown",
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2024-04-10T15:04:57.547785Z",
+     "start_time": "2024-04-10T15:04:57.541801Z"
+    }
+   },
+   "cell_type": "code",
    "source": [
-    "If implementing a new estimator that extends ``BaseSeriesEstimator`` then just set\n",
-    "the ``axis`` to the shape you want to work with by passing it to the\n",
-    "``BaseSeriesEstimator`` constructor. If your estimator can handle\n",
-    "multivariate series, set the tag and set the ``capability:multivariate`` tag to True.\n",
-    " The data will always then be passed to your estimator in ``(n_channels,\n",
-    " n_timepoints)`` if axis is 1, or ``(n_timepoints, n_channels)``\n",
-    " if axis is 0, either\n",
-    "  in numpy arrays or pandas DataFrame, dependning on ``X_inner_type`` tag. If\n",
-    " a univariate series is passed  it will be passed in ``(1, n_timepoints)`` if axis is\n",
-    "  0, or ``(n_timepoints, 1)`` if the estimator axis is 0."
+    "# Passing a univariate array will raise an error if capability:univariate is False\n",
+    "d1 = pd.Series(np.random.random(size=(100,)))\n",
+    "try:\n",
+    "    d2 = bs._preprocess_series(d1, axis=0, store_metadata=True)\n",
+    "except ValueError as e:\n",
+    "    print(f\"5. {e}\")"
+   ],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "5. Univariate data not supported by BaseSeriesEstimator\n"
+     ]
+    }
    ],
+   "execution_count": 15
+  },
+  {
+   "cell_type": "markdown",
+   "source": "If implementing a new estimator that extends `BaseSeriesEstimator` then just set the `axis` to the shape you want to work with by passing it to the `BaseSeriesEstimator` constructor. If your estimator can handle multivariate series, set the tag and set the `capability:multivariate` tag to `True`.  Set the `X_inner_type` tag if you wish to use a datatype other than `np.ndarray`.",
    "metadata": {
     "collapsed": false
    }


Test patch:
diff --git a/aeon/base/tests/test_base_series.py b/aeon/base/tests/test_base_series.py
index bbe816f8..e0ea7a40 100644
--- a/aeon/base/tests/test_base_series.py
+++ b/aeon/base/tests/test_base_series.py
@@ -3,81 +3,46 @@
 import numpy as np
 import pandas as pd
 import pytest
+from numpy.testing import assert_equal
 
 from aeon.base import BaseSeriesEstimator
 
-
-def test__check_X():
-    """Test if capabilities correctly tested."""
-    dummy1 = BaseSeriesEstimator()
-    invalid_always = np.array(["1", "2", "3", "4", "5"])
-    with pytest.raises(ValueError, match=r"Error in input type should be one of"):
-        dummy1._check_X("String input")
-    with pytest.raises(ValueError, match=r"array must contain floats or ints"):
-        dummy1._check_X(invalid_always)
-    with pytest.raises(ValueError, match=r"pd.Series must be numeric"):
-        dummy1._check_X(pd.Series(invalid_always))
-    with pytest.raises(ValueError, match=r"pd.DataFrame must be numeric"):
-        dummy1._check_X(pd.DataFrame(invalid_always))
-    multi_np = np.random.random(size=(5, 10))
-    multi_pd = pd.DataFrame(multi_np)
-    with pytest.raises(ValueError, match=r"Multivariate data not supported"):
-        dummy1._check_X(multi_np)
-    with pytest.raises(ValueError, match=r"Multivariate data not supported"):
-        dummy1._check_X(multi_pd)
-    missing = np.random.rand(4, 10)
-    missing[3][8] = np.NAN
-    with pytest.raises(ValueError, match=r"Missing values not supported"):
-        dummy1._check_X(missing)
-
-    dummy2 = BaseSeriesEstimator()
-    all_tags = {
-        "capability:multivariate": True,
-        "capability:missing_values": True,
-    }
-    dummy2.set_tags(**all_tags)
-    meta = dummy2._check_X(multi_np)
-    meta2 = dummy2._check_X(multi_pd)
-    assert meta == meta2
-    assert meta["multivariate"]
-    assert not meta["missing_values"]
-    multi_np[0][0] = np.NAN
-    multi_pd[0][0] = np.NAN
-    uni_missing = pd.Series(np.array([1.0, np.NAN, 2.0]))
-    meta = dummy2._check_X(multi_np)
-    meta2 = dummy2._check_X(multi_pd)
-    meta3 = dummy2._check_X(uni_missing)
-    assert (
-        meta["missing_values"] and meta2["missing_values"] and meta3["missing_values"]
-    )
-    work_always = np.random.random(size=(10))
-    meta = dummy1._check_X(work_always)
-    meta2 = dummy1._check_X(pd.Series(work_always))
-    meta3 = dummy1._check_X(pd.DataFrame(work_always))
-    assert meta == meta2 == meta3
-    all_tags = {"capability:univariate": False}
-    dummy2.set_tags(**all_tags)
-    uni_np = np.random.random(size=(10))
-    uni_pd = pd.Series(uni_np)
-    with pytest.raises(ValueError, match=r"Univariate data not supported"):
-        dummy2._check_X(uni_np)
-    with pytest.raises(ValueError, match=r"Univariate data not supported"):
-        dummy2._check_X(uni_pd)
-    wrong_size = np.random.rand(10, 2, 20)
-    with pytest.raises(ValueError, match=r"Should be 1D or 2D"):
-        dummy2._check_X(wrong_size)
-
-
 UNIVARIATE = {
     "np.ndarray": np.random.random(size=(20)),
     "pd.Series": pd.Series(np.random.random(size=(20))),
-    "pd.DataFrame": pd.DataFrame(np.random.random(size=(20))),
+    "pd.DataFrame": pd.DataFrame(np.random.random(size=(1, 20))),
 }
 MULTIVARIATE = {
     "np.ndarray": np.random.random(size=(5, 20)),
-    "pd.Series": pd.Series(np.random.random(size=(20))),
     "pd.DataFrame": pd.DataFrame(np.random.random(size=(5, 20))),
 }
+
+UNIVARIATE_MISSING = {
+    "np.ndarray": UNIVARIATE["np.ndarray"].copy(),
+    "pd.Series": UNIVARIATE["pd.Series"].copy(),
+    "pd.DataFrame": UNIVARIATE["pd.DataFrame"].copy(),
+}
+UNIVARIATE_MISSING["np.ndarray"][np.random.randint(20)] = np.NAN
+UNIVARIATE_MISSING["np.ndarray"][np.random.randint(20)] = np.NAN
+UNIVARIATE_MISSING["pd.Series"][np.random.randint(20)] = np.NAN
+UNIVARIATE_MISSING["pd.Series"][np.random.randint(20)] = np.NAN
+UNIVARIATE_MISSING["pd.DataFrame"].iloc[0, np.random.randint(20)] = np.NAN
+UNIVARIATE_MISSING["pd.DataFrame"].iloc[0, np.random.randint(20)] = np.NAN
+
+MULTIVARIATE_MISSING = {
+    "np.ndarray": MULTIVARIATE["np.ndarray"].copy(),
+    "pd.DataFrame": MULTIVARIATE["pd.DataFrame"].copy(),
+}
+MULTIVARIATE_MISSING["np.ndarray"][np.random.randint(5)][np.random.randint(20)] = np.NAN
+MULTIVARIATE_MISSING["np.ndarray"][np.random.randint(5)][np.random.randint(20)] = np.NAN
+MULTIVARIATE_MISSING["pd.DataFrame"].iloc[
+    np.random.randint(5), np.random.randint(20)
+] = np.NAN
+MULTIVARIATE_MISSING["pd.DataFrame"].iloc[
+    np.random.randint(5), np.random.randint(20)
+] = np.NAN
+
+
 VALID_TYPES = [
     "np.ndarray",
     "pd.Series",
@@ -85,116 +50,364 @@ VALID_TYPES = [
 ]
 
 
-def test_univariate_convert_X():
-    """Test _convert_X on univariate series."""
-    # np.ndarray inner, no multivariate. Univariate are always series,
-    # not dataframes, axis is ignored
-    dummy1 = BaseSeriesEstimator()
-    for st in ["np.ndarray", "pd.Series"]:  # Inner type, DataFrame not allowed for
-        # univariate
-        for st2 in VALID_TYPES:  # input type
-            X = UNIVARIATE[st2]
-            dummy1.set_tags(**{"X_inner_type": st})
-            X2 = dummy1._convert_X(X, axis=0)
-            assert type(X2).__name__ == st.split(".")[1]
-            X2 = dummy1._convert_X(X, axis=1)
-            assert len(X) == len(X2)
-    with pytest.raises(ValueError, match="Axis should be 0 or 1"):
-        dummy1._convert_X(X, axis=2)
-    d = BaseSeriesEstimator.get_test_params()
-    assert d["axis"] == 0
-    all_tags = {"X_inner_type": "numpy3D"}
-    dummy1.set_tags(**all_tags)
-    with pytest.raises(ValueError, match="Unsupported inner"):
-        dummy1._convert_X(X, axis=0)
-
-
-def test_multivariate_convert_X():
-    """Test _convert_X on multivariate series."""
-    # np.ndarray inner multivariate capable. Multivariate are always DataFrames,
-    # not Series. 1D np.ndarray converted to 2D. Capability is tested in check_X,
-    # not here
-    dummy1 = BaseSeriesEstimator(axis=0)
-    for st in ["np.ndarray", "pd.DataFrame"]:  # Inner type, Series not allowed for
-        # Multivariate
-        name = st.split(".")[1]
-        for st2 in VALID_TYPES:  # input type
-            dummy1.set_tags(**{"X_inner_type": st, "capability:multivariate": True})
-            X = MULTIVARIATE[st2]
-            X2 = dummy1._convert_X(X, axis=0)
-            assert type(X2).__name__ == name
-
-            shape1 = X2.shape
-            X2 = dummy1._convert_X(X, axis=1)
-            shape2 = X2.shape
-            assert shape1[0] == shape2[1] and shape1[1] == shape2[0]
+def test_check_X():
+    """Test if capabilities correctly tested in _check_X."""
+    dummy = BaseSeriesEstimator(axis=1)
+
+    # check basic univariate input
+    meta = dummy._check_X(UNIVARIATE["np.ndarray"], axis=1)
+    meta2 = dummy._check_X(UNIVARIATE["pd.Series"], axis=1)
+    meta3 = dummy._check_X(UNIVARIATE["pd.DataFrame"], axis=1)
+    assert meta == meta2 == meta3
+    assert not meta["multivariate"]
+    assert meta["n_channels"] == 1
+    assert not meta["missing_values"]
+
+    # DataFrames are always 2d
+    meta = dummy._check_X(UNIVARIATE["pd.DataFrame"].T, axis=0)
+    assert not meta["multivariate"]
+    assert meta["n_channels"] == 1
+    assert not meta["missing_values"]
+
+    # check multivariate capability False
+    with pytest.raises(ValueError, match="Multivariate data not supported"):
+        dummy._check_X(MULTIVARIATE["np.ndarray"], axis=1)
+    with pytest.raises(ValueError, match="Multivariate data not supported"):
+        dummy._check_X(MULTIVARIATE["pd.DataFrame"], axis=1)
+    with pytest.raises(ValueError, match="Multivariate data not supported"):
+        dummy._check_X(MULTIVARIATE["np.ndarray"].T, axis=0)
+    with pytest.raises(ValueError, match="Multivariate data not supported"):
+        dummy._check_X(MULTIVARIATE["pd.DataFrame"].T, axis=0)
+
+    # check missing value capability False
+    dummy.set_tags(**{"capability:multivariate": True})
+
+    with pytest.raises(ValueError, match="Missing values not supported"):
+        dummy._check_X(UNIVARIATE_MISSING["np.ndarray"], axis=1)
+    with pytest.raises(ValueError, match="Missing values not supported"):
+        dummy._check_X(UNIVARIATE_MISSING["pd.Series"], axis=1)
+    with pytest.raises(ValueError, match="Missing values not supported"):
+        dummy._check_X(UNIVARIATE_MISSING["pd.DataFrame"], axis=1)
+    with pytest.raises(ValueError, match="Missing values not supported"):
+        dummy._check_X(MULTIVARIATE_MISSING["np.ndarray"], axis=1)
+    with pytest.raises(ValueError, match="Missing values not supported"):
+        dummy._check_X(MULTIVARIATE_MISSING["pd.DataFrame"], axis=1)
+
+    # check multivariate capable
+    meta = dummy._check_X(MULTIVARIATE["np.ndarray"], axis=1)
+    meta2 = dummy._check_X(MULTIVARIATE["pd.DataFrame"], axis=1)
+    meta3 = dummy._check_X(MULTIVARIATE["np.ndarray"].T, axis=0)
+    meta4 = dummy._check_X(MULTIVARIATE["pd.DataFrame"].T, axis=0)
+    assert meta == meta2 == meta3 == meta4
+    assert meta["multivariate"]
+    assert meta["n_channels"] == 5
+    assert not meta["missing_values"]
+
+    # check missing value capable
+    dummy.set_tags(**{"capability:missing_values": True})
+
+    meta = dummy._check_X(UNIVARIATE_MISSING["np.ndarray"], axis=1)
+    meta2 = dummy._check_X(UNIVARIATE_MISSING["pd.Series"], axis=1)
+    meta3 = dummy._check_X(UNIVARIATE_MISSING["pd.DataFrame"], axis=1)
+    meta4 = dummy._check_X(MULTIVARIATE_MISSING["np.ndarray"], axis=1)
+    meta5 = dummy._check_X(MULTIVARIATE_MISSING["pd.DataFrame"], axis=1)
+    assert meta == meta2 == meta3
+    assert meta4 == meta5
+    assert meta["missing_values"] and meta4["missing_values"]
+    assert not meta["multivariate"]
+    assert meta["n_channels"] == 1
+    assert meta4["multivariate"]
+    assert meta4["n_channels"] == 5
+
+    # check univariate capability False
+    dummy.set_tags(**{"capability:univariate": False})
+
+    with pytest.raises(ValueError, match="Univariate data not supported"):
+        dummy._check_X(UNIVARIATE["np.ndarray"], axis=1)
+    with pytest.raises(ValueError, match="Univariate data not supported"):
+        dummy._check_X(UNIVARIATE["pd.Series"], axis=1)
+    with pytest.raises(ValueError, match="Univariate data not supported"):
+        dummy._check_X(UNIVARIATE["pd.DataFrame"], axis=1)
+    with pytest.raises(ValueError, match="Univariate data not supported"):
+        dummy._check_X(UNIVARIATE["pd.DataFrame"].T, axis=0)
+
+    # check invalid inputs and data types
+    with pytest.raises(ValueError, match="Input type of X should be one of"):
+        dummy._check_X("String input", axis=1)
+
+    invalid_always = np.array(["1", "2", "3", "4", "5"])
+    with pytest.raises(ValueError, match="dtype for np.ndarray must be float or int"):
+        dummy._check_X(invalid_always, axis=1)
+    with pytest.raises(ValueError, match="pd.Series dtype must be numeric"):
+        dummy._check_X(pd.Series(invalid_always), axis=1)
+    with pytest.raises(ValueError, match="pd.DataFrame dtype must be numeric"):
+        dummy._check_X(pd.DataFrame(invalid_always), axis=1)
+
+    with pytest.raises(ValueError, match="Input axis should be 0 or 1, saw 2"):
+        dummy._check_X(UNIVARIATE["np.ndarray"], axis=2)
+    with pytest.raises(ValueError, match="Input axis should be 0 or 1, saw -1"):
+        dummy._check_X(UNIVARIATE["np.ndarray"], axis=-1)
+
+    collection = np.random.random(size=(10, 2, 10))
+    with pytest.raises(ValueError, match="X must have at most 2 dimensions"):
+        dummy._check_X(collection, axis=0)
+
+
+@pytest.mark.parametrize("input_type", VALID_TYPES)
+def test_convert_X_ndarray_inner(input_type):
+    """Test _convert_X on with np.ndarray inner type."""
+    dummy = BaseSeriesEstimator(axis=1)
+    dummy.set_tags(**{"X_inner_type": "np.ndarray"})
+    # test univariate
+    X = UNIVARIATE[input_type]
+
+    X2 = dummy._convert_X(X, axis=1)
+    assert type(X2).__name__ == "ndarray"
+    assert X2.shape == (1, 20)
+    assert X.shape[-1] == X2.shape[1]
+
+    X3 = dummy._convert_X(X.T, axis=0)
+    assert type(X3).__name__ == "ndarray"
+    assert X3.shape == (1, 20)
+    assert_equal(X2, X3)
+
+    # test multivariate
+    if input_type != "pd.Series":
+        dummy.set_tags(**{"capability:multivariate": True})
+        X = MULTIVARIATE[input_type]
+
+        X2 = dummy._convert_X(X, axis=1)
+        assert type(X2).__name__ == "ndarray"
+        assert X2.shape == (5, 20)
+        assert X.shape == X2.shape
+
+        X3 = dummy._convert_X(X.T, axis=0)
+        assert type(X3).__name__ == "ndarray"
+        assert X3.shape == (5, 20)
+        assert_equal(X2, X3)
+
+
+@pytest.mark.parametrize("input_type", VALID_TYPES)
+def test_convert_X_series_inner(input_type):
+    """Test _convert_X on with pd.Series inner type."""
+    dummy = BaseSeriesEstimator(axis=1)
+    dummy.set_tags(**{"X_inner_type": "pd.Series"})
+    # test univariate
+    X = UNIVARIATE[input_type]
+
+    X2 = dummy._convert_X(X, axis=1)
+    assert type(X2).__name__ == "Series"
+    assert X2.shape == (20,)
+    assert X.shape[-1] == X2.shape[0]
+
+    X3 = dummy._convert_X(X.T, axis=0)
+    assert type(X3).__name__ == "Series"
+    assert X3.shape == (20,)
+    assert X2.equals(X3)
+
+    # test multivariate, should raise error as pd.Series cannot be multivariate
+    if input_type != "pd.Series":
+        X = MULTIVARIATE[input_type]
+
+        with pytest.raises(ValueError, match="multivariate data. Found 5 channels"):
+            dummy._convert_X(X, axis=1)
+
+        dummy.set_tags(**{"capability:multivariate": True})
+
+        with pytest.raises(ValueError, match="for multivariate capable estimators"):
+            dummy._convert_X(X, axis=1)
+        with pytest.raises(ValueError, match="for multivariate capable estimators"):
+            dummy._convert_X(X.T, axis=0)
+
+
+@pytest.mark.parametrize("input_type", VALID_TYPES)
+def test_convert_X_dataframe_inner(input_type):
+    """Test _convert_X on with pd.DataFrame inner type."""
+    dummy = BaseSeriesEstimator(axis=1)
+    dummy.set_tags(**{"X_inner_type": "pd.DataFrame"})
+    # test univariate
+    X = UNIVARIATE[input_type]
+
+    X2 = dummy._convert_X(X, axis=1)
+    assert type(X2).__name__ == "DataFrame"
+    assert X2.shape == (1, 20)
+    assert X.shape[-1] == X2.shape[1]
+
+    X3 = dummy._convert_X(X.T, axis=0)
+    assert type(X3).__name__ == "DataFrame"
+    assert X3.shape == (1, 20)
+    assert X2.equals(X3)
+
+    # test multivariate
+    if input_type != "pd.Series":
+        dummy.set_tags(**{"capability:multivariate": True})
+        X = MULTIVARIATE[input_type]
+
+        X2 = dummy._convert_X(X, axis=1)
+        assert type(X2).__name__ == "DataFrame"
+        assert X2.shape == (5, 20)
+        assert X.shape == X2.shape
+
+        X3 = dummy._convert_X(X.T, axis=0)
+        assert type(X3).__name__ == "DataFrame"
+        assert X3.shape == (5, 20)
+        assert X2.equals(X3)
+
+
+def test_convert_X_invalid():
+    """Test _convert_X for invalid inputs."""
+    dummy = BaseSeriesEstimator(axis=1)
+
+    with pytest.raises(ValueError, match="Input axis should be 0 or 1, saw 2"):
+        dummy._convert_X(UNIVARIATE["np.ndarray"], axis=2)
+    with pytest.raises(ValueError, match="Input axis should be 0 or 1, saw -1"):
+        dummy._convert_X(UNIVARIATE["np.ndarray"], axis=-1)
+
+    dummy.set_tags(**{"X_inner_type": ["invalid"]})
+    with pytest.raises(ValueError, match="Unsupported inner type"):
+        dummy._convert_X(UNIVARIATE["np.ndarray"], axis=1)
 
 
 @pytest.mark.parametrize("input_type", VALID_TYPES)
 @pytest.mark.parametrize("inner_type", VALID_TYPES)
 def test_preprocess_series(input_type, inner_type):
-    """Test _preprocess_series."""
-    dummy1 = BaseSeriesEstimator(axis=0)
-    inner = inner_type.split(".")[1]
-    if inner_type != "pd.DataFrame":
-        dummy1.set_tags(**{"X_inner_type": inner_type})
-        X = UNIVARIATE[input_type]
-        X2 = dummy1._preprocess_series(X)
-        assert type(X2).__name__ == inner
-    if inner_type != "pd.Series":
-        dummy1.set_tags(**{"X_inner_type": inner_type, "capability:multivariate": True})
-        X = UNIVARIATE[input_type]
-        X2 = dummy1._preprocess_series(X)
-        assert type(X2).__name__ == inner
-
-
-INPUT_CORRECT = [
-    np.array([0, 0, 0, 1, 1]),
-    pd.Series([0, 0, 0, 1, 1]),
-    pd.DataFrame([0, 0, 0, 1, 1]),
-]
-INPUT_WRONG = [
-    np.array([[0, 0, 0, 1, 1, 2], [0, 0, 0, 1, 1, 2]]),
-    pd.DataFrame([[0, 0, 0, 1, 1, 2], [0, 0, 0, 1, 1, 2]]),
-    np.array([0, 0, 0, 1, "FOO"]),
-    pd.Series([0, 0, 0, 1, "FOO"]),
-    pd.DataFrame([0, 0, 0, 1, "FOO"]),
-    "Up the arsenal",
-]
+    """Test _preprocess_series for different input and inner types."""
+    dummy = BaseSeriesEstimator(axis=1)
+    dummy.set_tags(**{"X_inner_type": inner_type})
+    inner_name = inner_type.split(".")[1]
+    # test univariate
+    X = UNIVARIATE[input_type]
 
+    X2 = dummy._preprocess_series(X, axis=1, store_metadata=True)
+    assert type(X2).__name__ == inner_name
+    assert X.shape[-1] == X2.shape[-1]
+    assert not dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 1
+    assert not dummy.metadata_["missing_values"]
 
-@pytest.mark.parametrize("y_correct", INPUT_CORRECT)
-def test_check_y_correct(y_correct):
-    """Test the _check_y method with correct input."""
-    assert BaseSeriesEstimator._check_y(None, y_correct) is None
+    X3 = dummy._preprocess_series(X.T, axis=0, store_metadata=True)
+    assert type(X3).__name__ == inner_name
+    assert X.shape[-1] == X3.shape[-1]
+    assert not dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 1
+    assert not dummy.metadata_["missing_values"]
 
+    # test multivariate, excludes pd.Series input type as it cannot be multivariate
+    if input_type != "pd.Series" and inner_type != "pd.Series":
+        dummy.set_tags(**{"capability:multivariate": True})
+        X = MULTIVARIATE[input_type]
 
-@pytest.mark.parametrize("y_wrong", INPUT_WRONG)
-def test_check_y_wrong(y_wrong):
-    """Test the _check_y method with incorrect input."""
-    with pytest.raises(ValueError, match="Error in input type for y"):
-        BaseSeriesEstimator._check_y(None, y_wrong)
+        X2 = dummy._preprocess_series(X, axis=1, store_metadata=True)
+        assert type(X2).__name__ == inner_name
+        assert X2.shape == (5, 20)
+        assert X.shape == X2.shape
+        assert dummy.metadata_["multivariate"]
+        assert dummy.metadata_["n_channels"] == 5
+        assert not dummy.metadata_["missing_values"]
+
+        X3 = dummy._preprocess_series(X.T, axis=0, store_metadata=True)
+        assert type(X3).__name__ == inner_name
+        assert X3.shape == (5, 20)
+        assert X.shape == X3.shape
+        assert dummy.metadata_["multivariate"]
+        assert dummy.metadata_["n_channels"] == 5
+        assert not dummy.metadata_["missing_values"]
+    elif input_type != "pd.Series":
+        dummy.set_tags(**{"capability:multivariate": True})
+        X = MULTIVARIATE[input_type]
+
+        with pytest.raises(ValueError, match="Cannot convert to pd.Series"):
+            dummy._preprocess_series(X, axis=1, store_metadata=True)
 
 
 @pytest.mark.parametrize("returned_type", VALID_TYPES)
-def test__postprocess_series(returned_type):
-    """Test data reformatted correctly."""
-    x = UNIVARIATE[returned_type]
-    dummy1 = BaseSeriesEstimator()
-    tags = {"capability:multivariate": False}
-    dummy1.set_tags(**tags)
-    y = dummy1._postprocess_series(x)
-    y2 = dummy1._postprocess_series(x, axis=0)
-    y3 = dummy1._postprocess_series(x, axis=1)
-    assert y.ndim == 1
-    assert y2.shape == y3.shape
-    tags = {"capability:multivariate": True}
-    dummy1.set_tags(**tags)
-    x = MULTIVARIATE[returned_type]
-    y = dummy1._postprocess_series(x)
-    y2 = dummy1._postprocess_series(x, axis=0)
-    y3 = dummy1._postprocess_series(x, axis=1)
-    assert x.shape == y.shape
-    assert y.shape == y2.shape
+def test_postprocess_series(returned_type):
+    """Test data reformatted correctly by _postprocess_series."""
+    dummy = BaseSeriesEstimator(axis=1)
+    # test univariate
+    Xu = UNIVARIATE[returned_type]
+
+    X2 = dummy._postprocess_series(Xu, axis=1)
+    assert X2.ndim == 1
+    assert X2.shape == (20,)
+
+    X3 = dummy._postprocess_series(Xu.T, axis=0)
+    assert X2.shape == X3.shape
+
+    # test multivariate and 2d, excludes pd.Series input type as it is 1D only
     if returned_type != "pd.Series":
-        assert y.shape[0] == y3.shape[1] and y.shape[1] == y3.shape[0]
+        Xu = Xu.reshape((1, -1)) if returned_type == "np.ndarray" else Xu
+        Xm = MULTIVARIATE[returned_type]
+
+        X2 = dummy._postprocess_series(Xu, axis=1)
+        assert X2.ndim == 1
+        assert X2.shape == (20,)
+
+        X3 = dummy._postprocess_series(Xu.T, axis=0)
+        assert X2.shape == X3.shape
+
+        X2 = dummy._postprocess_series(Xm, axis=1)
+        assert X2.ndim == 2
+        assert X2.shape == (5, 20)
+
+        X3 = dummy._postprocess_series(Xm.T, axis=0)
+        assert X2.shape == X3.shape
+
+        # multivariate capable estimator
+        dummy.set_tags(**{"capability:multivariate": True})
+
+        X2 = dummy._postprocess_series(Xu, axis=1)
+        assert X2.ndim == 2
+        assert X2.shape == (1, 20)
+
+        X3 = dummy._postprocess_series(Xu.T, axis=0)
+        assert X2.shape == X3.shape
+
+        X2 = dummy._postprocess_series(Xm, axis=1)
+        assert X2.ndim == 2
+        assert X2.shape == (5, 20)
+
+        X3 = dummy._postprocess_series(Xm.T, axis=0)
+        assert X2.shape == X3.shape
+
+
+def test_axis():
+    """Test axis property."""
+    dummy = BaseSeriesEstimator(axis=1)
+    dummy.set_tags(**{"capability:multivariate": True})
+    X = MULTIVARIATE["np.ndarray"]
+
+    X2 = dummy._preprocess_series(X, axis=1, store_metadata=True)
+    assert type(X2).__name__ == "ndarray"
+    assert X2.shape == (5, 20)
+    assert X.shape == X2.shape
+    assert dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 5
+    assert not dummy.metadata_["missing_values"]
+
+    X2 = dummy._preprocess_series(X.T, axis=0, store_metadata=True)
+    assert type(X2).__name__ == "ndarray"
+    assert X2.shape == (5, 20)
+    assert X.shape == X2.shape
+    assert dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 5
+    assert not dummy.metadata_["missing_values"]
+
+    dummy.axis = 0
+    X = X.T
+
+    X2 = dummy._preprocess_series(X.T, axis=1, store_metadata=True)
+    assert type(X2).__name__ == "ndarray"
+    assert X2.shape == (20, 5)
+    assert X.shape == X2.shape
+    assert dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 5
+    assert not dummy.metadata_["missing_values"]
+
+    X2 = dummy._preprocess_series(X, axis=0, store_metadata=True)
+    assert type(X2).__name__ == "ndarray"
+    assert X2.shape == (20, 5)
+    assert X.shape == X2.shape
+    assert dummy.metadata_["multivariate"]
+    assert dummy.metadata_["n_channels"] == 5
+    assert not dummy.metadata_["missing_values"]
diff --git a/aeon/segmentation/tests/test_base.py b/aeon/segmentation/tests/test_base.py
index 14a7c501..013e4ecd 100644
--- a/aeon/segmentation/tests/test_base.py
+++ b/aeon/segmentation/tests/test_base.py
@@ -1,6 +1,7 @@
 """Test base segmenter."""
 
 import numpy as np
+import pandas as pd
 import pytest
 
 from aeon.segmentation.base import BaseSegmenter
@@ -53,3 +54,31 @@ def test_to_clusters():
     assert np.array_equal(labels, np.array([0, 0, 1, 1, 1, 1, 1, 1, 2, 2]))
     labels = BaseSegmenter.to_clusters([1, 2, 3], 4)
     assert np.array_equal(labels, np.array([0, 1, 2, 3]))
+
+
+INPUT_CORRECT = [
+    np.array([0, 0, 0, 1, 1]),
+    pd.Series([0, 0, 0, 1, 1]),
+    pd.DataFrame([0, 0, 0, 1, 1]),
+]
+INPUT_WRONG = [
+    np.array([[0, 0, 0, 1, 1, 2], [0, 0, 0, 1, 1, 2]]),
+    pd.DataFrame([[0, 0, 0, 1, 1, 2], [0, 0, 0, 1, 1, 2]]),
+    np.array([0, 0, 0, 1, "FOO"]),
+    pd.Series([0, 0, 0, 1, "FOO"]),
+    pd.DataFrame([0, 0, 0, 1, "FOO"]),
+    "Up the arsenal",
+]
+
+
+@pytest.mark.parametrize("y_correct", INPUT_CORRECT)
+def test_check_y_correct(y_correct):
+    """Test the _check_y method with correct input."""
+    assert BaseSegmenter._check_y(None, y_correct) is None
+
+
+@pytest.mark.parametrize("y_wrong", INPUT_WRONG)
+def test_check_y_wrong(y_wrong):
+    """Test the _check_y method with incorrect input."""
+    with pytest.raises(ValueError, match="Error in input type for y"):
+        BaseSegmenter._check_y(None, y_wrong)