Repository: aeon-toolkit/aeon
Pull request #263 (2023-04-05 15:26:21)

[ENH] Collection transformer base class
Introduces a base class for the current "Panel" transformers which only accept panel data as a solution to #149. This still uses the `BaseTransformer` tests currently, as it inherits from it. Converts 5 current panel classifiers to use the base class.

For easier viewing of difference between the base classes, see https://gist.github.com/MatthewMiddlehurst/1e68f03dfee7436bcdb6731f19ea52bc/revisions.

TonyBagnall: for one, lets not call it a panel transformer. No one in machine learning knows what a Panel is. If we do need separate base classes, call them maybe CollectionBaseClass. Alternatively,  we can just have tags to differentiate? WE could adapt the transform-input": "Series" tag to specify between single series and collection. The difference is single series transformers regard the axis of input to mean different things. So Collection views axis=0 as a case, axis 1 as either a time series (pandas or numpy2d) or axis 1 = channel, axis 2 = time series (3D numpy).  

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 3ed70f7a..c1dba379 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -2146,6 +2146,15 @@
       "contributions": [
         "code"
       ]
-    }
+    },
+    {
+      "login": "RafaAyGar",
+      "name": "Rafael Ayllón-Gavilán",
+      "avatar_url": "https://avatars.githubusercontent.com/u/37343008?s=400&u=bbd9c00c6045adf11cd9376e6db1d959565c27c6&v=4",
+      "profile": "https://github.com/RafaAyGar",
+      "contributions": [
+        "code"
+      ]
+    },
   ]
 }
diff --git a/aeon/classification/deep_learning/base.py b/aeon/classification/deep_learning/base.py
index 26e155a2..7cf7ef3b 100644
--- a/aeon/classification/deep_learning/base.py
+++ b/aeon/classification/deep_learning/base.py
@@ -5,7 +5,14 @@ Abstract base class for the Keras neural network classifiers.
 The reason for this class between BaseClassifier and deep_learning classifiers is
 because we can generalise tags, _predict and _predict_proba
 """
-__author__ = ["James-Large", "ABostrom", "TonyBagnall", "aurunmpegasus", "achieveordie"]
+__author__ = [
+    "James-Large",
+    "ABostrom",
+    "TonyBagnall",
+    "aurunmpegasus",
+    "achieveordie",
+    "hadifawaz1999",
+]
 __all__ = ["BaseDeepClassifier"]
 
 from abc import ABC, abstractmethod
@@ -15,7 +22,6 @@ from sklearn.preprocessing import LabelEncoder, OneHotEncoder
 from sklearn.utils import check_random_state
 
 from aeon.classification.base import BaseClassifier
-from aeon.utils.validation._dependencies import _check_soft_dependencies
 
 
 class BaseDeepClassifier(BaseClassifier, ABC):
@@ -29,6 +35,9 @@ class BaseDeepClassifier(BaseClassifier, ABC):
     ----------
     batch_size : int, default = 40
         training batch size for the model
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, used
+        only if save_last_model_to_file is used
 
     Arguments
     ---------
@@ -43,11 +52,17 @@ class BaseDeepClassifier(BaseClassifier, ABC):
         "algorithm_type": "deeplearning",
     }
 
-    def __init__(self, batch_size=40, random_state=None):
+    def __init__(
+        self,
+        batch_size=40,
+        random_state=None,
+        last_file_name="last_model",
+    ):
         super(BaseDeepClassifier, self).__init__()
 
         self.batch_size = batch_size
         self.random_state = random_state
+        self.last_file_name = last_file_name
         self.model_ = None
 
     @abstractmethod
@@ -126,251 +141,16 @@ class BaseDeepClassifier(BaseClassifier, ABC):
         y = self.onehot_encoder.fit_transform(y)
         return y
 
-    def __getstate__(self):
-        """Get Dict config that will be used when a serialization method is called.
-
-        Returns
-        -------
-        copy : dict, the config to be serialized
-        """
-        from tensorflow.keras.optimizers import Optimizer, serialize
-
-        copy = self.__dict__.copy()
-
-        # Either optimizer might not exist at all(-1),
-        # or it does and takes a value(including None)
-        optimizer_attr = copy.get("optimizer", -1)
-        if not isinstance(optimizer_attr, str):
-            if optimizer_attr is None:
-                # if it is None, then save it as 0, so it can be
-                # later correctly restored as None
-                copy["optimizer"] = 0
-            elif optimizer_attr == -1:
-                # if an `optimizer` parameter doesn't exist at all
-                # save it as -1
-                copy["optimizer"] = -1
-            elif isinstance(optimizer_attr, Optimizer):
-                copy["optimizer"] = serialize(optimizer_attr)
-            else:
-                raise ValueError(
-                    f"`optimizer` of type {type(optimizer_attr)} cannot be "
-                    "serialized, it should either be absent/None/str/"
-                    "tf.keras.optimizers.Optimizer object"
-                )
-        else:
-            # if it was a string, don't touch since already serializable
-            pass
-
-        check_before_deletion = ["model_", "history", "optimizer_"]
-        for attribute in check_before_deletion:
-            if copy.get(attribute) is not None:
-                del copy[attribute]
-        return copy
-
-    def __setstate__(self, state):
-        """Magic method called during deserialization.
+    def save_last_model_to_file(self, file_path="./"):
+        """Save the last epoch of the trained deep learning model.
 
         Parameters
         ----------
-        state : dict, as returned from __getstate__(), used for correct deserialization
+        file_path : str, default = "./"
+            The directory where the model will be saved
 
         Returns
         -------
-        -
+        None
         """
-        from tensorflow.keras.optimizers import deserialize
-
-        self.__dict__ = state
-
-        if hasattr(self, "model_"):
-            self.__dict__["model_"] = self.model_
-            if hasattr(self, "model_.optimizer"):
-                self.__dict__["optimizer_"] = self.model_.optimizer
-
-        # if optimizer_ exists, set optimizer as optimizer_
-        if self.__dict__.get("optimizer_") is not None:
-            self.__dict__["optimizer"] = self.__dict__["optimizer_"]
-        # else model may not have been built, but an optimizer might be passed
-        else:
-            # Having 0 as value implies "optimizer" attribute was None
-            # as per __getstate__()
-            if self.__dict__.get("optimizer") == 0:
-                self.__dict__["optimizer"] = None
-            elif self.__dict__.get("optimizer") == -1:
-                # `optimizer` doesn't exist as a parameter alone, so delete it.
-                del self.__dict__["optimizer"]
-            else:
-                if isinstance(self.optimizer, dict):
-                    self.__dict__["optimizer"] = deserialize(self.optimizer)
-                else:
-                    # must have been a string already, no need to set
-                    pass
-
-        if hasattr(self, "history"):
-            self.__dict__["history"] = self.history
-
-    def save(self, path=None):
-        """Save serialized self to bytes-like object or to (.zip) file.
-
-        Behaviour:
-        if `path` is None, returns an in-memory serialized self
-        if `path` is a file, stores the zip with that name at the location.
-        The contents of the zip file are:
-        _metadata - contains class of self, i.e., type(self).
-        _obj - serialized self. This class uses the default serialization (pickle).
-        keras/ - model, optimizer and state stored inside this directory.
-        history - serialized history object.
-
-
-        Parameters
-        ----------
-        path : None or file location (str or Path)
-            if None, self is saved to an in-memory object
-            if file location, self is saved to that file location. For eg:
-                path="estimator" then a zip file `estimator.zip` will be made at cwd.
-                path="/home/stored/estimator" then a zip file `estimator.zip` will be
-                stored in `/home/stored/`.
-
-        Returns
-        -------
-        if `path` is None - in-memory serialized self
-        if `path` is file location - ZipFile with reference to the file
-        """
-        import pickle
-        import shutil
-        from pathlib import Path
-        from zipfile import ZipFile
-
-        if path is None:
-            _check_soft_dependencies("h5py")
-            import h5py
-
-            in_memory_model = None
-            if self.model_ is not None:
-                with h5py.File(
-                    "disk_less", "w", driver="core", backing_store=False
-                ) as h5file:
-                    self.model_.save(h5file)
-                    h5file.flush()
-                    in_memory_model = h5file.id.get_file_image()
-
-            in_memory_history = pickle.dumps(self.history.history)
-
-            return (
-                type(self),
-                (
-                    pickle.dumps(self),
-                    in_memory_model,
-                    in_memory_history,
-                ),
-            )
-
-        if not isinstance(path, (str, Path)):
-            raise TypeError(
-                "`path` is expected to either be a string or a Path object "
-                f"but found of type:{type(path)}."
-            )
-
-        path = Path(path) if isinstance(path, str) else path
-        path.mkdir()
-
-        if self.model_ is not None:
-            self.model_.save(path / "keras/")
-
-        with open(path / "history", "wb") as history_writer:
-            pickle.dump(self.history.history, history_writer)
-
-        pickle.dump(type(self), open(path / "_metadata", "wb"))
-        pickle.dump(self, open(path / "_obj", "wb"))
-
-        shutil.make_archive(base_name=path, format="zip", root_dir=path)
-        shutil.rmtree(path)
-        return ZipFile(path.with_name(f"{path.stem}.zip"))
-
-    @classmethod
-    def load_from_serial(cls, serial):
-        """Load object from serialized memory container.
-
-        Parameters
-        ----------
-        serial: 1st element of output of `cls.save(None)`
-                This is a tuple of size 3.
-                The first element represents pickle-serialized instance.
-                The second element represents h5py-serialized `keras` model.
-                The third element represent pickle-serialized history of `.fit()`.
-
-        Returns
-        -------
-        Deserialized self resulting in output `serial`, of `cls.save(None)`
-        """
-        _check_soft_dependencies("h5py")
-        import pickle
-        from tempfile import TemporaryFile
-
-        import h5py
-        from tensorflow.keras.models import load_model
-
-        if not isinstance(serial, tuple):
-            raise TypeError(
-                "`serial` is expected to be a tuple, "
-                f"instead found of type: {type(serial)}"
-            )
-        if len(serial) != 3:
-            raise ValueError(
-                "`serial` should have 3 elements. "
-                "All 3 elements represent in-memory serialization "
-                "of the estimator. "
-                f"Found a tuple of length: {len(serial)} instead."
-            )
-
-        serial, in_memory_model, in_memory_history = serial
-        if in_memory_model is None:
-            cls.model_ = None
-        else:
-            with TemporaryFile() as store_:
-                store_.write(in_memory_model)
-                h5file = h5py.File(store_, "r")
-                cls.model_ = load_model(h5file)
-                h5file.close()
-
-        cls.history = pickle.loads(in_memory_history)
-        return pickle.loads(serial)
-
-    @classmethod
-    def load_from_path(cls, serial):
-        """Load object from file location.
-
-        Parameters
-        ----------
-        serial : Name of the zip file.
-
-        Returns
-        -------
-        deserialized self resulting in output at `path`, of `cls.save(path)`
-        """
-        import pickle
-        from shutil import rmtree
-        from zipfile import ZipFile
-
-        from tensorflow import keras
-
-        temp_unzip_loc = serial.parent / "temp_unzip/"
-        temp_unzip_loc.mkdir()
-
-        with ZipFile(serial, mode="r") as zip_file:
-            for file in zip_file.namelist():
-                if not file.startswith("keras/"):
-                    continue
-                zip_file.extract(file, temp_unzip_loc)
-
-        keras_location = temp_unzip_loc / "keras"
-        if keras_location.exists():
-            cls.model_ = keras.models.load_model(keras_location)
-        else:
-            cls.model_ = None
-
-        rmtree(temp_unzip_loc)
-        cls.history = keras.callbacks.History()
-        with ZipFile(serial, mode="r") as file:
-            cls.history.set_params(pickle.loads(file.open("history").read()))
-            return pickle.loads(file.open("_obj").read())
+        self.model_.save(file_path + self.last_file_name + ".hdf5")
diff --git a/aeon/classification/deep_learning/cnn.py b/aeon/classification/deep_learning/cnn.py
index d66d67d1..0fa80eef 100644
--- a/aeon/classification/deep_learning/cnn.py
+++ b/aeon/classification/deep_learning/cnn.py
@@ -66,7 +66,24 @@ class CNNClassifier(BaseDeepClassifier):
     callbacks       : keras.callbacks, default=model_checkpoint to save best
                       model on training loss
     file_path       : file_path for the best model (if checkpoint is used as callback)
-
+    save_best_model     : bool, default = False
+        Whether or not to save the best model, if the
+        modelcheckpoint callback is used by default,
+        this condition, if True, will prevent the
+        automatic deletion of the best saved model from
+        file and the user can choose the file name
+    save_last_model     : bool, default = False
+        Whether or not to save the last model, last
+        epoch trained, using the base class method
+        save_last_model_to_file
+    best_file_name      : str, default = "best_model"
+        The name of the file of the best model, if
+        save_best_model is set to False, this parameter
+        is discarded
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, if
+        save_last_model is set to False, this parameter
+        is discarded
 
     Notes
     -----
@@ -89,7 +106,11 @@ class CNNClassifier(BaseDeepClassifier):
     CNNClassifier(...)
     """
 
-    _tags = {"python_dependencies": "tensorflow"}
+    _tags = {
+        "python_dependencies": "tensorflow",
+        "capability:multivariate": True,
+        "algorithm_type": "deeplearning",
+    }
 
     def __init__(
         self,
@@ -105,6 +126,10 @@ class CNNClassifier(BaseDeepClassifier):
         batch_size=16,
         callbacks=None,
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         verbose=False,
         loss="mean_squared_error",
         metrics=None,
@@ -113,7 +138,7 @@ class CNNClassifier(BaseDeepClassifier):
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(CNNClassifier, self).__init__()
+        super(CNNClassifier, self).__init__(last_file_name=last_file_name)
 
         self.n_layers = n_layers
         self.kernel_size = kernel_size
@@ -130,6 +155,10 @@ class CNNClassifier(BaseDeepClassifier):
         self.batch_size = batch_size
         self.callbacks = callbacks
         self.file_path = file_path
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
         self.verbose = verbose
         self.loss = loss
         self.metrics = metrics
@@ -222,7 +251,9 @@ class CNNClassifier(BaseDeepClassifier):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -249,10 +280,14 @@ class CNNClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/deep_learning/encoder.py b/aeon/classification/deep_learning/encoder.py
index f3a2df92..df6739e2 100644
--- a/aeon/classification/deep_learning/encoder.py
+++ b/aeon/classification/deep_learning/encoder.py
@@ -45,6 +45,24 @@ class EncoderClassifier(BaseDeepClassifier):
         connected layer used in the EncoderNetwork
     file_path           : str, default = "./"
             file_path when saving model_Checkpoint callback
+    save_best_model     : bool, default = False
+        Whether or not to save the best model, if the
+        modelcheckpoint callback is used by default,
+        this condition, if True, will prevent the
+        automatic deletion of the best saved model from
+        file and the user can choose the file name
+    save_last_model     : bool, default = False
+        Whether or not to save the last model, last
+        epoch trained, using the base class method
+        save_last_model_to_file
+    best_file_name      : str, default = "best_model"
+        The name of the file of the best model, if
+        save_best_model is set to False, this parameter
+        is discarded
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, if
+        save_last_model is set to False, this parameter
+        is discarded
     random_state    : int, default = 0
         seed to any needed random actions
 
@@ -62,7 +80,11 @@ class EncoderClassifier(BaseDeepClassifier):
 
     """
 
-    _tags = {"python_dependencies": ["tensorflow", "tensorflow_addons"]}
+    _tags = {
+        "python_dependencies": ["tensorflow", "tensorflow_addons"],
+        "capability:multivariate": True,
+        "algorithm_type": "deeplearning",
+    }
 
     def __init__(
         self,
@@ -78,6 +100,10 @@ class EncoderClassifier(BaseDeepClassifier):
         fc_units=256,
         callbacks=None,
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         verbose=False,
         loss="categorical_crossentropy",
         metrics=None,
@@ -86,7 +112,7 @@ class EncoderClassifier(BaseDeepClassifier):
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(EncoderClassifier, self).__init__()
+        super(EncoderClassifier, self).__init__(last_file_name=last_file_name)
 
         self.n_filters = n_filters
         self.max_pool_size = max_pool_size
@@ -99,6 +125,10 @@ class EncoderClassifier(BaseDeepClassifier):
 
         self.callbacks = callbacks
         self.file_path = file_path
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
         self.n_epochs = n_epochs
         self.batch_size = batch_size
         self.verbose = verbose
@@ -197,7 +227,9 @@ class EncoderClassifier(BaseDeepClassifier):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -224,10 +256,14 @@ class EncoderClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/deep_learning/fcn.py b/aeon/classification/deep_learning/fcn.py
index 4144855f..07944a1f 100644
--- a/aeon/classification/deep_learning/fcn.py
+++ b/aeon/classification/deep_learning/fcn.py
@@ -55,6 +55,24 @@ class FCNClassifier(BaseDeepClassifier):
         specify the optimizer and the learning rate to be used.
     file_path       : str, default = "./"
         file path to save best model
+    save_best_model     : bool, default = False
+        Whether or not to save the best model, if the
+        modelcheckpoint callback is used by default,
+        this condition, if True, will prevent the
+        automatic deletion of the best saved model from
+        file and the user can choose the file name
+    save_last_model     : bool, default = False
+        Whether or not to save the last model, last
+        epoch trained, using the base class method
+        save_last_model_to_file
+    best_file_name      : str, default = "best_model"
+        The name of the file of the best model, if
+        save_best_model is set to False, this parameter
+        is discarded
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, if
+        save_last_model is set to False, this parameter
+        is discarded
     callbacks       : keras.callbacks, default = None
     Notes
     -----
@@ -77,7 +95,11 @@ class FCNClassifier(BaseDeepClassifier):
     FCNClassifier(...)
     """
 
-    _tags = {"python_dependencies": "tensorflow"}
+    _tags = {
+        "python_dependencies": "tensorflow",
+        "capability:multivariate": True,
+        "algorithm_type": "deeplearning",
+    }
 
     def __init__(
         self,
@@ -89,6 +111,10 @@ class FCNClassifier(BaseDeepClassifier):
         padding="same",
         activation="relu",
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         n_epochs=2000,
         batch_size=16,
         use_mini_batch_size=True,
@@ -101,7 +127,7 @@ class FCNClassifier(BaseDeepClassifier):
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(FCNClassifier, self).__init__()
+        super(FCNClassifier, self).__init__(last_file_name=last_file_name)
 
         self.n_layers = n_layers
         self.kernel_size = kernel_size
@@ -123,6 +149,10 @@ class FCNClassifier(BaseDeepClassifier):
         self.optimizer = optimizer
         self.history = None
         self.file_path = file_path
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
         self._network = FCNNetwork(
             random_state=self.random_state,
             n_layers=self.n_layers,
@@ -214,7 +244,9 @@ class FCNClassifier(BaseDeepClassifier):
         else:
             mini_batch_size = self.batch_size
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -244,10 +276,14 @@ class FCNClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/deep_learning/inception_time.py b/aeon/classification/deep_learning/inception_time.py
index 354f859d..3a874e70 100644
--- a/aeon/classification/deep_learning/inception_time.py
+++ b/aeon/classification/deep_learning/inception_time.py
@@ -97,6 +97,24 @@ class InceptionTimeClassifier(BaseClassifier):
             list of tf.keras.callbacks.Callback objects.
         file_path           : str, default = "./"
             file_path when saving model_Checkpoint callback
+        save_best_model     : bool, default = False
+            Whether or not to save the best model, if the
+            modelcheckpoint callback is used by default,
+            this condition, if True, will prevent the
+            automatic deletion of the best saved model from
+            file and the user can choose the file name
+        save_last_model     : bool, default = False
+            Whether or not to save the last model, last
+            epoch trained, using the base class method
+            save_last_model_to_file
+        best_file_name      : str, default = "best_model"
+            The name of the file of the best model, if
+            save_best_model is set to False, this parameter
+            is discarded
+        last_file_name      : str, default = "last_model"
+            The name of the file of the last model, if
+            save_last_model is set to False, this parameter
+            is discarded
         random_state        : int, default = 0
             seed to any needed random actions.
         verbose             : boolean, default = False
@@ -150,6 +168,10 @@ class InceptionTimeClassifier(BaseClassifier):
         depth=6,
         use_custom_filters=True,
         file_path="./",
+        save_last_model=False,
+        save_best_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         batch_size=64,
         use_mini_batch_size=False,
         n_epochs=1500,
@@ -183,6 +205,11 @@ class InceptionTimeClassifier(BaseClassifier):
 
         self.file_path = file_path
 
+        self.save_last_model = save_last_model
+        self.save_best_model = save_best_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
+
         self.callbacks = callbacks
         self.random_state = random_state
         self.verbose = verbose
@@ -213,7 +240,7 @@ class InceptionTimeClassifier(BaseClassifier):
         self.classifers_ = []
         rng = check_random_state(self.random_state)
 
-        for _ in range(0, self.n_classifiers):
+        for n in range(0, self.n_classifiers):
             cls = IndividualInceptionClassifier(
                 nb_filters=self.nb_filters,
                 nb_conv_per_layer=self.nb_conv_per_layer,
@@ -230,6 +257,10 @@ class InceptionTimeClassifier(BaseClassifier):
                 depth=self.depth,
                 use_custom_filters=self.use_custom_filters,
                 file_path=self.file_path,
+                save_best_model=self.save_best_model,
+                save_last_model=self.save_last_model,
+                best_file_name=self.best_file_name + str(n),
+                last_file_name=self.last_file_name + str(n),
                 batch_size=self.batch_size,
                 use_mini_batch_size=self.use_mini_batch_size,
                 n_epochs=self.n_epochs,
@@ -375,7 +406,6 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
         use_custom_filters  : bool, default = True,
             condition on wether or not to use custom filters
             in the first inception module
-
         batch_size          : int, default = 64
             the number of samples per gradient update.
         use_mini_batch_size : bool, default = False
@@ -387,6 +417,24 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
             list of tf.keras.callbacks.Callback objects.
         file_path           : str, default = "./"
             file_path when saving model_Checkpoint callback
+        save_best_model     : bool, default = False
+            Whether or not to save the best model, if the
+            modelcheckpoint callback is used by default,
+            this condition, if True, will prevent the
+            automatic deletion of the best saved model from
+            file and the user can choose the file name
+        save_last_model     : bool, default = False
+            Whether or not to save the last model, last
+            epoch trained, using the base class method
+            save_last_model_to_file
+        best_file_name      : str, default = "best_model"
+            The name of the file of the best model, if
+            save_best_model is set to False, this parameter
+            is discarded
+        last_file_name      : str, default = "last_model"
+            The name of the file of the last model, if
+            save_last_model is set to False, this parameter
+            is discarded
         random_state        : int, default = 0
             seed to any needed random actions.
         verbose             : boolean, default = False
@@ -429,6 +477,10 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
         depth=6,
         use_custom_filters=True,
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         batch_size=64,
         use_mini_batch_size=False,
         n_epochs=1500,
@@ -440,7 +492,9 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(IndividualInceptionClassifier, self).__init__()
+        super(IndividualInceptionClassifier, self).__init__(
+            last_file_name=last_file_name
+        )
         # predefined
         self.nb_filters = nb_filters
         self.nb_conv_per_layer = nb_conv_per_layer
@@ -462,6 +516,11 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
 
         self.file_path = file_path
 
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
+
         self.callbacks = callbacks
         self.random_state = random_state
         self.verbose = verbose
@@ -486,7 +545,6 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
             bottleneck_size=self.bottleneck_size,
             depth=self.depth,
             use_custom_filters=self.use_custom_filters,
-            random_state=self.random_state,
         )
 
     def build_model(self, input_shape, n_classes, **kwargs):
@@ -569,7 +627,9 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -599,10 +659,14 @@ class IndividualInceptionClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/deep_learning/mlp.py b/aeon/classification/deep_learning/mlp.py
index bba19e2b..34b96f1c 100644
--- a/aeon/classification/deep_learning/mlp.py
+++ b/aeon/classification/deep_learning/mlp.py
@@ -35,6 +35,24 @@ class MLPClassifier(BaseDeepClassifier):
         fit parameter for the keras model
     file_path           : str, default = "./"
             file_path when saving model_Checkpoint callback
+    save_best_model     : bool, default = False
+        Whether or not to save the best model, if the
+        modelcheckpoint callback is used by default,
+        this condition, if True, will prevent the
+        automatic deletion of the best saved model from
+        file and the user can choose the file name
+    save_last_model     : bool, default = False
+        Whether or not to save the last model, last
+        epoch trained, using the base class method
+        save_last_model_to_file
+    best_file_name      : str, default = "best_model"
+        The name of the file of the best model, if
+        save_best_model is set to False, this parameter
+        is discarded
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, if
+        save_last_model is set to False, this parameter
+        is discarded
     optimizer       : keras.optimizer, default=keras.optimizers.Adadelta(),
     metrics         : list of strings, default=["accuracy"],
     activation      : string or a tf callable, default="sigmoid"
@@ -65,7 +83,11 @@ class MLPClassifier(BaseDeepClassifier):
     MLPClassifier(...)
     """
 
-    _tags = {"python_dependencies": "tensorflow"}
+    _tags = {
+        "python_dependencies": "tensorflow",
+        "capability:multivariate": True,
+        "algorithm_type": "deeplearning",
+    }
 
     def __init__(
         self,
@@ -76,13 +98,17 @@ class MLPClassifier(BaseDeepClassifier):
         loss="categorical_crossentropy",
         metrics=None,
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         random_state=None,
         activation="sigmoid",
         use_bias=True,
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(MLPClassifier, self).__init__()
+        super(MLPClassifier, self).__init__(last_file_name=last_file_name)
         self.callbacks = callbacks
         self.n_epochs = n_epochs
         self.batch_size = batch_size
@@ -93,6 +119,10 @@ class MLPClassifier(BaseDeepClassifier):
         self.activation = activation
         self.use_bias = use_bias
         self.file_path = file_path
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
         self.optimizer = optimizer
         self.history = None
         self._network = MLPNetwork(
@@ -173,7 +203,9 @@ class MLPClassifier(BaseDeepClassifier):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -203,10 +235,14 @@ class MLPClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/deep_learning/resnet.py b/aeon/classification/deep_learning/resnet.py
index 4ce8498a..006ddbae 100644
--- a/aeon/classification/deep_learning/resnet.py
+++ b/aeon/classification/deep_learning/resnet.py
@@ -65,6 +65,24 @@ class ResNetClassifier(BaseDeepClassifier):
             list of tf.keras.callbacks.Callback objects.
         file_path                   : str, default = './'
             file_path when saving model_Checkpoint callback
+        save_best_model     : bool, default = False
+            Whether or not to save the best model, if the
+            modelcheckpoint callback is used by default,
+            this condition, if True, will prevent the
+            automatic deletion of the best saved model from
+            file and the user can choose the file name
+        save_last_model     : bool, default = False
+            Whether or not to save the last model, last
+            epoch trained, using the base class method
+            save_last_model_to_file
+        best_file_name      : str, default = "best_model"
+            The name of the file of the best model, if
+            save_best_model is set to False, this parameter
+            is discarded
+        last_file_name      : str, default = "last_model"
+            The name of the file of the last model, if
+            save_last_model is set to False, this parameter
+            is discarded
         verbose                     : boolean, default = False
             whether to output extra information
         loss                        : string, default="mean_squared_error"
@@ -93,7 +111,11 @@ class ResNetClassifier(BaseDeepClassifier):
     ResNetClassifier(...)
     """
 
-    _tags = {"python_dependencies": "tensorflow"}
+    _tags = {
+        "python_dependencies": "tensorflow",
+        "capability:multivariate": True,
+        "algorithm_type": "deeplearning",
+    }
 
     def __init__(
         self,
@@ -115,10 +137,15 @@ class ResNetClassifier(BaseDeepClassifier):
         use_mini_batch_size=True,
         random_state=None,
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(ResNetClassifier, self).__init__()
+        super(ResNetClassifier, self).__init__(last_file_name=last_file_name)
+        optimizer = (None,)
         self.n_residual_blocks = n_residual_blocks
         self.n_conv_per_residual_block = n_conv_per_residual_block
         self.n_filters = n_filters
@@ -138,6 +165,10 @@ class ResNetClassifier(BaseDeepClassifier):
         self.activation = activation
         self.use_bias = use_bias
         self.file_path = file_path
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
         self.optimizer = optimizer
         self.history = None
         self._network = ResNetNetwork(
@@ -231,7 +262,9 @@ class ResNetClassifier(BaseDeepClassifier):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -266,10 +299,14 @@ class ResNetClassifier(BaseDeepClassifier):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/classification/ordinal_classification/__init__.py b/aeon/classification/ordinal_classification/__init__.py
new file mode 100644
index 00000000..95a3d83f
--- /dev/null
+++ b/aeon/classification/ordinal_classification/__init__.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+"""Ordinal time series classifiers."""
+__all__ = [
+    "OrdinalTDE",
+    "IndividualOrdinalTDE",
+    "histogram_intersection",
+]
+
+from aeon.classification.ordinal_classification._ordinal_tde import (
+    IndividualOrdinalTDE,
+    OrdinalTDE,
+    histogram_intersection,
+)
diff --git a/aeon/classification/ordinal_classification/_ordinal_tde.py b/aeon/classification/ordinal_classification/_ordinal_tde.py
new file mode 100644
index 00000000..07694150
--- /dev/null
+++ b/aeon/classification/ordinal_classification/_ordinal_tde.py
@@ -0,0 +1,1081 @@
+# -*- coding: utf-8 -*-
+"""TDE classifiers.
+
+Dictionary based Ordinal TDE classifiers based on SFA transform. Contains a single
+IndividualOrdinalTDE and Ordinal TDE.
+"""
+
+__author__ = ["RafaelAyllon"]
+__all__ = [
+    "OrdinalTDE",
+    "IndividualOrdinalTDE",
+    "histogram_intersection",
+]
+
+import math
+import time
+import warnings
+from collections import defaultdict
+
+import numpy as np
+from joblib import Parallel, delayed
+from numba import njit, types
+from numba.typed import Dict
+from sklearn import preprocessing
+from sklearn.kernel_ridge import KernelRidge
+from sklearn.utils import check_random_state
+
+from aeon.classification.base import BaseClassifier
+from aeon.transformations.panel.dictionary_based import SFA
+from aeon.utils.validation.panel import check_X_y
+
+
+class OrdinalTDE(BaseClassifier):
+    """Ordinal Temporal Dictionary Ensemble (O-TDE).
+
+    Implementation of the dictionary based Ordinal Temporal Dictionary
+    Ensemble as described in [1]_. This method is an ordinal adaptation
+    of the Temporal Dictionary Ensemble (TDE) presented in [2]_.
+
+    Overview: Input "n" series length "m" with "d" dimensions.
+    O-TDE performs parameter selection to build the ensemble members
+    based on a Gaussian process which is intended to predict Mean
+    Absolute Error (MAE) values for specific O-TDE parameters configurations.
+    Then, the best performing members are selected and used to build the
+    final ensemble.
+
+    fit involves finding "n" histograms.
+
+    predict uses 1 nearest neighbor with the histogram intersection distance
+    function.
+
+    Parameters
+    ----------
+    n_parameter_samples : int, default=250
+        Number of parameter combinations to consider for the final ensemble.
+    max_ensemble_size : int, default=50
+        Maximum number of estimators in the ensemble.
+    max_win_len_prop : float, default=1
+        Maximum window length as a proportion of series length, must be between 0 and 1.
+    min_window : int, default=10
+        Minimum window length.
+    randomly_selected_params: int, default=50
+        Number of parameters randomly selected before the Gaussian process parameter
+        selection is used.
+    bigrams : boolean or None, default=None
+        Whether to use bigrams, defaults to true for univariate data and false for
+        multivariate data.
+    dim_threshold : float, default=0.85
+        Dimension accuracy threshold for multivariate data, must be between 0 and 1.
+    max_dims : int, default=20
+        Max number of dimensions per classifier for multivariate data.
+    time_limit_in_minutes : int, default=0
+        Time contract to limit build time in minutes, overriding n_parameter_samples.
+        Default of 0 means n_parameter_samples is used.
+    contract_max_n_parameter_samples : int, default=np.inf
+        Max number of parameter combinations to consider when time_limit_in_minutes is
+        set.
+    typed_dict : bool, default=True
+        Use a numba typed Dict to store word counts. May increase memory usage, but will
+        be faster for larger datasets. As the Dict cannot be pickled currently, there
+        will be some overhead converting it to a python dict with multiple threads and
+        pickling.
+    save_train_predictions : bool, default=False
+        Save the ensemble member train predictions in fit for use in _get_train_probs
+        leave-one-out cross-validation.
+    n_jobs : int, default=1
+        The number of jobs to run in parallel for both `fit` and `predict`.
+        ``-1`` means using all processors.
+    random_state : int or None, default=None
+        Seed for random number generation.
+
+    Attributes
+    ----------
+    n_classes_ : int
+        The number of classes.
+    classes_ : list
+        The classes labels.
+    n_instances_ : int
+        The number of train cases.
+    n_dims_ : int
+        The number of dimensions per case.
+    series_length_ : int
+        The length of each series.
+    n_estimators_ : int
+        The final number of classifiers used (<= max_ensemble_size)
+    estimators_ : list of shape (n_estimators) of IndividualOrdinalTDE
+        The collections of estimators trained in fit.
+    weights_ : list of shape (n_estimators) of float
+        Weight of each estimator in the ensemble.
+
+    See Also
+    --------
+    IndividualOrdinalTDE, TDE, WEASEL
+
+    References
+    ----------
+    ..  [1] Rafael Ayllon-Gavilan, David Guijo-Rubio, Pedro Antonio Gutierrez and
+        Cesar Hervas-Martinez.
+        "A Dictionary-based approach to Time Series Ordinal Classification",
+        IWANN 2023. 17th International Work-Conference on Artificial Neural Networks.
+    ..  [2] Matthew Middlehurst, James Large, Gavin Cawley and Anthony Bagnall.
+        "The Temporal Dictionary Ensemble (TDE) Classifier for Time Series
+        Classification", in proceedings of the European Conference on Machine Learning
+        and Principles and Practice of Knowledge Discovery in Databases, 2020.
+
+    Examples
+    --------
+    >>> from aeon.classification.ordinal_classification import OrdinalTDE
+    >>> from aeon.datasets import load_unit_test
+    >>> X_train, y_train = load_unit_test(split="train", return_X_y=True)
+    >>> X_test, y_test = load_unit_test(split="test", return_X_y=True)
+    >>> clf = OrdinalTDE(
+    ...     n_parameter_samples=10,
+    ...     max_ensemble_size=3,
+    ...     randomly_selected_params=5,
+    ... )
+    >>> clf.fit(X_train, y_train)
+    OrdinalTDE(...)
+    >>> y_pred = clf.predict(X_test)
+    """
+
+    _tags = {
+        "capability:multivariate": True,
+        "capability:train_estimate": True,
+        "capability:contractable": True,
+        "capability:multithreading": True,
+        "algorithm_type": "dictionary",
+    }
+
+    def __init__(
+        self,
+        n_parameter_samples=250,
+        max_ensemble_size=50,
+        max_win_len_prop=1,
+        min_window=10,
+        randomly_selected_params=50,
+        bigrams=None,
+        dim_threshold=0.85,
+        max_dims=20,
+        time_limit_in_minutes=0.0,
+        contract_max_n_parameter_samples=np.inf,
+        typed_dict=True,
+        save_train_predictions=False,
+        n_jobs=1,
+        random_state=None,
+    ):
+        self.n_parameter_samples = n_parameter_samples
+        self.max_ensemble_size = max_ensemble_size
+        self.max_win_len_prop = max_win_len_prop
+        self.min_window = min_window
+        self.randomly_selected_params = randomly_selected_params
+        self.bigrams = bigrams
+
+        # multivariate
+        self.dim_threshold = dim_threshold
+        self.max_dims = max_dims
+
+        self.time_limit_in_minutes = time_limit_in_minutes
+        self.contract_max_n_parameter_samples = contract_max_n_parameter_samples
+        self.typed_dict = typed_dict
+        self.save_train_predictions = save_train_predictions
+        self.random_state = random_state
+        self.n_jobs = n_jobs
+
+        self.n_instances_ = 0
+        self.n_dims_ = 0
+        self.series_length_ = 0
+        self.n_estimators_ = 0
+        self.estimators_ = []
+        self.weights_ = []
+
+        self._word_lengths = [16, 14, 12, 10, 8]
+        self._norm_options = [True, False]
+        self._levels = [1, 2, 3]
+        self._igb_options = [True]  # No "equi-depth" in ordinal version
+        self._alphabet_size = 4
+        self._weight_sum = 0
+        self._prev_parameters_x = []
+        self._prev_parameters_y = []
+
+        super(OrdinalTDE, self).__init__()
+
+    def _fit(self, X, y):
+        """Fit an ensemble on cases (X,y), where y is the target variable.
+
+        Build an ensemble of base TDE classifiers from the training set (X,
+        y), through an optimised selection over the para space to make a fixed size
+        ensemble of the best.
+
+        Parameters
+        ----------
+        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
+            The training data.
+        y : array-like, shape = [n_instances]
+            The class labels.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+
+        Notes
+        -----
+        Changes state by creating a fitted model that updates attributes
+        ending in "_" and sets is_fitted flag to True.
+        """
+        if self.n_parameter_samples <= self.randomly_selected_params:
+            warnings.warn(
+                "TemporalDictionaryEnsemble warning: n_parameter_samples <= "
+                "randomly_selected_params, ensemble member parameters will be fully "
+                "randomly selected.",
+                stacklevel=2,
+            )
+
+        self.n_instances_, self.n_dims_, self.series_length_ = X.shape
+
+        self.estimators_ = []
+        self.weights_ = []
+        self._prev_parameters_x = []
+        self._prev_parameters_y = []
+
+        # Window length parameter space dependent on series length
+        max_window_searches = self.series_length_ / 4
+        max_window = int(self.series_length_ * self.max_win_len_prop)
+
+        if self.min_window >= max_window:
+            self._min_window = max_window
+            warnings.warn(
+                f"TemporalDictionaryEnsemble warning: min_window = "
+                f"{self.min_window} is larger than max_window = {max_window}."
+                f" min_window has been set to {max_window}.",
+                stacklevel=2,
+            )
+
+        win_inc = int((max_window - self.min_window) / max_window_searches)
+        if win_inc < 1:
+            win_inc = 1
+
+        possible_parameters = self._unique_parameters(max_window, win_inc)
+        num_classifiers = 0
+        subsample_size = int(self.n_instances_ * 0.7)
+        highest_mae = 0
+        highest_mae_idx = 0
+
+        time_limit = self.time_limit_in_minutes * 60
+        start_time = time.time()
+        train_time = 0
+        if time_limit > 0:
+            n_parameter_samples = 0
+            contract_max_n_parameter_samples = self.contract_max_n_parameter_samples
+        else:
+            n_parameter_samples = self.n_parameter_samples
+            contract_max_n_parameter_samples = np.inf
+
+        rng = check_random_state(self.random_state)
+
+        if self.bigrams is None:
+            if self.n_dims_ > 1:
+                use_bigrams = False
+            else:
+                use_bigrams = True
+        else:
+            use_bigrams = self.bigrams
+
+        # use time limit or n_parameter_samples if limit is 0
+        while (
+            (
+                train_time < time_limit
+                and num_classifiers < contract_max_n_parameter_samples
+            )
+            or num_classifiers < n_parameter_samples
+        ) and len(possible_parameters) > 0:
+            if num_classifiers < self.randomly_selected_params:
+                parameters = possible_parameters.pop(
+                    rng.randint(0, len(possible_parameters))
+                )
+            else:
+                scaler = preprocessing.StandardScaler()
+                scaler.fit(self._prev_parameters_x)
+                gp = KernelRidge(kernel="poly", degree=1)
+                gp.fit(
+                    scaler.transform(self._prev_parameters_x), self._prev_parameters_y
+                )
+                preds = gp.predict(scaler.transform(possible_parameters))
+                parameters = possible_parameters.pop(
+                    rng.choice(np.flatnonzero(preds == preds.min()))
+                )
+
+            subsample = rng.choice(
+                self.n_instances_, size=subsample_size, replace=False
+            )
+            X_subsample = X[subsample]
+            y_subsample = y[subsample]
+
+            tde = IndividualOrdinalTDE(
+                *parameters,
+                alphabet_size=self._alphabet_size,
+                bigrams=use_bigrams,
+                dim_threshold=self.dim_threshold,
+                max_dims=self.max_dims,
+                typed_dict=self.typed_dict,
+                n_jobs=self._threads_to_use,
+                random_state=self.random_state,
+            )
+            tde.fit(X_subsample, y_subsample)
+            tde._subsample = subsample
+
+            tde._mae = self._individual_train_mae(
+                tde,
+                y_subsample,
+                subsample_size,
+                100 if num_classifiers < self.max_ensemble_size else highest_mae,
+            )
+
+            w = 1 / (1 + abs(tde._mae))
+            if w >= 0:
+                weight = math.pow(w, 4)
+            else:
+                weight = 0.000000001
+
+            if num_classifiers < self.max_ensemble_size:
+                if tde._mae > highest_mae:
+                    highest_mae = tde._mae
+                    highest_mae_idx = num_classifiers
+                self.weights_.append(weight)
+                self.estimators_.append(tde)
+            else:
+                if tde._mae < highest_mae:
+                    self.weights_[highest_mae_idx] = weight
+                    self.estimators_[highest_mae_idx] = tde
+                    highest_mae, highest_mae_idx = self._worst_ensemble_mae()
+
+            self._prev_parameters_x.append(parameters)
+            self._prev_parameters_y.append(tde._mae)
+
+            num_classifiers += 1
+            train_time = time.time() - start_time
+
+        self.n_estimators_ = len(self.estimators_)
+        self._weight_sum = np.sum(self.weights_)
+
+        return self
+
+    def _predict(self, X) -> np.ndarray:
+        """Predict class values of n instances in X.
+
+        Parameters
+        ----------
+        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
+            The data to make predictions for.
+
+        Returns
+        -------
+        y : array-like, shape = [n_instances]
+            Predicted class labels.
+        """
+        rng = check_random_state(self.random_state)
+        return np.array(
+            [
+                self.classes_[int(rng.choice(np.flatnonzero(prob == prob.max())))]
+                for prob in self._predict_proba(X)
+            ]
+        )
+
+    def _predict_proba(self, X) -> np.ndarray:
+        """Predict class probabilities for n instances in X.
+
+        Parameters
+        ----------
+        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
+            The data to make predict probabilities for.
+
+        Returns
+        -------
+        y : array-like, shape = [n_instances, n_classes_]
+            Predicted probabilities using the ordering in classes_.
+        """
+        _, _, series_length = X.shape
+        if series_length != self.series_length_:
+            raise TypeError(
+                "ERROR number of attributes in the train does not match "
+                "that in the test data"
+            )
+
+        sums = np.zeros((X.shape[0], self.n_classes_))
+
+        for n, clf in enumerate(self.estimators_):
+            preds = clf.predict(X)
+            for i in range(0, X.shape[0]):
+                sums[i, self._class_dictionary[preds[i]]] += self.weights_[n]
+
+        return sums / (np.ones(self.n_classes_) * self._weight_sum)
+
+    def _worst_ensemble_acc(self):
+        min_acc = 1.0
+        min_acc_idx = 0
+
+        for c, classifier in enumerate(self.estimators_):
+            if classifier._accuracy < min_acc:
+                min_acc = classifier._accuracy
+                min_acc_idx = c
+
+        return min_acc, min_acc_idx
+
+    def _worst_ensemble_mae(self):
+        worst_mae = 0.0
+        worst_mae_idx = 0
+
+        for c, classifier in enumerate(self.estimators_):
+            if classifier._mae > worst_mae:
+                worst_mae = classifier._mae
+                worst_mae_idx = c
+
+        return worst_mae, worst_mae_idx
+
+    def _unique_parameters(self, max_window, win_inc):
+        possible_parameters = [
+            [win_size, word_len, normalise, levels, igb]
+            for normalise in self._norm_options
+            for win_size in range(self.min_window, max_window + 1, win_inc)
+            for word_len in self._word_lengths
+            for levels in self._levels
+            for igb in self._igb_options
+        ]
+
+        return possible_parameters
+
+    def _get_train_probs(self, X, y, train_estimate_method="loocv") -> np.ndarray:
+        self.check_is_fitted()
+        X, y = check_X_y(X, y, coerce_to_numpy=True)
+
+        n_instances, n_dims, series_length = X.shape
+
+        if (
+            n_instances != self.n_instances_
+            or n_dims != self.n_dims_
+            or series_length != self.series_length_
+        ):
+            raise ValueError(
+                "n_instances, n_dims, series_length mismatch. X should be "
+                "the same as the training data used in fit for generating train "
+                "probabilities."
+            )
+
+        results = np.zeros((n_instances, self.n_classes_))
+        divisors = np.zeros(n_instances)
+
+        if train_estimate_method.lower() == "loocv":
+            for i, clf in enumerate(self.estimators_):
+                subsample = clf._subsample
+                preds = (
+                    clf._train_predictions
+                    if self.save_train_predictions
+                    else Parallel(n_jobs=self._threads_to_use, prefer="threads")(
+                        delayed(clf._train_predict)(
+                            i,
+                        )
+                        for i in range(len(subsample))
+                    )
+                )
+
+                for n, pred in enumerate(preds):
+                    results[subsample[n]][
+                        self._class_dictionary[pred]
+                    ] += self.weights_[i]
+                    divisors[subsample[n]] += self.weights_[i]
+        elif train_estimate_method.lower() == "oob":
+            indices = range(n_instances)
+            for i, clf in enumerate(self.estimators_):
+                oob = [n for n in indices if n not in clf._subsample]
+
+                if len(oob) == 0:
+                    continue
+
+                preds = clf.predict(X[oob])
+
+                for n, pred in enumerate(preds):
+                    results[oob[n]][self._class_dictionary[pred]] += self.weights_[i]
+                    divisors[oob[n]] += self.weights_[i]
+        else:
+            raise ValueError(
+                "Invalid train_estimate_method. Available options: loocv, oob"
+            )
+
+        for i in range(n_instances):
+            results[i] = (
+                np.ones(self.n_classes_) * (1 / self.n_classes_)
+                if divisors[i] == 0
+                else results[i] / (np.ones(self.n_classes_) * divisors[i])
+            )
+
+        return results
+
+    def _individual_train_acc(self, tde, y, train_size, lowest_acc):
+        correct = 0
+        required_correct = int(lowest_acc * train_size)
+
+        if self._threads_to_use > 1:
+            c = Parallel(n_jobs=self._threads_to_use, prefer="threads")(
+                delayed(tde._train_predict)(
+                    i,
+                )
+                for i in range(train_size)
+            )
+
+            for i in range(train_size):
+                if correct + train_size - i < required_correct:
+                    return -1
+                elif c[i] == y[i]:
+                    correct += 1
+
+                if self.save_train_predictions:
+                    tde._train_predictions.append(c[i])
+
+        else:
+            for i in range(train_size):
+                if correct + train_size - i < required_correct:
+                    return -1
+
+                c = tde._train_predict(i)
+
+                if c == y[i]:
+                    correct += 1
+
+                if self.save_train_predictions:
+                    tde._train_predictions.append(c)
+
+        return correct / train_size
+
+    def _individual_train_mae(self, tde, y, train_size, highest_mae):
+        absolute_error = 0
+
+        if self._threads_to_use > 1:
+            c = Parallel(n_jobs=self._threads_to_use)(
+                delayed(tde._train_predict)(
+                    i,
+                )
+                for i in range(train_size)
+            )
+            for i in range(train_size):
+                absolute_error += abs(int(y[i]) - int(c[i]))
+                if self.save_train_predictions:
+                    tde._train_predictions.append(c[i])
+        else:
+            for i in range(train_size):
+                c = tde._train_predict(i)
+                absolute_error += abs(int(y[i]) - int(c))
+                if self.save_train_predictions:
+                    tde._train_predictions.append(c)
+
+        mae = absolute_error / train_size
+        if mae > highest_mae:
+            return 100
+        return mae
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            For classifiers, a "default" set of parameters should be provided for
+            general testing, and a "results_comparison" set for comparing against
+            previously recorded results if the general set does not produce suitable
+            probabilities to compare against.
+
+        Returns
+        -------
+        params : dict or list of dict, default={}
+            Parameters to create testing instances of the class.
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`.
+        """
+        if parameter_set == "results_comparison":
+            return {
+                "n_parameter_samples": 10,
+                "max_ensemble_size": 5,
+                "randomly_selected_params": 5,
+            }
+        elif parameter_set == "contracting":
+            return {
+                "time_limit_in_minutes": 5,
+                "contract_max_n_parameter_samples": 5,
+                "max_ensemble_size": 2,
+                "randomly_selected_params": 3,
+            }
+        elif parameter_set == "train_estimate":
+            return {
+                "n_parameter_samples": 5,
+                "max_ensemble_size": 2,
+                "randomly_selected_params": 3,
+                "save_train_predictions": True,
+            }
+        else:
+            return {
+                "n_parameter_samples": 5,
+                "max_ensemble_size": 2,
+                "randomly_selected_params": 3,
+            }
+
+
+class IndividualOrdinalTDE(BaseClassifier):
+    """Single O-TDE classifier, an ordinal version of the IndividualTDE from [2]_.
+
+    Base classifier for the O-TDE classifier. Implementation of single O-TDE base model
+    from [1]_.
+
+    Overview: input "n" series of length "m" and IndividualOrdinalTDE performs a SFA
+    transform to form a sparse dictionary of discretised words. The binning thresholds
+    are obtained from a DecisionTreeRegressor which considers as splitting criterion
+    the friedman mse metric. Then, histograms are formed from the discretised words for
+    each time series.
+
+    fit involves finding "n" histograms.
+
+    predict uses 1 nearest neighbor with the histogram intersection distance function.
+
+    Parameters
+    ----------
+    window_size : int, default=10
+        Size of the window to use in the SFA transform.
+    word_length : int, default=8
+        Length of word to use to use in the SFA transform.
+    norm : bool, default=False
+        Whether to normalize SFA words by dropping the first Fourier coefficient.
+    levels : int, default=1
+        The number of spatial pyramid levels for the SFA transform.
+    igb : bool, default=False
+        Whether to use Information Gain Binning (IGB) or
+        Multiple Coefficient Binning (MCB) for the SFA transform.
+    alphabet_size : default=4
+        Number of possible letters (values) for each word.
+    bigrams : bool, default=False
+        Whether to record word bigrams in the SFA transform.
+    dim_threshold : float, default=0.85
+        Accuracy threshold as a propotion of the highest accuracy dimension for words
+        extracted from each dimensions. Only applicable for multivariate data.
+    max_dims : int, default=20
+        Maximum number of dimensions words are extracted from. Only applicable for
+        multivariate data.
+    typed_dict : bool, default=True
+        Use a numba TypedDict to store word counts. May increase memory usage, but will
+        be faster for larger datasets.
+    n_jobs : int, default=1
+        The number of jobs to run in parallel for both `fit` and `predict`.
+        ``-1`` means using all processors.
+    random_state : int or None, default=None
+        Seed for random, integer.
+
+    Attributes
+    ----------
+    n_classes_ : int
+        The number of classes.
+    classes_ : list
+        The classes labels.
+    n_instances_ : int
+        The number of train cases.
+    n_dims_ : int
+        The number of dimensions per case.
+    series_length_ : int
+        The length of each series.
+
+    See Also
+    --------
+    TemporalDictinaryEnsemble, SFA
+
+    Notes
+    -----
+    For the Java version, see
+    `TSML <https://github.com/uea-machine-learning/tsml/blob/master/src/main/java/
+    tsml/classifiers/dictionary_based/IndividualOrdinalTDE.java>`_.
+
+    References
+    ----------
+    ..  [1] Rafael Ayllon-Gavilan, David Guijo-Rubio, Pedro Antonio Gutierrez and
+        Cesar Hervas-Martinez.
+        "A Dictionary-based approach to Time Series Ordinal Classification",
+        IWANN 2023. 17th International Work-Conference on Artificial Neural Networks.
+    ..  [2] Matthew Middlehurst, James Large, Gavin Cawley and Anthony Bagnall
+        "The Temporal Dictionary Ensemble (TDE) Classifier for Time Series
+        Classification", in proceedings of the European Conference on Machine Learning
+        and Principles and Practice of Knowledge Discovery in Databases, 2020.
+
+    Examples
+    --------
+    >>> from aeon.classification.ordinal_classification import IndividualOrdinalTDE
+    >>> from aeon.datasets import load_unit_test
+    >>> X_train, y_train = load_unit_test(split="train", return_X_y=True)
+    >>> X_test, y_test = load_unit_test(split="test", return_X_y=True)
+    >>> clf = IndividualOrdinalTDE()
+    >>> clf.fit(X_train, y_train)
+    IndividualOrdinalTDE(...)
+    >>> y_pred = clf.predict(X_test)
+    """
+
+    _tags = {
+        "capability:multivariate": True,
+        "capability:multithreading": True,
+    }
+
+    def __init__(
+        self,
+        window_size=10,
+        word_length=8,
+        norm=False,
+        levels=1,
+        igb=False,
+        alphabet_size=4,
+        bigrams=True,
+        dim_threshold=0.85,
+        max_dims=20,
+        typed_dict=True,
+        n_jobs=1,
+        random_state=None,
+    ):
+        self.window_size = window_size
+        self.word_length = word_length
+        self.norm = norm
+        self.levels = levels
+        self.igb = igb
+        self.alphabet_size = alphabet_size
+        self.bigrams = bigrams
+
+        # multivariate
+        self.dim_threshold = dim_threshold
+        self.max_dims = max_dims
+
+        self.typed_dict = typed_dict
+        self.n_jobs = n_jobs
+        self.random_state = random_state
+
+        self.n_instances_ = 0
+        self.n_dims_ = 0
+        self.series_length_ = 0
+
+        self._transformers = []
+        self._transformed_data = []
+        self._class_vals = []
+        self._dims = []
+        self._highest_dim_bit = 0
+        self._accuracy = 0
+        self._subsample = []
+        self._train_predictions = []
+
+        super(IndividualOrdinalTDE, self).__init__()
+
+    # todo remove along with BOSS and SFA workarounds when Dict becomes serialisable.
+    def __getstate__(self):
+        """Return state as dictionary for pickling, required for typed Dict objects."""
+        state = self.__dict__.copy()
+        if self.typed_dict:
+            nl = [None] * len(self._transformed_data)
+            for i, ndict in enumerate(state["_transformed_data"]):
+                pdict = dict()
+                for key, val in ndict.items():
+                    pdict[key] = val
+                nl[i] = pdict
+            state["_transformed_data"] = nl
+        return state
+
+    def __setstate__(self, state):
+        """Set current state using input pickling, required for typed Dict objects."""
+        self.__dict__.update(state)
+        if self.typed_dict:
+            nl = [None] * len(self._transformed_data)
+            for i, pdict in enumerate(self._transformed_data):
+                ndict = (
+                    Dict.empty(
+                        key_type=types.UniTuple(types.int64, 2), value_type=types.uint32
+                    )
+                    if self.levels > 1 or self.n_dims_ > 1
+                    else Dict.empty(key_type=types.int64, value_type=types.uint32)
+                )
+                for key, val in pdict.items():
+                    ndict[key] = val
+                nl[i] = ndict
+            self._transformed_data = nl
+
+    def _fit(self, X, y):
+        """Fit a single base TDE classifier on n_instances cases (X,y).
+
+        Parameters
+        ----------
+        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
+            The training data.
+        y : array-like, shape = [n_instances]
+            The class labels.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+
+        Notes
+        -----
+        Changes state by creating a fitted model that updates attributes
+        ending in "_" and sets is_fitted flag to True.
+        """
+        self.n_instances_, self.n_dims_, self.series_length_ = X.shape
+        self._class_vals = y
+
+        # select dimensions using accuracy estimate if multivariate
+        if self.n_dims_ > 1:
+            self._dims, self._transformers = self._select_dims(X, y)
+
+            words = (
+                [
+                    Dict.empty(
+                        key_type=types.UniTuple(types.int64, 2), value_type=types.uint32
+                    )
+                    for _ in range(self.n_instances_)
+                ]
+                if self.typed_dict
+                else [defaultdict(int) for _ in range(self.n_instances_)]
+            )
+
+            for i, dim in enumerate(self._dims):
+                X_dim = X[:, dim, :].reshape(self.n_instances_, 1, self.series_length_)
+                dim_words = self._transformers[i].transform(X_dim, y)
+                dim_words = dim_words[0]
+
+                for n in range(self.n_instances_):
+                    if self.typed_dict:
+                        for word, count in dim_words[n].items():
+                            if self.levels > 1:
+                                words[n][
+                                    (word[0], word[1] << self._highest_dim_bit | dim)
+                                ] = count
+                            else:
+                                words[n][(word, dim)] = count
+                    else:
+                        for word, count in dim_words[n].items():
+                            words[n][word << self._highest_dim_bit | dim] = count
+
+            self._transformed_data = words
+        else:
+            self._transformers.append(
+                SFA(
+                    word_length=self.word_length,
+                    alphabet_size=self.alphabet_size,
+                    window_size=self.window_size,
+                    norm=self.norm,
+                    levels=self.levels,
+                    binning_method="information-gain-mae" if self.igb else "equi-depth",
+                    bigrams=self.bigrams,
+                    remove_repeat_words=True,
+                    lower_bounding=False,
+                    save_words=False,
+                    use_fallback_dft=True,
+                    typed_dict=self.typed_dict,
+                    n_jobs=self._threads_to_use,
+                    random_state=self.random_state,
+                )
+            )
+            self._transformers[0].fit(X, y)
+            sfa = self._transformers[0].transform(X, y)
+            self._transformed_data = sfa[0]
+
+    def _predict(self, X):
+        """Predict class values of all instances in X.
+
+        Parameters
+        ----------
+        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
+            The data to make predictions for.
+
+        Returns
+        -------
+        y : array-like, shape = [n_instances]
+            Predicted class labels.
+        """
+        num_cases = X.shape[0]
+
+        if self.n_dims_ > 1:
+            words = (
+                [
+                    Dict.empty(
+                        key_type=types.UniTuple(types.int64, 2), value_type=types.uint32
+                    )
+                    for _ in range(num_cases)
+                ]
+                if self.typed_dict
+                else [defaultdict(int) for _ in range(num_cases)]
+            )
+
+            for i, dim in enumerate(self._dims):
+                X_dim = X[:, dim, :].reshape(num_cases, 1, self.series_length_)
+                dim_words = self._transformers[i].transform(X_dim)
+                dim_words = dim_words[0]
+
+                for n in range(num_cases):
+                    if self.typed_dict:
+                        for word, count in dim_words[n].items():
+                            if self.levels > 1:
+                                words[n][
+                                    (word[0], word[1] << self._highest_dim_bit | dim)
+                                ] = count
+                            else:
+                                words[n][(word, dim)] = count
+                    else:
+                        for word, count in dim_words[n].items():
+                            words[n][word << self._highest_dim_bit | dim] = count
+
+            test_bags = words
+        else:
+            test_bags = self._transformers[0].transform(X)
+            test_bags = test_bags[0]
+
+        classes = Parallel(n_jobs=self._threads_to_use, prefer="threads")(
+            delayed(self._test_nn)(
+                test_bag,
+            )
+            for test_bag in test_bags
+        )
+
+        return np.array(classes)
+
+    def _test_nn(self, test_bag):
+        rng = check_random_state(self.random_state)
+
+        best_sim = -1
+        nn = None
+
+        for n, bag in enumerate(self._transformed_data):
+            sim = histogram_intersection(test_bag, bag)
+
+            if sim > best_sim or (sim == best_sim and rng.random() < 0.5):
+                best_sim = sim
+                nn = self._class_vals[n]
+
+        return nn
+
+    def _select_dims(self, X, y):
+        self._highest_dim_bit = (math.ceil(math.log2(self.n_dims_))) + 1
+        maes = []
+        transformers = []
+
+        # select dimensions based on reduced bag size accuracy
+        for i in range(self.n_dims_):
+            self._dims.append(i)
+            transformers.append(
+                SFA(
+                    word_length=self.word_length,
+                    alphabet_size=self.alphabet_size,
+                    window_size=self.window_size,
+                    norm=self.norm,
+                    levels=self.levels,
+                    binning_method="information-gain-mae" if self.igb else "equi-depth",
+                    bigrams=self.bigrams,
+                    remove_repeat_words=True,
+                    lower_bounding=False,
+                    save_words=False,
+                    keep_binning_dft=True,
+                    use_fallback_dft=True,
+                    typed_dict=self.typed_dict,
+                    n_jobs=self._threads_to_use,
+                )
+            )
+
+            X_dim = X[:, i, :].reshape(self.n_instances_, 1, self.series_length_)
+
+            transformers[i].fit(X_dim, y)
+            sfa = transformers[i].transform(
+                X_dim,
+                y,
+            )
+            transformers[i].keep_binning_dft = False
+            transformers[i].binning_dft = None
+
+            total_absolute_err = 0
+            for i in range(self.n_instances_):
+                absolute_err = abs(int(y[i]) - int(self._train_predict(i, sfa[0])))
+                total_absolute_err += absolute_err
+            mae = total_absolute_err / self.n_instances_
+            maes.append(mae)
+
+        min_mae = min(maes)
+
+        dims = []
+        fin_transformers = []
+        mae_min_threshold = 1 + (1 - self.dim_threshold)
+        for i in range(self.n_dims_):
+            if maes[i] <= min_mae * mae_min_threshold:
+                dims.append(i)
+                fin_transformers.append(transformers[i])
+
+        if len(dims) > self.max_dims:
+            rng = check_random_state(self.random_state)
+            idx = rng.choice(len(dims), self.max_dims, replace=False).tolist()
+            dims = [dims[i] for i in idx]
+            fin_transformers = [fin_transformers[i] for i in idx]
+
+        return dims, fin_transformers
+
+    def _train_predict(self, train_num, bags=None):
+        if bags is None:
+            bags = self._transformed_data
+
+        test_bag = bags[train_num]
+        best_sim = -1
+        nn = None
+
+        for n, bag in enumerate(bags):
+            if n == train_num:
+                continue
+
+            sim = histogram_intersection(test_bag, bag)
+
+            if sim > best_sim:
+                best_sim = sim
+                nn = self._class_vals[n]
+
+        return nn
+
+
+def histogram_intersection(first, second):
+    """Find the distance between two histograms using the histogram intersection.
+
+    This distance function is designed for sparse matrix, represented as a
+    dictionary or numba Dict, but can accept arrays.
+
+    Parameters
+    ----------
+    first : dict, numba.Dict or array
+        First dictionary used in distance measurement.
+    second : dict, numba.Dict or array
+        Second dictionary that will be used to measure distance from `first`.
+
+    Returns
+    -------
+    dist : float
+        The histogram intersection distance between the first and second dictionaries.
+    """
+    if isinstance(first, dict):
+        sim = 0
+        for word, val_a in first.items():
+            val_b = second.get(word, 0)
+            sim += min(val_a, val_b)
+        return sim
+    elif isinstance(first, Dict):
+        return _histogram_intersection_dict(first, second)
+    else:
+        return np.sum(
+            [
+                0 if first[n] == 0 else np.min(first[n], second[n])
+                for n in range(len(first))
+            ]
+        )
+
+
+@njit(fastmath=True, cache=True)
+def _histogram_intersection_dict(first, second):
+    sim = 0
+    for word, val_a in first.items():
+        val_b = second.get(word, types.uint32(0))
+        sim += min(val_a, val_b)
+    return sim
diff --git a/aeon/regression/deep_learning/base.py b/aeon/regression/deep_learning/base.py
index 66c1e11a..ba1afe36 100644
--- a/aeon/regression/deep_learning/base.py
+++ b/aeon/regression/deep_learning/base.py
@@ -14,7 +14,6 @@ from abc import ABC, abstractmethod
 import numpy as np
 
 from aeon.regression.base import BaseRegressor
-from aeon.utils.validation._dependencies import _check_soft_dependencies
 
 
 class BaseDeepRegressor(BaseRegressor, ABC):
@@ -28,6 +27,9 @@ class BaseDeepRegressor(BaseRegressor, ABC):
     ----------
     batch_size : int, default = 40
         training batch size for the model
+    last_file_name      : str, default = "last_model"
+        The name of the file of the last model, used
+        only if save_last_model_to_file is used
 
     Arguments
     ---------
@@ -41,10 +43,11 @@ class BaseDeepRegressor(BaseRegressor, ABC):
         "python_dependencies": "tensorflow",
     }
 
-    def __init__(self, batch_size=40):
+    def __init__(self, batch_size=40, last_file_name="last_model"):
         super(BaseDeepRegressor, self).__init__()
 
         self.batch_size = batch_size
+        self.last_file_name = last_file_name
         self.model_ = None
 
     @abstractmethod
@@ -82,251 +85,16 @@ class BaseDeepRegressor(BaseRegressor, ABC):
         y_pred = np.squeeze(y_pred, axis=-1)
         return y_pred
 
-    def __getstate__(self):
-        """Get Dict config that will be used when a serialization method is called.
-
-        Returns
-        -------
-        copy : dict, the config to be serialized
-        """
-        from tensorflow.keras.optimizers import Optimizer, serialize
-
-        copy = self.__dict__.copy()
-
-        # Either optimizer might not exist at all(-1),
-        # or it does and takes a value(including None)
-        optimizer_attr = copy.get("optimizer", -1)
-        if not isinstance(optimizer_attr, str):
-            if optimizer_attr is None:
-                # if it is None, then save it as 0, so it can be
-                # later correctly restored as None
-                copy["optimizer"] = 0
-            elif optimizer_attr == -1:
-                # if an `optimizer` parameter doesn't exist at all
-                # save it as -1
-                copy["optimizer"] = -1
-            elif isinstance(optimizer_attr, Optimizer):
-                copy["optimizer"] = serialize(optimizer_attr)
-            else:
-                raise ValueError(
-                    f"`optimizer` of type {type(optimizer_attr)} cannot be "
-                    "serialized, it should either be absent/None/str/"
-                    "tf.keras.optimizers.Optimizer object"
-                )
-        else:
-            # if it was a string, don't touch since already serializable
-            pass
-
-        check_before_deletion = ["model_", "history", "optimizer_"]
-        for attribute in check_before_deletion:
-            if copy.get(attribute) is not None:
-                del copy[attribute]
-        return copy
-
-    def __setstate__(self, state):
-        """Magic method called during deserialization.
+    def save_last_model_to_file(self, file_path="./"):
+        """Save the last epoch of the trained deep learning model.
 
         Parameters
         ----------
-        state : dict, as returned from __getstate__(), used for correct deserialization
+        file_path : str, default = "./"
+            The directory where the model will be saved
 
         Returns
         -------
-        -
+        None
         """
-        from tensorflow.keras.optimizers import deserialize
-
-        self.__dict__ = state
-
-        if hasattr(self, "model_"):
-            self.__dict__["model_"] = self.model_
-            if hasattr(self, "model_.optimizer"):
-                self.__dict__["optimizer_"] = self.model_.optimizer
-
-        # if optimizer_ exists, set optimizer as optimizer_
-        if self.__dict__.get("optimizer_") is not None:
-            self.__dict__["optimizer"] = self.__dict__["optimizer_"]
-        # else model may not have been built, but an optimizer might be passed
-        else:
-            # Having 0 as value implies "optimizer" attribute was None
-            # as per __getstate__()
-            if self.__dict__.get("optimizer") == 0:
-                self.__dict__["optimizer"] = None
-            elif self.__dict__.get("optimizer") == -1:
-                # `optimizer` doesn't exist as a parameter alone, so delete it.
-                del self.__dict__["optimizer"]
-            else:
-                if isinstance(self.optimizer, dict):
-                    self.__dict__["optimizer"] = deserialize(self.optimizer)
-                else:
-                    # must have been a string already, no need to set
-                    pass
-
-        if hasattr(self, "history"):
-            self.__dict__["history"] = self.history
-
-    def save(self, path=None):
-        """Save serialized self to bytes-like object or to (.zip) file.
-
-        Behaviour:
-        if `path` is None, returns an in-memory serialized self
-        if `path` is a file, stores the zip with that name at the location.
-        The contents of the zip file are:
-        _metadata - contains class of self, i.e., type(self).
-        _obj - serialized self. This class uses the default serialization (pickle).
-        keras/ - model, optimizer and state stored inside this directory.
-        history - serialized history object.
-
-
-        Parameters
-        ----------
-        path : None or file location (str or Path)
-            if None, self is saved to an in-memory object
-            if file location, self is saved to that file location. For eg:
-                path="estimator" then a zip file `estimator.zip` will be made at cwd.
-                path="/home/stored/estimator" then a zip file `estimator.zip` will be
-                stored in `/home/stored/`.
-
-        Returns
-        -------
-        if `path` is None - in-memory serialized self
-        if `path` is file location - ZipFile with reference to the file
-        """
-        import pickle
-        import shutil
-        from pathlib import Path
-        from zipfile import ZipFile
-
-        if path is None:
-            _check_soft_dependencies("h5py")
-            import h5py
-
-            in_memory_model = None
-            if self.model_ is not None:
-                with h5py.File(
-                    "disk_less", "w", driver="core", backing_store=False
-                ) as h5file:
-                    self.model_.save(h5file)
-                    h5file.flush()
-                    in_memory_model = h5file.id.get_file_image()
-
-            in_memory_history = pickle.dumps(self.history.history)
-
-            return (
-                type(self),
-                (
-                    pickle.dumps(self),
-                    in_memory_model,
-                    in_memory_history,
-                ),
-            )
-
-        if not isinstance(path, (str, Path)):
-            raise TypeError(
-                "`path` is expected to either be a string or a Path object "
-                f"but found of type:{type(path)}."
-            )
-
-        path = Path(path) if isinstance(path, str) else path
-        path.mkdir()
-
-        if self.model_ is not None:
-            self.model_.save(path / "keras/")
-
-        with open(path / "history", "wb") as history_writer:
-            pickle.dump(self.history.history, history_writer)
-
-        pickle.dump(type(self), open(path / "_metadata", "wb"))
-        pickle.dump(self, open(path / "_obj", "wb"))
-
-        shutil.make_archive(base_name=path, format="zip", root_dir=path)
-        shutil.rmtree(path)
-        return ZipFile(path.with_name(f"{path.stem}.zip"))
-
-    @classmethod
-    def load_from_serial(cls, serial):
-        """Load object from serialized memory container.
-
-        Parameters
-        ----------
-        serial: 1st element of output of `cls.save(None)`
-                This is a tuple of size 3.
-                The first element represents pickle-serialized instance.
-                The second element represents h5py-serialized `keras` model.
-                The third element represent pickle-serialized history of `.fit()`.
-
-        Returns
-        -------
-        Deserialized self resulting in output `serial`, of `cls.save(None)`
-        """
-        _check_soft_dependencies("h5py")
-        import pickle
-        from tempfile import TemporaryFile
-
-        import h5py
-        from tensorflow.keras.models import load_model
-
-        if not isinstance(serial, tuple):
-            raise TypeError(
-                "`serial` is expected to be a tuple, "
-                f"instead found of type: {type(serial)}"
-            )
-        if len(serial) != 3:
-            raise ValueError(
-                "`serial` should have 3 elements. "
-                "All 3 elements represent in-memory serialization "
-                "of the estimator. "
-                f"Found a tuple of length: {len(serial)} instead."
-            )
-
-        serial, in_memory_model, in_memory_history = serial
-        if in_memory_model is None:
-            cls.model_ = None
-        else:
-            with TemporaryFile() as store_:
-                store_.write(in_memory_model)
-                h5file = h5py.File(store_, "r")
-                cls.model_ = load_model(h5file)
-                h5file.close()
-
-        cls.history = pickle.loads(in_memory_history)
-        return pickle.loads(serial)
-
-    @classmethod
-    def load_from_path(cls, serial):
-        """Load object from file location.
-
-        Parameters
-        ----------
-        serial : Name of the zip file.
-
-        Returns
-        -------
-        deserialized self resulting in output at `path`, of `cls.save(path)`
-        """
-        import pickle
-        from shutil import rmtree
-        from zipfile import ZipFile
-
-        from tensorflow import keras
-
-        temp_unzip_loc = serial.parent / "temp_unzip/"
-        temp_unzip_loc.mkdir()
-
-        with ZipFile(serial, mode="r") as zip_file:
-            for file in zip_file.namelist():
-                if not file.startswith("keras/"):
-                    continue
-                zip_file.extract(file, temp_unzip_loc)
-
-        keras_location = temp_unzip_loc / "keras"
-        if keras_location.exists():
-            cls.model_ = keras.models.load_model(keras_location)
-        else:
-            cls.model_ = None
-
-        rmtree(temp_unzip_loc)
-        cls.history = keras.callbacks.History()
-        with ZipFile(serial, mode="r") as file:
-            cls.history.set_params(pickle.loads(file.open("history").read()))
-            return pickle.loads(file.open("_obj").read())
+        self.model_.save(file_path + self.last_file_name + ".hdf5")
diff --git a/aeon/regression/deep_learning/inception_time.py b/aeon/regression/deep_learning/inception_time.py
index 13135d4a..3c9c07ff 100644
--- a/aeon/regression/deep_learning/inception_time.py
+++ b/aeon/regression/deep_learning/inception_time.py
@@ -100,6 +100,24 @@ class InceptionTimeRegressor(BaseRegressor):
             list of tf.keras.callbacks.Callback objects.
         file_path           : str, default = './'
             file_path when saving model_Checkpoint callback
+        save_best_model     : bool, default = False
+            Whether or not to save the best model, if the
+            modelcheckpoint callback is used by default,
+            this condition, if True, will prevent the
+            automatic deletion of the best saved model from
+            file and the user can choose the file name
+        save_last_model     : bool, default = False
+            Whether or not to save the last model, last
+            epoch trained, using the base class method
+            save_last_model_to_file
+        best_file_name      : str, default = "best_model"
+            The name of the file of the best model, if
+            save_best_model is set to False, this parameter
+            is discarded
+        last_file_name      : str, default = "last_model"
+            The name of the file of the last model, if
+            save_last_model is set to False, this parameter
+            is discarded
         random_state        : int, default = 0
             seed to any needed random actions.
         verbose             : boolean, default = False
@@ -153,6 +171,10 @@ class InceptionTimeRegressor(BaseRegressor):
         use_custom_filters=True,
         output_activation="linear",
         file_path="./",
+        save_last_model=False,
+        save_best_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         batch_size=64,
         use_mini_batch_size=False,
         n_epochs=1500,
@@ -185,6 +207,11 @@ class InceptionTimeRegressor(BaseRegressor):
 
         self.file_path = file_path
 
+        self.save_last_model = save_last_model
+        self.save_best_model = save_best_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
+
         self.callbacks = callbacks
         self.random_state = random_state
         self.verbose = verbose
@@ -215,7 +242,7 @@ class InceptionTimeRegressor(BaseRegressor):
         self.regressors_ = []
         rng = check_random_state(self.random_state)
 
-        for _ in range(0, self.n_regressors):
+        for n in range(0, self.n_regressors):
             rgs = IndividualInceptionRegressor(
                 nb_filters=self.nb_filters,
                 nb_conv_per_layer=self.nb_conv_per_layer,
@@ -233,6 +260,10 @@ class InceptionTimeRegressor(BaseRegressor):
                 use_custom_filters=self.use_custom_filters,
                 output_activation=self.output_activation,
                 file_path=self.file_path,
+                save_best_model=self.save_best_model,
+                save_last_model=self.save_last_model,
+                best_file_name=self.best_file_name + str(n),
+                last_file_name=self.last_file_name + str(n),
                 batch_size=self.batch_size,
                 use_mini_batch_size=self.use_mini_batch_size,
                 n_epochs=self.n_epochs,
@@ -369,6 +400,24 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
             list of tf.keras.callbacks.Callback objects.
         file_path           : str, default = './'
             file_path when saving model_Checkpoint callback
+        save_best_model     : bool, default = False
+            Whether or not to save the best model, if the
+            modelcheckpoint callback is used by default,
+            this condition, if True, will prevent the
+            automatic deletion of the best saved model from
+            file and the user can choose the file name
+        save_last_model     : bool, default = False
+            Whether or not to save the last model, last
+            epoch trained, using the base class method
+            save_last_model_to_file
+        best_file_name      : str, default = "best_model"
+            The name of the file of the best model, if
+            save_best_model is set to False, this parameter
+            is discarded
+        last_file_name      : str, default = "last_model"
+            The name of the file of the last model, if
+            save_last_model is set to False, this parameter
+            is discarded
         random_state        : int, default = 0
             seed to any needed random actions.
         verbose             : boolean, default = False
@@ -411,6 +460,10 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
         use_custom_filters=True,
         output_activation="linear",
         file_path="./",
+        save_best_model=False,
+        save_last_model=False,
+        best_file_name="best_model",
+        last_file_name="last_model",
         batch_size=64,
         use_mini_batch_size=False,
         n_epochs=1500,
@@ -421,7 +474,9 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
         optimizer=None,
     ):
         _check_dl_dependencies(severity="error")
-        super(IndividualInceptionRegressor, self).__init__()
+        super(IndividualInceptionRegressor, self).__init__(
+            last_file_name=last_file_name
+        )
         # predefined
         self.nb_filters = nb_filters
         self.nb_conv_per_layer = nb_conv_per_layer
@@ -444,6 +499,11 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
 
         self.file_path = file_path
 
+        self.save_best_model = save_best_model
+        self.save_last_model = save_last_model
+        self.best_file_name = best_file_name
+        self.last_file_name = last_file_name
+
         self.callbacks = callbacks
         self.random_state = random_state
         self.verbose = verbose
@@ -544,7 +604,9 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
         if self.verbose:
             self.training_model_.summary()
 
-        self.file_name_ = str(time.time_ns())
+        self.file_name_ = (
+            self.best_file_name if self.save_best_model else str(time.time_ns())
+        )
 
         self.callbacks_ = (
             [
@@ -574,10 +636,14 @@ class IndividualInceptionRegressor(BaseDeepRegressor):
             self.model_ = tf.keras.models.load_model(
                 self.file_path + self.file_name_ + ".hdf5", compile=False
             )
-            os.remove(self.file_path + self.file_name_ + ".hdf5")
+            if not self.save_best_model:
+                os.remove(self.file_path + self.file_name_ + ".hdf5")
         except FileNotFoundError:
             self.model_ = deepcopy(self.training_model_)
 
+        if self.save_last_model:
+            self.save_last_model_to_file(file_path=self.file_path)
+
         return self
 
     @classmethod
diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py
index 04429864..cf68ce5c 100644
--- a/aeon/transformations/base.py
+++ b/aeon/transformations/base.py
@@ -128,7 +128,7 @@ class BaseTransformer(BaseEstimator):
         "remember_data": False,  # whether all data seen is remembered as self._X
     }
 
-    # allowed mtypes for transformers - Series and Panel
+    # allowed types for transformers - Series and Panel
     ALLOWED_INPUT_TYPES = [
         "pd.Series",
         "pd.DataFrame",
diff --git a/aeon/transformations/panel/base.py b/aeon/transformations/panel/base.py
new file mode 100644
index 00000000..e3319dbf
--- /dev/null
+++ b/aeon/transformations/panel/base.py
@@ -0,0 +1,631 @@
+# -*- coding: utf-8 -*-
+# copyright: aeon developers, BSD-3-Clause License (see LICENSE file)
+"""
+Base class template for panel transformers.
+
+    class name: BasePanelTransformer
+
+Defining methods:
+    fitting         - fit(self, X, y=None)
+    transform       - transform(self, X, y=None)
+    fit&transform   - fit_transform(self, X, y=None)
+    updating        - update(self, X, y=None)
+
+Inherited inspection methods:
+    hyper-parameter inspection  - get_params()
+    fitted parameter inspection - get_fitted_params()
+
+State:
+    fitted model/strategy   - by convention, any attributes ending in "_"
+    fitted state flag       - is_fitted (property)
+    fitted state inspection - check_is_fitted()
+"""
+
+__author__ = ["mloning", "fkiraly", "miraep8", "MatthewMiddlehurst"]
+__all__ = [
+    "BaseCollectionTransformer",
+]
+
+from abc import ABCMeta, abstractmethod
+
+from aeon.datatypes import check_is_scitype, convert_to, mtype_to_scitype, update_data
+from aeon.transformations.base import BaseTransformer, _coerce_to_list
+
+
+class BaseCollectionTransformer(BaseTransformer, metaclass=ABCMeta):
+    """Transformer base class."""
+
+    # default tag values - these typically make the "safest" assumption
+    _tags = {
+        "scitype:transform-input": "Panel",
+        "scitype:transform-output": "Panel",
+        "X_inner_mtype": "numpy3D",
+        "y_inner_mtype": "None",
+        "capability:unequal_length": False,
+    }
+
+    # allowed types for transformers - Series and Panel
+    ALLOWED_INPUT_TYPES = [
+        "numpy3D",
+        "numpyflat",
+        "np-list",
+        "pd-multiindex",
+        "df-list",
+        "nested_univ",
+    ]
+
+    def __init__(self):
+        super(BaseCollectionTransformer, self).__init__(_output_convert=False)
+
+    def fit(self, X, y=None):
+        """Fit transformer to X, optionally to y.
+
+        State change:
+            Changes state to "fitted".
+
+        Writes to self:
+        _is_fitted : flag is set to True.
+        _X : X, coerced copy of X, if remember_data tag is True
+            possibly coerced to inner type or update_data compatible type
+            by reference, when possible
+        model attributes (ending in "_") : dependent on estimator
+
+        Parameters
+        ----------
+        X : Series or Panel, any supported mtype
+            Data to fit transform to, of python type as follows:
+                Series: pd.Series, pd.DataFrame, or np.ndarray (1D or 2D)
+                Panel: pd.DataFrame with 2-level MultiIndex, list of pd.DataFrame,
+                    nested pd.DataFrame, or pd.DataFrame in long/wide format
+                subject to aeon mtype format specifications, for further details see
+                    examples/AA_datatypes_and_datasets.ipynb
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        self : a fitted instance of the estimator
+        """
+        # input checks and datatype conversion
+        X_inner, y_inner = self._fit_checks(X, y)
+
+        # skip the rest if fit_is_empty is True
+        if self.get_tag("fit_is_empty"):
+            self._is_fitted = True
+            return self
+
+        self._fit(X=X_inner, y=y_inner)
+
+        # this should happen last: fitted state is set to True
+        self._is_fitted = True
+
+        return self
+
+    def transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+        _is_fitted : must be True
+        _X : optionally accessed, only available if remember_data tag is True
+        fitted model attributes (ending in "_") : must be set, accessed by _transform
+
+        Parameters
+        ----------
+        X : Series or Panel, any supported mtype
+            Data to be transformed, of python type as follows:
+                Series: pd.Series, pd.DataFrame, or np.ndarray (1D or 2D)
+                Panel: pd.DataFrame with 2-level MultiIndex, list of pd.DataFrame,
+                    nested pd.DataFrame, or pd.DataFrame in long/wide format
+                subject to aeon mtype format specifications, for further details see
+                    examples/AA_datatypes_and_datasets.ipynb
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        transformed version of X
+        type depends on type of X and scitype:transform-output tag:
+            |          | `transform`  |                        |
+            |   `X`    |  `-output`   |     type of return     |
+            |----------|--------------|------------------------|
+            | `Series` | `Primitives` | `pd.DataFrame` (1-row) |
+            | `Panel`  | `Primitives` | `pd.DataFrame`         |
+            | `Series` | `Series`     | `Series`               |
+            | `Panel`  | `Series`     | `Panel`                |
+            | `Series` | `Panel`      | `Panel`                |
+        instances in return correspond to instances in `X`
+        combinations not in the table are currently not supported
+
+        Explicitly, with examples:
+            if `X` is `Series` (e.g., `pd.DataFrame`) and `transform-output` is `Series`
+                then the return is a single `Series` of the same mtype
+                Example: detrending a single series
+            if `X` is `Panel` (e.g., `pd-multiindex`) and `transform-output` is `Series`
+                then the return is `Panel` with same number of instances as `X`
+                    (the transformer is applied to each input Series instance)
+                Example: all series in the panel are detrended individually
+            if `X` is `Series` or `Panel` and `transform-output` is `Primitives`
+                then the return is `pd.DataFrame` with as many rows as instances in `X`
+                Example: i-th row of the return has mean and variance of the i-th series
+            if `X` is `Series` and `transform-output` is `Panel`
+                then the return is a `Panel` object of type `pd-multiindex`
+                Example: i-th instance of the output is the i-th window running over `X`
+        """
+        # check whether is fitted
+        self.check_is_fitted()
+
+        # input check and conversion for X/y
+        X_inner, y_inner, metadata = self._check_X_y(X=X, y=y, return_metadata=True)
+
+        Xt = self._transform(X=X_inner, y=y_inner)
+
+        return Xt
+
+    def fit_transform(self, X, y=None):
+        """Fit to data, then transform it.
+
+        Fits the transformer to X and y and returns a transformed version of X.
+
+        State change:
+            Changes state to "fitted".
+
+        Writes to self:
+        _is_fitted : flag is set to True.
+        _X : X, coerced copy of X, if remember_data tag is True
+            possibly coerced to inner type or update_data compatible type
+            by reference, when possible
+        model attributes (ending in "_") : dependent on estimator
+
+        Parameters
+        ----------
+        X : Series or Panel, any supported mtype
+            Data to be transformed, of python type as follows:
+                Series: pd.Series, pd.DataFrame, or np.ndarray (1D or 2D)
+                Panel: pd.DataFrame with 2-level MultiIndex, list of pd.DataFrame,
+                    nested pd.DataFrame, or pd.DataFrame in long/wide format
+                subject to aeon mtype format specifications, for further details see
+                    examples/AA_datatypes_and_datasets.ipynb
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        transformed version of X
+        type depends on type of X and scitype:transform-output tag:
+            |   `X`    | `tf-output`  |     type of return     |
+            |----------|--------------|------------------------|
+            | `Series` | `Primitives` | `pd.DataFrame` (1-row) |
+            | `Panel`  | `Primitives` | `pd.DataFrame`         |
+            | `Series` | `Series`     | `Series`               |
+            | `Panel`  | `Series`     | `Panel`                |
+            | `Series` | `Panel`      | `Panel`                |
+        instances in return correspond to instances in `X`
+        combinations not in the table are currently not supported
+
+        Explicitly, with examples:
+            if `X` is `Series` (e.g., `pd.DataFrame`) and `transform-output` is `Series`
+                then the return is a single `Series` of the same mtype
+                Example: detrending a single series
+            if `X` is `Panel` (e.g., `pd-multiindex`) and `transform-output` is `Series`
+                then the return is `Panel` with same number of instances as `X`
+                    (the transformer is applied to each input Series instance)
+                Example: all series in the panel are detrended individually
+            if `X` is `Series` or `Panel` and `transform-output` is `Primitives`
+                then the return is `pd.DataFrame` with as many rows as instances in `X`
+                Example: i-th row of the return has mean and variance of the i-th series
+            if `X` is `Series` and `transform-output` is `Panel`
+                then the return is a `Panel` object of type `pd-multiindex`
+                Example: i-th instance of the output is the i-th window running over `X`
+        """
+        # input checks and datatype conversion
+        X_inner, y_inner, metadata = self._fit_checks(X, y, False, True)
+
+        Xt = self._fit_transform(X=X_inner, y=y_inner)
+
+        self._is_fitted = True
+
+        return Xt
+
+    def inverse_transform(self, X, y=None):
+        """Inverse transform X and return an inverse transformed version.
+
+        Currently it is assumed that only transformers with tags
+            "scitype:transform-input"="Series", "scitype:transform-output"="Series",
+        have an inverse_transform.
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+        _is_fitted : must be True
+        _X : optionally accessed, only available if remember_data tag is True
+        fitted model attributes (ending in "_") : accessed by _inverse_transform
+
+        Parameters
+        ----------
+        X : Series or Panel, any supported mtype
+            Data to be inverse transformed, of python type as follows:
+                Series: pd.Series, pd.DataFrame, or np.ndarray (1D or 2D)
+                Panel: pd.DataFrame with 2-level MultiIndex, list of pd.DataFrame,
+                    nested pd.DataFrame, or pd.DataFrame in long/wide format
+                subject to aeon mtype format specifications, for further details see
+                    examples/AA_datatypes_and_datasets.ipynb
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        inverse transformed version of X
+            of the same type as X, and conforming to mtype format specifications
+        """
+        if self.get_tag("skip-inverse-transform"):
+            return X
+
+        if not self.get_tag("capability:inverse_transform"):
+            raise NotImplementedError(
+                f"{type(self)} does not implement inverse_transform"
+            )
+
+        # check whether is fitted
+        self.check_is_fitted()
+
+        # input check and conversion for X/y
+        X_inner, y_inner, metadata = self._check_X_y(X=X, y=y, return_metadata=True)
+
+        Xt = self._inverse_transform(X=X_inner, y=y_inner)
+
+        return Xt
+
+    def update(self, X, y=None, update_params=True):
+        """Update transformer with X, optionally y.
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+        _is_fitted : must be True
+        _X : accessed by _update and by update_data, if remember_data tag is True
+        fitted model attributes (ending in "_") : must be set, accessed by _update
+
+        Writes to self:
+        _X : updated by values in X, via update_data, if remember_data tag is True
+        fitted model attributes (ending in "_") : only if update_params=True
+            type and nature of update are dependent on estimator
+
+        Parameters
+        ----------
+        X : Series or Panel, any supported mtype
+            Data to fit transform to, of python type as follows:
+                Series: pd.Series, pd.DataFrame, or np.ndarray (1D or 2D)
+                Panel: pd.DataFrame with 2-level MultiIndex, list of pd.DataFrame,
+                    nested pd.DataFrame, or pd.DataFrame in long/wide format
+                subject to aeon mtype format specifications, for further details see
+                    examples/AA_datatypes_and_datasets.ipynb
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+        update_params : bool, default=True
+            whether the model is updated. Yes if true, if false, simply skips call.
+            argument exists for compatibility with forecasting module.
+
+        Returns
+        -------
+        self : a fitted instance of the estimator
+        """
+        # check whether is fitted
+        self.check_is_fitted()
+
+        # if requires_y is set, y is required in fit and update
+        if self.get_tag("requires_y") and y is None:
+            raise ValueError(f"{self.__class__.__name__} requires `y` in `update`.")
+
+        # check and convert X/y
+        X_inner, y_inner = self._check_X_y(X=X, y=y)
+
+        # update memory of X, if remember_data tag is set to True
+        if self.get_tag("remember_data", False):
+            self._X = update_data(None, X_new=X_inner)
+
+        # skip everything if update_params is False
+        # skip everything if fit_is_empty is True
+        if not update_params or self.get_tag("fit_is_empty", False):
+            return self
+
+        self._update(X=X_inner, y=y_inner)
+
+        return self
+
+    def _fit_checks(self, X, y, early_abandon=True, return_metadata=False):
+        """Input checks and conversions for fit and fit_transform."""
+        self.reset()
+
+        X_inner = None
+        y_inner = None
+        metadata = None
+
+        # skip everything if fit_is_empty is True and we do not need to remember data
+        if (
+            not early_abandon
+            or not self.get_tag("fit_is_empty")
+            or self.get_tag("remember_data", False)
+        ):
+            # if requires_y is set, y is required in fit and update
+            if self.get_tag("requires_y") and y is None:
+                raise ValueError(f"{self.__class__.__name__} requires `y` in `fit`.")
+
+            # check and convert X/y
+            X_inner, y_inner, metadata = self._check_X_y(X=X, y=y, return_metadata=True)
+
+            # memorize X as self._X, if remember_data tag is set to True
+            if self.get_tag("remember_data", False):
+                self._X = update_data(None, X_new=X_inner)
+
+        if return_metadata:
+            return X_inner, y_inner, metadata
+        else:
+            return X_inner, y_inner
+
+    def _check_X_y(self, X=None, y=None, return_metadata=False):
+        """Check and coerce X/y for fit/transform functions.
+
+        Parameters
+        ----------
+        X : object of aeon compatible time series type
+            can be Series, Panel, Hierarchical
+        y : None (default), or object of aeon compatible time series type
+            can be Series, Panel, Hierarchical
+        return_metadata : bool, optional, default=False
+            whether to return the metadata return object
+
+        Returns
+        -------
+        X_inner : Series, Panel, or Hierarchical object, or VectorizedDF
+                compatible with self.get_tag("X_inner_mtype") format
+            Case 1: self.get_tag("X_inner_mtype") supports scitype of X, then
+                converted/coerced version of X, mtype determined by "X_inner_mtype" tag
+            Case 2: self.get_tag("X_inner_mtype") supports *higher* scitype than X
+                then X converted to "one-Series" or "one-Panel" sub-case of that scitype
+                always pd-multiindex (Panel) or pd_multiindex_hier (Hierarchical)
+            Case 3: self.get_tag("X_inner_mtype") supports only *simpler* scitype than X
+                then VectorizedDF of X, iterated as the most complex supported scitype
+        y_inner : Series, Panel, or Hierarchical object, or VectorizedDF
+                compatible with self.get_tag("y_inner_mtype") format
+            Case 1: self.get_tag("y_inner_mtype") supports scitype of y, then
+                converted/coerced version of y, mtype determined by "y_inner_mtype" tag
+            Case 2: self.get_tag("y_inner_mtype") supports *higher* scitype than y
+                then X converted to "one-Series" or "one-Panel" sub-case of that scitype
+                always pd-multiindex (Panel) or pd_multiindex_hier (Hierarchical)
+            Case 3: self.get_tag("y_inner_mtype") supports only *simpler* scitype than y
+                then VectorizedDF of X, iterated as the most complex supported scitype
+            Case 4: None if y was None, or self.get_tag("y_inner_mtype") is "None"
+
+            Complexity order above: Hierarchical > Panel > Series
+
+        metadata : dict, returned only if return_metadata=True
+            dictionary with str keys, contents as follows
+            _converter_store_X : dict, metadata from X conversion, for back-conversion
+            _X_mtype_last_seen : str, mtype of X seen last
+            _X_input_scitype : str, scitype of X seen last
+            _convert_case : str, coversion case (see above), one of
+                "case 1: scitype supported"
+                "case 2: higher scitype supported"
+                "case 3: requires vectorization"
+
+        Raises
+        ------
+        TypeError if X is None
+        TypeError if X or y is not one of the permissible Series mtypes
+        TypeError if X is not compatible with self.get_tag("univariate_only")
+            if tag value is "True", X must be univariate
+        ValueError if self.get_tag("requires_y")=True but y is None
+        """
+        if X is None:
+            raise TypeError("X cannot be None, but found None")
+
+        metadata = dict()
+        metadata["_converter_store_X"] = dict()
+
+        # retrieve supported mtypes
+        X_inner_mtype = _coerce_to_list(self.get_tag("X_inner_mtype"))
+        y_inner_mtype = _coerce_to_list(self.get_tag("y_inner_mtype"))
+        y_inner_scitype = mtype_to_scitype(y_inner_mtype, return_unique=True)
+
+        # checking X
+        X_valid, msg, X_metadata = check_is_scitype(
+            X,
+            scitype="Panel",
+            return_metadata=True,
+            var_name="X",
+        )
+
+        if not X_valid:
+            raise TypeError("invalid input type for X")
+
+        X_scitype = X_metadata["scitype"]
+        X_mtype = X_metadata["mtype"]
+        # remember these for potential back-conversion (in transform etc)
+        metadata["_X_mtype_last_seen"] = X_mtype
+        metadata["_X_input_scitype"] = X_scitype
+
+        if X_mtype not in self.ALLOWED_INPUT_TYPES:
+            raise TypeError("invalid input mtype for X")
+
+        # check if univariate-only
+        if self.get_tag("univariate-only") and not X_metadata["is_univariate"]:
+            raise TypeError("X must be univariate, but found multivariate")
+
+        # checking y
+        if y_inner_mtype != ["None"] and y is not None:
+            if "Table" in y_inner_scitype:
+                y_possible_scitypes = "Table"
+            elif X_scitype == "Series":
+                y_possible_scitypes = "Series"
+            elif X_scitype == "Panel":
+                y_possible_scitypes = "Panel"
+            elif X_scitype == "Hierarchical":
+                y_possible_scitypes = ["Panel", "Hierarchical"]
+
+            y_valid, _, y_metadata = check_is_scitype(
+                y, scitype=y_possible_scitypes, return_metadata=True, var_name="y"
+            )
+            if not y_valid:
+                raise TypeError("invalid input mtype for y")
+
+            y_scitype = y_metadata["scitype"]
+        else:
+            # y_scitype is used below - set to None if y is None
+            y_scitype = None
+
+        X_inner = convert_to(
+            X,
+            to_type=X_inner_mtype,
+            store=metadata["_converter_store_X"],
+            store_behaviour="reset",
+        )
+
+        # converts y, returns None if y is None
+        if y_inner_mtype != ["None"] and y is not None:
+            y_inner = convert_to(
+                y,
+                to_type=y_inner_mtype,
+                as_scitype=y_scitype,
+            )
+        else:
+            y_inner = None
+
+        if return_metadata:
+            return X_inner, y_inner, metadata
+        else:
+            return X_inner, y_inner
+
+    def _check_X(self, X=None):
+        """Shorthand for _check_X_y with one argument X, see _check_X_y."""
+        return self._check_X_y(X=X)[0]
+
+    def _fit(self, X, y=None):
+        """Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _fit must support all types in it
+            Data to fit transform to
+        y : Series or Panel of mtype y_inner_mtype, default=None
+            Additional data, e.g., labels for tarnsformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+
+        See extension_templates/transformer.py for implementation details.
+        """
+        # default fit is "no fitting happens"
+        return self
+
+    @abstractmethod
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _transform must support all types in it
+            Data to be transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        transformed version of X
+        type depends on type of X and scitype:transform-output tag:
+            |          | `transform`  |                        |
+            |   `X`    |  `-output`   |     type of return     |
+            |----------|--------------|------------------------|
+            | `Series` | `Primitives` | `pd.DataFrame` (1-row) |
+            | `Panel`  | `Primitives` | `pd.DataFrame`         |
+            | `Series` | `Series`     | `Series`               |
+            | `Panel`  | `Series`     | `Panel`                |
+            | `Series` | `Panel`      | `Panel`                |
+        instances in return correspond to instances in `X`
+        combinations not in the table are currently not supported
+
+        See extension_templates/transformer.py for implementation details.
+        """
+
+    def _fit_transform(self, X, y=None):
+        """Fit to data, then transform it.
+
+        Fits the transformer to X and y and returns a transformed version of X.
+
+        private _fit_transform containing the core logic, called from fit_transform
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _fit_transform must support all types in it
+            Data to fit transform to
+        y : Series or Panel of mtype y_inner_mtype, default=None
+            Additional data, e.g., labels for tarnsformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+
+        See extension_templates/transformer.py for implementation details.
+        """
+        # Non-optimized default implementation; override when a better
+        # method is possible for a given algorithm.
+        self._fit(X, y)
+        return self._transform(X, y)
+
+    def _inverse_transform(self, X, y=None):
+        """Inverse transform X and return an inverse transformed version.
+
+        private _inverse_transform containing core logic, called from inverse_transform
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _inverse_transform must support all types in it
+            Data to be transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        inverse transformed version of X
+            of the same type as X, and conforming to mtype format specifications
+
+        See extension_templates/transformer.py for implementation details.
+        """
+        raise NotImplementedError(
+            f"{self.__class__.__name__} does not support inverse_transform"
+        )
+
+    def _update(self, X, y=None):
+        """Update transformer with X and y.
+
+        private _update containing the core logic, called from update
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _update must support all types in it
+            Data to update transformer with
+        y : Series or Panel of mtype y_inner_mtype, default=None
+            Additional data, e.g., labels for tarnsformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+
+        See extension_templates/transformer.py for implementation details.
+        """
+        # standard behaviour: no update takes place, new data is ignored
+        return self
diff --git a/aeon/transformations/panel/catch22.py b/aeon/transformations/panel/catch22.py
index 6a37ee54..4c4175ea 100644
--- a/aeon/transformations/panel/catch22.py
+++ b/aeon/transformations/panel/catch22.py
@@ -13,11 +13,11 @@ import numpy as np
 from joblib import Parallel, delayed
 from numba import njit
 
-from aeon.transformations.base import BaseTransformer
+from aeon.transformations.panel.base import BaseCollectionTransformer
 from aeon.utils.validation import check_n_jobs
 
 
-class Catch22(BaseTransformer):
+class Catch22(BaseCollectionTransformer):
     """Canonical Time-series Characteristics (Catch22).
 
     Overview: Input n series with d dimensions of length m
diff --git a/aeon/transformations/panel/channel_selection.py b/aeon/transformations/panel/channel_selection.py
index 87a1c8f4..c7d15bd2 100644
--- a/aeon/transformations/panel/channel_selection.py
+++ b/aeon/transformations/panel/channel_selection.py
@@ -18,7 +18,7 @@ from scipy.stats import median_abs_deviation
 from sklearn.preprocessing import LabelEncoder
 
 from aeon.distances import distance as aeon_distance
-from aeon.transformations.base import BaseTransformer
+from aeon.transformations.panel.base import BaseCollectionTransformer
 
 
 def _detect_knee_point(values: List[float], indices: List[int]) -> List[int]:
@@ -226,7 +226,7 @@ class ClassPrototype:
         return (prototypes, le.classes_)
 
 
-class ElbowClassSum(BaseTransformer):
+class ElbowClassSum(BaseCollectionTransformer):
     """Elbow Class Sum (ECS) transformer to select a subset of channels/variables.
 
     Overview: From the input of multivariate time series data, create a distance
@@ -289,10 +289,6 @@ class ElbowClassSum(BaseTransformer):
     """
 
     _tags = {
-        "scitype:transform-input": "Series",
-        # what is the scitype of X: Series, or Panel
-        # "scitype:transform-output": "Primitives",
-        # what scitype is returned: Primitives, Series, Panel
         "scitype:instancewise": True,  # is this an instance-wise transform?
         "univariate-only": False,  # can the transformer handle multivariate X?
         "X_inner_mtype": "numpy3D",  # which mtypes do _fit/_predict support for X?
@@ -369,7 +365,7 @@ class ElbowClassSum(BaseTransformer):
         return X[:, self.channels_selected_idx]
 
 
-class ElbowClassPairwise(BaseTransformer):
+class ElbowClassPairwise(BaseCollectionTransformer):
     """Elbow Class Pairwise (ECP) transformer to select a subset of channels.
 
     Overview: From the input of multivariate time series data, create a distance
@@ -432,9 +428,6 @@ class ElbowClassPairwise(BaseTransformer):
     """
 
     _tags = {
-        "scitype:transform-input": "Series",
-        # what is the scitype of X: Series, or Panel
-        # "scitype:transform-output": "Primitives",
         # what scitype is returned: Primitives, Series, Panel
         "scitype:instancewise": True,  # is this an instance-wise transform?
         "univariate-only": False,  # can the transformer handle multivariate X?
diff --git a/aeon/transformations/panel/dictionary_based/_sfa.py b/aeon/transformations/panel/dictionary_based/_sfa.py
index c2ec2293..9d72af56 100644
--- a/aeon/transformations/panel/dictionary_based/_sfa.py
+++ b/aeon/transformations/panel/dictionary_based/_sfa.py
@@ -17,13 +17,19 @@ from numba import NumbaTypeSafetyWarning, njit, types
 from numba.typed import Dict
 from sklearn.feature_selection import f_classif
 from sklearn.preprocessing import KBinsDiscretizer
-from sklearn.tree import DecisionTreeClassifier
+from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
 
 from aeon.transformations.base import BaseTransformer
 from aeon.utils.validation.panel import check_X
 
 # The binning methods to use: equi-depth, equi-width, information gain or kmeans
-binning_methods = {"equi-depth", "equi-width", "information-gain", "kmeans"}
+binning_methods = {
+    "equi-depth",
+    "equi-width",
+    "information-gain",
+    "information-gain-mae",
+    "kmeans",
+}
 
 
 class SFA(BaseTransformer):
@@ -53,8 +59,8 @@ class SFA(BaseTransformer):
     norm:                boolean, default = False
         mean normalise words by dropping first fourier coefficient
 
-    binning_method:      {"equi-depth", "equi-width", "information-gain", "kmeans"},
-                            default="equi-depth"
+    binning_method:      {"equi-depth", "equi-width", "information-gain",
+        "information-gain-mae", "kmeans"}, default="equi-depth"
         the binning method used to derive the breakpoints.
 
     anova:               boolean, default = False
@@ -123,6 +129,7 @@ class SFA(BaseTransformer):
         use_fallback_dft=False,
         typed_dict=False,
         n_jobs=1,
+        random_state=None,
     ):
         self.words = []
         self.breakpoints = []
@@ -165,6 +172,7 @@ class SFA(BaseTransformer):
         self.typed_dict = typed_dict
 
         self.n_jobs = n_jobs
+        self.random_state = random_state
 
         self.n_instances = 0
         self.series_length = 0
@@ -195,7 +203,10 @@ class SFA(BaseTransformer):
         if self.word_length < 1:
             raise ValueError("Word length must be an integer greater than 1")
 
-        if self.binning_method == "information-gain" and y is None:
+        if (
+            self.binning_method == "information-gain"
+            or self.binning_method == "information-gain-mae"
+        ) and y is None:
             raise ValueError(
                 "Class values must be provided for information gain binning"
             )
@@ -419,6 +430,8 @@ class SFA(BaseTransformer):
 
         if self.binning_method == "information-gain":
             return self._igb(dft, y)
+        if self.binning_method == "information-gain-mae":
+            return self._igb_mae(dft, y)
         elif self.binning_method == "kmeans":
             return self._k_bins_discretizer(dft)
         else:
@@ -474,7 +487,26 @@ class SFA(BaseTransformer):
             criterion="entropy",
             max_depth=int(np.floor(np.log2(self.alphabet_size))),
             max_leaf_nodes=self.alphabet_size,
-            random_state=1,
+            random_state=self.random_state,
+        )
+
+        for i in range(self.word_length):
+            clf.fit(dft[:, i][:, None], y)
+            threshold = clf.tree_.threshold[clf.tree_.children_left != -1]
+            for bp in range(len(threshold)):
+                breakpoints[i][bp] = threshold[bp]
+            for bp in range(len(threshold), self.alphabet_size):
+                breakpoints[i][bp] = sys.float_info.max
+
+        return np.sort(breakpoints, axis=1)
+
+    def _igb_mae(self, dft, y):
+        breakpoints = np.zeros((self.word_length, self.alphabet_size))
+        clf = DecisionTreeRegressor(
+            criterion="friedman_mse",
+            max_depth=int(np.floor(np.log2(self.alphabet_size))),
+            max_leaf_nodes=self.alphabet_size,
+            random_state=self.random_state,
         )
 
         for i in range(self.word_length):
diff --git a/aeon/transformations/panel/dictionary_based/_sfa_fast.py b/aeon/transformations/panel/dictionary_based/_sfa_fast.py
index 60e40b52..d32a1a61 100644
--- a/aeon/transformations/panel/dictionary_based/_sfa_fast.py
+++ b/aeon/transformations/panel/dictionary_based/_sfa_fast.py
@@ -26,14 +26,21 @@ from numba.typed import Dict
 from scipy.sparse import csr_matrix
 from sklearn.feature_selection import chi2, f_classif
 from sklearn.preprocessing import KBinsDiscretizer
-from sklearn.tree import DecisionTreeClassifier
+from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
 from sklearn.utils import check_random_state
 
 from aeon.transformations.base import BaseTransformer
 from aeon.utils.validation.panel import check_X
 
 # The binning methods to use: equi-depth, equi-width, information gain or kmeans
-binning_methods = {"equi-depth", "equi-width", "information-gain", "kmeans", "quantile"}
+binning_methods = {
+    "equi-depth",
+    "equi-width",
+    "information-gain",
+    "information-gain-mae",
+    "kmeans",
+    "quantile",
+}
 
 simplefilter(action="ignore", category=NumbaPendingDeprecationWarning)
 simplefilter(action="ignore", category=NumbaTypeSafetyWarning)
@@ -66,9 +73,8 @@ class SFAFast(BaseTransformer):
         norm:                boolean, default = False
             mean normalise words by dropping first fourier coefficient
 
-        binning_method:      {"equi-depth", "equi-width", "information-gain", "kmeans",
-                              "quantile"},
-                             default="equi-depth"
+        binning_method:      {"equi-depth", "equi-width", "information-gain",
+            "information-gain-mae", "kmeans"}, default="equi-depth"
             the binning method used to derive the breakpoints.
 
         anova:               boolean, default = False
@@ -238,7 +244,10 @@ class SFAFast(BaseTransformer):
         if self.alphabet_size < 2:
             raise ValueError("Alphabet size must be an integer greater than 2")
 
-        if self.binning_method == "information-gain" and y is None:
+        if (
+            self.binning_method == "information-gain"
+            or self.binning_method == "information-gain-mae"
+        ) and y is None:
             raise ValueError(
                 "Class values must be provided for information gain binning"
             )
@@ -532,6 +541,8 @@ class SFAFast(BaseTransformer):
 
         if self.binning_method == "information-gain":
             return self._igb(dft, y)
+        if self.binning_method == "information-gain-mae":
+            return self._igb_mae(dft, y)
         elif self.binning_method == "kmeans" or self.binning_method == "quantile":
             return self._k_bins_discretizer(dft)
         else:
@@ -587,7 +598,7 @@ class SFAFast(BaseTransformer):
             criterion="entropy",
             max_depth=np.uint32(np.log2(self.alphabet_size)),
             max_leaf_nodes=self.alphabet_size,
-            random_state=1,
+            random_state=self.random_state,
         )
 
         for i in range(self.word_length_actual):
@@ -600,6 +611,25 @@ class SFAFast(BaseTransformer):
 
         return np.sort(breakpoints, axis=1)
 
+    def _igb_mae(self, dft, y):
+        breakpoints = np.zeros((self.word_length, self.alphabet_size))
+        clf = DecisionTreeRegressor(
+            criterion="friedman_mse",
+            max_depth=int(np.floor(np.log2(self.alphabet_size))),
+            max_leaf_nodes=self.alphabet_size,
+            random_state=self.random_state,
+        )
+
+        for i in range(self.word_length):
+            clf.fit(dft[:, i][:, None], y)
+            threshold = clf.tree_.threshold[clf.tree_.children_left != -1]
+            for bp in range(len(threshold)):
+                breakpoints[i][bp] = threshold[bp]
+            for bp in range(len(threshold), self.alphabet_size):
+                breakpoints[i][bp] = sys.float_info.max
+
+        return np.sort(breakpoints, axis=1)
+
     def _shorten_bags(self, word_len, y):
         if self.save_words is False:
             raise ValueError(
diff --git a/aeon/transformations/panel/random_intervals.py b/aeon/transformations/panel/random_intervals.py
index f23fade4..d825a316 100644
--- a/aeon/transformations/panel/random_intervals.py
+++ b/aeon/transformations/panel/random_intervals.py
@@ -12,11 +12,11 @@ import pandas as pd
 from sklearn.utils import check_random_state
 
 from aeon.base._base import _clone_estimator
-from aeon.transformations.base import BaseTransformer
+from aeon.transformations.panel.base import BaseCollectionTransformer
 from aeon.transformations.series.summarize import SummaryTransformer
 
 
-class RandomIntervals(BaseTransformer):
+class RandomIntervals(BaseCollectionTransformer):
     """Random interval feature transformer.
 
     Extracts intervals with random length, position and dimension from series in fit.
@@ -47,7 +47,6 @@ class RandomIntervals(BaseTransformer):
     """
 
     _tags = {
-        "scitype:transform-input": "Series",
         "scitype:transform-output": "Primitives",
         "scitype:instancewise": True,
         "X_inner_mtype": "numpy3D",
diff --git a/aeon/transformations/panel/shapelet_transform.py b/aeon/transformations/panel/shapelet_transform.py
index b873203f..3e867ea9 100644
--- a/aeon/transformations/panel/shapelet_transform.py
+++ b/aeon/transformations/panel/shapelet_transform.py
@@ -22,7 +22,7 @@ from sklearn import preprocessing
 from sklearn.utils import check_random_state
 from sklearn.utils.multiclass import class_distribution
 
-from aeon.transformations.base import BaseTransformer
+from aeon.transformations.panel.base import BaseCollectionTransformer
 from aeon.utils.numba.general import z_normalise_series
 from aeon.utils.validation import check_n_jobs
 
@@ -88,7 +88,7 @@ class _ShapeletPQ:
         return self._queue
 
 
-class ShapeletTransform(BaseTransformer):
+class ShapeletTransform(BaseCollectionTransformer):
     """Shapelet Transform.
 
     The original shapelet transform, as described in [1,2]. The transform performs a
@@ -127,11 +127,11 @@ class ShapeletTransform(BaseTransformer):
 
     _tags = {
         "scitype:transform-output": "Primitives",
-        "X_inner_mtype": "numpy3D",  # which mtypes do _fit/_predict support for X?
-        "y_inner_mtype": "numpy1D",  # and for y?
-        "requires_y": True,
-        "univariate-only": True,
         "fit_is_empty": False,
+        "univariate-only": True,
+        "X_inner_mtype": "numpy3D",
+        "y_inner_mtype": "numpy1D",
+        "requires_y": True,
     }
 
     def __init__(
@@ -151,7 +151,8 @@ class ShapeletTransform(BaseTransformer):
         self.remove_self_similar = remove_self_similar
         self.predefined_ig_rejection_level = 0.05
         self.shapelets = None
-        super(ShapeletTransform, self).__init__(_output_convert=False)
+
+        super(ShapeletTransform, self).__init__()
 
     def _fit(self, X, y=None):
         """Fit the shapelet transform to a specified X and y.
@@ -850,7 +851,7 @@ class ShapeletTransform(BaseTransformer):
         return sum_dist
 
 
-class RandomShapeletTransform(BaseTransformer):
+class RandomShapeletTransform(BaseCollectionTransformer):
     """Random Shapelet Transform.
 
     Implementation of the binary shapelet transform along the lines of [1]_[2]_, with
@@ -1019,7 +1020,7 @@ class RandomShapeletTransform(BaseTransformer):
         self._class_dictionary = {}
         self._sorted_indicies = []
 
-        super(RandomShapeletTransform, self).__init__(_output_convert=False)
+        super(RandomShapeletTransform, self).__init__()
 
     def _fit(self, X, y=None):
         """Fit the shapelet transform to a specified X and y.
diff --git a/aeon/transformations/panel/tsfresh.py b/aeon/transformations/panel/tsfresh.py
index bf536796..0f6a8a4d 100644
--- a/aeon/transformations/panel/tsfresh.py
+++ b/aeon/transformations/panel/tsfresh.py
@@ -1,27 +1,27 @@
 # -*- coding: utf-8 -*-
-"""tsfresh interface class."""
 # copyright: aeon developers, BSD-3-Clause License (see LICENSE file)
+"""tsfresh interface class."""
 
 __author__ = ["AyushmaanSeth", "mloning", "Alwin Wang", "MatthewMiddlehurst"]
 __all__ = ["TSFreshFeatureExtractor", "TSFreshRelevantFeatureExtractor"]
 
 from aeon.datatypes._panel._convert import from_3d_numpy_to_long
-from aeon.transformations.base import BaseTransformer
+from aeon.transformations.panel.base import BaseCollectionTransformer
 from aeon.utils.validation import check_n_jobs
 from aeon.utils.validation._dependencies import _check_soft_dependencies
 
 _check_soft_dependencies("tsfresh", severity="warning")
 
 
-class _TSFreshFeatureExtractor(BaseTransformer):
+class _TSFreshFeatureExtractor(BaseCollectionTransformer):
     """Base adapter class for tsfresh transformations."""
 
     _tags = {
         "scitype:transform-output": "Primitives",
-        "scitype:instancewise": True,
+        "fit_is_empty": True,
         "X_inner_mtype": "numpy3D",
         "y_inner_mtype": "None",
-        "fit_is_empty": True,
+        "scitype:instancewise": True,
         "python_dependencies": "tsfresh",
     }
 
@@ -442,12 +442,11 @@ class TSFreshRelevantFeatureExtractor(_TSFreshFeatureExtractor):
     """
 
     _tags = {
-        "scitype:instancewise": False,  # is this an instance-wise transform?
-        "requires_y": True,  # does y need to be passed in fit?
-        "X_inner_mtype": "nested_univ",  # which mtypes do _fit/_predict support for X?
+        "scitype:instancewise": False,
+        "requires_y": True,
+        "X_inner_mtype": "nested_univ",
         "y_inner_mtype": "pd_Series_Table",
-        # which mtypes do _fit/_predict support for X?
-        "fit_is_empty": False,  # is fit empty and can be skipped? Yes = True
+        "fit_is_empty": False,
     }
 
     def __init__(


Test patch:
diff --git a/aeon/classification/deep_learning/tests/test_deep_base.py b/aeon/classification/deep_learning/tests/test_deep_base.py
index 24464eb9..0fa74ce3 100644
--- a/aeon/classification/deep_learning/tests/test_deep_base.py
+++ b/aeon/classification/deep_learning/tests/test_deep_base.py
@@ -1,14 +1,12 @@
 # -*- coding: utf-8 -*-
 """Unit tests for classifier deep learning base class functionality."""
 
-import pickle
-
 import pytest
 
 from aeon.classification.deep_learning.base import BaseDeepClassifier
 from aeon.utils.validation._dependencies import _check_soft_dependencies
 
-__author__ = ["achieveordie"]
+__author__ = ["achieveordie", "hadifawaz1999"]
 
 
 class _DummyDeepClassifierEmpty(BaseDeepClassifier):
@@ -24,67 +22,15 @@ class _DummyDeepClassifierEmpty(BaseDeepClassifier):
         return self
 
 
-class _DummyDeepClassifierFull(BaseDeepClassifier):
-    """Dummy Deep Classifier to test serialization capabilities."""
-
-    def __init__(
-        self,
-        optimizer,
-    ):
-        super(_DummyDeepClassifierFull, self).__init__()
-        self.optimizer = optimizer
-
-    def build_model(self, input_shape, n_classes, **kwargs):
-        return None
-
-    def _fit(self, X, y):
-        return self
-
-
-@pytest.mark.skipif(
-    not _check_soft_dependencies("tensorflow", severity="none"),
-    reason="skip test if required soft dependency not available",
-)
-def test_deep_estimator_empty():
-    """Check if serialization works for empty dummy."""
-    empty_dummy = _DummyDeepClassifierEmpty()
-    serialized_empty = pickle.dumps(empty_dummy)
-    deserialized_empty = pickle.loads(serialized_empty)
-    assert empty_dummy.__dict__ == deserialized_empty.__dict__
-
-
 @pytest.mark.skipif(
     not _check_soft_dependencies("tensorflow", severity="none"),
     reason="skip test if required soft dependency not available",
 )
-@pytest.mark.parametrize("optimizer", [None, "adam", "object-adamax"])
-def test_deep_estimator_full(optimizer):
-    """Check if serialization works for full dummy."""
-    from tensorflow.keras.optimizers import Adamax, Optimizer, serialize
-
-    if optimizer == "object-adamax":
-        optimizer = Adamax()
-
-    full_dummy = _DummyDeepClassifierFull(optimizer)
-    serialized_full = pickle.dumps(full_dummy)
-    deserialized_full = pickle.loads(serialized_full)
-
-    if isinstance(optimizer, Optimizer):
-        # assert same configuration of optimizer
-        assert serialize(full_dummy.__dict__["optimizer"]) == serialize(
-            deserialized_full.__dict__["optimizer"]
-        )
-        assert serialize(full_dummy.optimizer) == serialize(deserialized_full.optimizer)
-
-        # assert weights of optimizers are same
-        assert (
-            full_dummy.optimizer.variables() == deserialized_full.optimizer.variables()
-        )
-
-        # remove optimizers from both to do full dict check,
-        # since two different objects
-        del full_dummy.__dict__["optimizer"]
-        del deserialized_full.__dict__["optimizer"]
-
-    # check if components are same
-    assert full_dummy.__dict__ == deserialized_full.__dict__
+def test_dummy_deep_classifier():
+    import numpy as np
+
+    dumpy_deep_clf = _DummyDeepClassifierEmpty()
+    dumpy_deep_clf.build_model(input_shape=(10, 1), n_classes=2)
+    dumpy_deep_clf.fit(
+        X=np.random.normal(size=(10, 1, 10)), y=np.random.choice([0, 1], size=(10,))
+    )
diff --git a/aeon/transformations/tests/test_base.py b/aeon/transformations/tests/test_base.py
index 256e8e1c..6e45e11b 100644
--- a/aeon/transformations/tests/test_base.py
+++ b/aeon/transformations/tests/test_base.py
@@ -430,6 +430,33 @@ def test_panel_in_primitives_out_not_supported_fit_in_transform():
     assert len(Xt) == 7
 
 
+def test_vectorize_reconstruct_correct_hierarchy():
+    """Tests correct transform return index in hierarchical case for primitives output.
+
+    Tests that the row index is as expected if rows are vectorized over,
+    by a transform that returns Primitives.
+    The row index of transform return should be identical to the input,
+    with temporal index level removed
+
+    Raises
+    ------
+    AssertionError if output index is not as expected.
+    """
+    from aeon.utils._testing.hierarchical import _make_hierarchical
+
+    # hierarchical data with 2 variables and 2 levels
+    X = _make_hierarchical(n_columns=2)
+
+    trafo = _DummyFour()
+
+    # this produces a pandas DataFrame with more rows and columns
+    # rows should correspond to different instances in X
+    Xt = trafo.fit_transform(X)
+
+    # check that Xt.index is the same as X.index with time level dropped and made unique
+    assert (X.index.droplevel(-1).unique() == Xt.index).all()
+
+
 class _DummyFive(BaseTransformer):
     _tags = {
         "scitype:transform-input": "Series",
diff --git a/aeon/utils/_testing/scenarios_transformers.py b/aeon/utils/_testing/scenarios_transformers.py
index cbb90e56..fba56cd9 100644
--- a/aeon/utils/_testing/scenarios_transformers.py
+++ b/aeon/utils/_testing/scenarios_transformers.py
@@ -8,6 +8,7 @@ __author__ = ["fkiraly"]
 
 __all__ = ["scenarios_transformers"]
 
+import inspect
 from copy import deepcopy
 from inspect import isclass
 
@@ -16,6 +17,7 @@ import pandas as pd
 
 from aeon.base import BaseObject
 from aeon.datatypes import mtype_to_scitype
+from aeon.transformations.panel.base import BaseCollectionTransformer
 from aeon.utils._testing.collection import _make_classification_y, _make_collection_X
 from aeon.utils._testing.estimator_checks import _make_primitives, _make_tabular_X
 from aeon.utils._testing.forecasting import _make_series
@@ -65,6 +67,12 @@ class TransformerTestScenario(TestScenario, BaseObject):
         X_scitype = self.get_tag("X_scitype")
         y_scitype = self.get_tag("y_scitype", None, raise_error=False)
 
+        if (
+            isinstance(obj, BaseCollectionTransformer)
+            or (inspect.isclass(obj) and issubclass(obj, BaseCollectionTransformer))
+        ) and X_scitype != "Panel":
+            return False
+
         # if transformer requires y, the scenario also must pass y
         has_y = self.get_tag("has_y")
         if not has_y and get_tag(obj, "requires_y"):