Repository: aeon-toolkit/aeon (python)

Issue:
[ENH] support unequal length for RDST
### Describe the feature or idea you want to propose

Implement support for unequal length input for RDST classifier and transformer. Related to the improvement introduced in #820 

### Describe your proposed solution

Replicate the changes from #820 and check for additional constraints on the shapelet generation of RDST. 

### Describe alternatives you've considered, if relevant

_No response_

### Additional context

_No response_

Pull request:
[ENH] Support unequal length for rdst
Add unequal length support for RDST transformer and classifier

#### Reference Issues/PRs

Fixes #872 

#### What does this implement/fix? Explain your changes.

For the non-numba code, use len(X) instead of shape[0] to be compatible with lists, and check for minimum length requirements in the input (i.e. all inputs must be longer than shapelets).

For the numba code, use the minimum length in fit to initialize dilations, and create a boolean mask of the size of the longer series for alpha similarity sampling. The mask is then set to false after each series length so we cannot sample points that do not exist.

#### Does your contribution introduce a new dependency? If yes, which one?

No additional dependency.

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..56d5c604
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,34 @@
+version: 2
+updates:
+  # update GitHub actions versions
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: weekly
+      time: "01:00"
+    commit-message:
+      prefix: "[MNT]"
+    labels:
+      - "maintenance"
+      - "no changelog"
+    groups:
+      github-actions:
+        patterns:
+          - "*"
+
+  # update Python dependencies
+  - package-ecosystem: pip
+    directory: /
+    schedule:
+      interval: weekly
+      time: "01:00"
+    commit-message:
+      prefix: "[MNT]"
+    labels:
+      - "maintenance"
+      - "full pytest actions"
+      - "no changelog"
+    groups:
+      python-packages:
+        patterns:
+          - "*"
diff --git a/.github/workflows/pr_opened.yml b/.github/workflows/pr_opened.yml
index 6b9026fe..74aaa608 100644
--- a/.github/workflows/pr_opened.yml
+++ b/.github/workflows/pr_opened.yml
@@ -25,18 +25,18 @@ jobs:
       - name: Install PyGithub
         run: pip install -Uq PyGithub
 
-      - name: Label pull request
-        id: label-pr
-        run: python build_tools/pr_labeler.py
-        env:
-          CONTEXT_GITHUB: ${{ toJson(github) }}
-
       - uses: actions/create-github-app-token@v1
         id: app-token
         with:
           app-id: ${{ vars.PR_APP_ID }}
           private-key: ${{ secrets.PR_APP_KEY }}
 
+      - name: Label pull request
+        id: label-pr
+        run: python build_tools/pr_labeler.py ${{ steps.app-token.outputs.token }}
+        env:
+          CONTEXT_GITHUB: ${{ toJson(github) }}
+
       - name: Write pull request comment
         run: python build_tools/pr_open_commenter.py ${{ steps.app-token.outputs.token }} ${{ steps.label-pr.outputs.title-labels }} ${{ steps.label-pr.outputs.title-labels-new }} ${{ steps.label-pr.outputs.content-labels }} ${{ steps.label-pr.outputs.content-labels-status }}
         env:
diff --git a/aeon/classification/shapelet_based/_rdst.py b/aeon/classification/shapelet_based/_rdst.py
index f57ab249..cad822cd 100644
--- a/aeon/classification/shapelet_based/_rdst.py
+++ b/aeon/classification/shapelet_based/_rdst.py
@@ -79,10 +79,6 @@ class RDSTClassifier(BaseClassifier):
         The number of unique classes in the training set.
     fit_time_  : int
         The time (in milliseconds) for ``fit`` to run.
-    n_instances_ : int
-        The number of train cases in the training set.
-    n_dims_ : int
-        The number of dimensions per case in the training set.
     series_length_ : int
         The length of each series in the training set.
     transformed_data_ : list of shape (n_estimators) of ndarray
@@ -120,7 +116,9 @@ class RDSTClassifier(BaseClassifier):
 
     _tags = {
         "capability:multivariate": True,
+        "capability:unequal_length": True,
         "capability:multithreading": True,
+        "X_inner_type": ["np-list", "numpy3D"],
         "non-deterministic": True,  # due to random_state bug in MacOS #324
         "algorithm_type": "shapelet",
     }
@@ -149,10 +147,6 @@ class RDSTClassifier(BaseClassifier):
         self.save_transformed_data = save_transformed_data
         self.random_state = random_state
         self.n_jobs = n_jobs
-
-        self.n_instances_ = 0
-        self.n_dims_ = 0
-        self.series_length_ = 0
         self.transformed_data_ = []
 
         self._transformer = None
@@ -180,8 +174,6 @@ class RDSTClassifier(BaseClassifier):
         Changes state by creating a fitted model that updates attributes
         ending in "_".
         """
-        self.n_instances_, self.n_dims_, self.series_length_ = X.shape
-
         self._transformer = RandomDilatedShapeletTransform(
             max_shapelets=self.max_shapelets,
             shapelet_lengths=self.shapelet_lengths,
@@ -250,9 +242,9 @@ class RDSTClassifier(BaseClassifier):
         if callable(m):
             return self._estimator.predict_proba(X_t)
         else:
-            dists = np.zeros((X.shape[0], self.n_classes_))
+            dists = np.zeros((len(X), self.n_classes_))
             preds = self._estimator.predict(X_t)
-            for i in range(0, X.shape[0]):
+            for i in range(0, len(X)):
                 dists[i, np.where(self.classes_ == preds[i])] = 1
             return dists
 
diff --git a/aeon/datasets/_data_writers.py b/aeon/datasets/_data_writers.py
index 05de0282..0618ad5e 100644
--- a/aeon/datasets/_data_writers.py
+++ b/aeon/datasets/_data_writers.py
@@ -203,7 +203,7 @@ def write_results_to_uea_format(
     third_line : str
         summary performance information (see comment below)
     """
-    if len(y_true) != len(y_pred):
+    if y_true is not None and len(y_true) != len(y_pred):
         raise IndexError(
             "The number of predicted values is not the same as the "
             "number of actual class values"
@@ -287,11 +287,7 @@ def _write_header(
     extension=None,
 ):
     if class_labels is not None and regression:
-        raise ValueError(
-            "Cannot have class_labels and targetlabel. If the problem "
-            "is classification, add class_labels. If regression, "
-            "set targetlabel to true."
-        )
+        raise ValueError("Cannot have class_labels true for a regression problem")
     # create path if it does not exist
     dir = f"{str(path)}/"
     try:
diff --git a/aeon/transformations/collection/shapelet_based/_dilated_shapelet_transform.py b/aeon/transformations/collection/shapelet_based/_dilated_shapelet_transform.py
index db9b3cd5..64d501e9 100644
--- a/aeon/transformations/collection/shapelet_based/_dilated_shapelet_transform.py
+++ b/aeon/transformations/collection/shapelet_based/_dilated_shapelet_transform.py
@@ -97,6 +97,10 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
             - normalization parameter
             - mean parameter
             - standard deviation parameter
+    max_shapelet_length_ : int
+        The maximum actual shapelet length fitted to train data.
+    min_series_length_ : int
+        The minimum length of series in train data.
 
     Notes
     -----
@@ -132,6 +136,9 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
     _tags = {
         "output_data_type": "Tabular",
         "capability:multivariate": True,
+        "capability:unequal_length": True,
+        "X_inner_type": ["np-list", "numpy3D"],
+        "y_inner_type": "numpy1D",
     }
 
     def __init__(
@@ -173,12 +180,14 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
         self : RandomDilatedShapeletTransform
             This estimator.
         """
+        # Numba does not yet support new random numpy API with generator
         if isinstance(self.random_state, int):
             self._random_state = np.int32(self.random_state)
         else:
             self._random_state = np.int32(np.random.randint(0, 2**31))
 
-        self.n_instances, self.n_channels, self.series_length = X.shape
+        n_instances_ = len(X)
+        self.min_series_length_ = min([X[i].shape[1] for i in range(n_instances_)])
 
         self._check_input_params()
 
@@ -186,15 +195,15 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
         set_num_threads(self._n_jobs)
 
         if y is None:
-            y = np.zeros(self.n_instances)
+            y = np.zeros(n_instances_)
         else:
             y = LabelEncoder().fit_transform(y)
 
-        if any(self.shapelet_lengths_ > self.series_length):
+        if any(self.shapelet_lengths_ > self.min_series_length_):
             raise ValueError(
                 "Shapelets lengths can't be superior to input length,",
                 "but got shapelets_lengths = {} ".format(self.shapelet_lengths_),
-                "with input length = {}".format(self.series_length),
+                "with an input length = {}".format(self.min_series_length_),
             )
         self.shapelets_ = random_dilated_shapelet_extraction(
             X,
@@ -218,6 +227,12 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
                 "you have NaN values in your data. We do not currently support NaN "
                 "values for shapelet transformation."
             )
+
+        # Shapelet "length" is length-1 times dilation
+        self.max_shapelet_length_ = np.max(
+            (self.shapelets_[1] - 1) * self.shapelets_[2]
+        )
+
         return self
 
     def _transform(self, X, y=None):
@@ -233,6 +248,14 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
         X_new : 2D np.array of shape = (n_instances, 3*n_shapelets)
             The transformed data.
         """
+        for i in range(0, len(X)):
+            if X[i].shape[1] < self.max_shapelet_length_:
+                raise ValueError(
+                    "The shortest series in transform is smaller than "
+                    "the min shapelet length, pad to min length prior to "
+                    "calling transform."
+                )
+
         X_new = dilated_shapelet_transform(X, self.shapelets_)
         if np.isinf(X_new).any() or np.isnan(X_new).any():
             warnings.warn(
@@ -257,7 +280,7 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
         self.shapelet_lengths_ = self.shapelet_lengths
         if self.shapelet_lengths_ is None:
             self.shapelet_lengths_ = np.array(
-                [min(max(2, self.series_length // 2), 11)]
+                [min(max(2, self.min_series_length_ // 2), 11)]
             )
         else:
             if not isinstance(self.shapelet_lengths_, (list, tuple, np.ndarray)):
@@ -277,14 +300,14 @@ class RandomDilatedShapeletTransform(BaseCollectionTransformer):
                     self.shapelet_lengths_ >= 2
                 ]
 
-            if not np.all(self.shapelet_lengths_ <= self.series_length):
+            if not np.all(self.shapelet_lengths_ <= self.min_series_length_):
                 warnings.warn(
                     "All the values in 'shapelet_lengths' must be lower or equal to"
                     + "the series length. Shapelet lengths above it will be ignored.",
                     stacklevel=2,
                 )
                 self.shapelet_lengths_ = self.shapelet_lengths_[
-                    self.shapelet_lengths_ <= self.series_length
+                    self.shapelet_lengths_ <= self.min_series_length_
                 ]
 
             if len(self.shapelet_lengths_) == 0:
@@ -425,6 +448,24 @@ def _init_random_shapelet_params(
     return values, lengths, dilations, threshold, normalize, means, stds
 
 
+@njit(cache=True)
+def _get_admissible_sampling_point(current_mask):
+    n_instances = len(current_mask)
+    # Count the number of admissible points per sample as cumsum
+    n_admissible_points = 0
+    for i in range(n_instances):
+        n_admissible_points += current_mask[i].shape[0]
+    if n_admissible_points > 0:
+        idx_choice = np.random.choice(n_admissible_points)
+        for i in range(n_instances):
+            _new_val = idx_choice - current_mask[i].shape[0]
+            if _new_val < 0 and current_mask[i].shape[0] > 0:
+                return i, idx_choice
+            idx_choice = _new_val
+    else:
+        return -1, -1
+
+
 @njit(fastmath=True, cache=True, parallel=True)
 def random_dilated_shapelet_extraction(
     X,
@@ -492,8 +533,13 @@ def random_dilated_shapelet_extraction(
         - stds : array, shape (max_shapelets, n_channels)
             Standard deviation of the shapelets
     """
-    n_instances, n_channels, series_length = X.shape
-
+    n_instances = len(X)
+    n_channels = X[0].shape[0]
+    series_lengths = np.zeros(n_instances, dtype=np.int64)
+    for i in range(n_instances):
+        series_lengths[i] = X[i].shape[1]
+    min_series_length = series_lengths.min()
+    max_series_length = series_lengths.max()
     # Fix the random seed
     set_numba_random_seed(seed)
 
@@ -512,7 +558,7 @@ def random_dilated_shapelet_extraction(
         proba_normalization,
         use_prime_dilations,
         n_channels,
-        series_length,
+        min_series_length,
     )
     # Get unique dilations to loop over
     unique_dil = np.unique(dilations)
@@ -521,7 +567,11 @@ def random_dilated_shapelet_extraction(
     # For each dilation, we can do in parallel
     for i_dilation in prange(n_dilations):
         # (2, _, _): Mask is different for normalized and non-normalized shapelets
-        alpha_mask = np.ones((2, n_instances, series_length), dtype=np.bool_)
+        alpha_mask = np.ones((2, n_instances, max_series_length), dtype=np.bool_)
+        for _i in range(n_instances):
+            # For the unequal length case, we scale the mask up and set to False
+            alpha_mask[:, _i, series_lengths[_i] :] = False
+
         id_shps = np.where(dilations == unique_dil[i_dilation])[0]
         min_len = min(lengths[id_shps])
         # For each shapelet id with this dilation
@@ -529,20 +579,16 @@ def random_dilated_shapelet_extraction(
             # Get shapelet params
             dilation = dilations[i_shp]
             length = lengths[i_shp]
-            norm = np.int32(normalize[i_shp])
-            dist_vect_shape = series_length - (length - 1) * dilation
-
+            norm = np.int_(normalize[i_shp])
             # Possible sampling points given self similarity mask
-            current_mask = alpha_mask[norm, :, :dist_vect_shape]
-            idx_mask = np.where(current_mask)
-
-            n_admissible_points = idx_mask[0].shape[0]
-            if n_admissible_points > 0:
-                # Choose a sample and a timestamp
-                idx_choice = np.random.choice(n_admissible_points)
-                idx_sample = idx_mask[0][idx_choice]
-                idx_timestamp = idx_mask[1][idx_choice]
-
+            current_mask = [
+                np.where(
+                    alpha_mask[norm, _i, : series_lengths[_i] - (length - 1) * dilation]
+                )[0]
+                for _i in range(n_instances)
+            ]
+            idx_sample, idx_timestamp = _get_admissible_sampling_point(current_mask)
+            if idx_sample >= 0:
                 # Update the mask in two directions from the sampling point
                 alpha_size = length - int(max(1, (1 - alpha_similarity) * min_len))
                 for j in range(alpha_size):
@@ -569,7 +615,6 @@ def random_dilated_shapelet_extraction(
                     _val = get_subsequence(
                         X[idx_sample], idx_timestamp, length, dilation
                     )
-
                 # Select another sample of the same class as the sample used to
                 loc_others = np.where(y == y[idx_sample])[0]
                 if loc_others.shape[0] > 1:
@@ -586,7 +631,6 @@ def random_dilated_shapelet_extraction(
                         X[id_test], length, dilation
                     )
                     X_subs = normalize_subsequences(X_subs, X_means, X_stds)
-
                 x_dist = compute_shapelet_dist_vector(X_subs, _val, length)
 
                 lower_bound = np.percentile(x_dist, threshold_percentiles[0])
@@ -648,7 +692,7 @@ def dilated_shapelet_transform(X, shapelets):
     """
     (values, lengths, dilations, threshold, normalize, means, stds) = shapelets
     n_shapelets = len(lengths)
-    n_instances, n_channels, series_length = X.shape
+    n_instances = len(X)
     n_ft = 3
 
     # (u_l * u_d , 2)
diff --git a/aeon/transformations/collection/shapelet_based/_shapelet_transform.py b/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
index a05fd3df..c7e9009a 100644
--- a/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
+++ b/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
@@ -90,7 +90,7 @@ class RandomShapeletTransform(BaseCollectionTransformer):
         The number of train cases.
     n_channels_ : int
         The number of dimensions per case.
-    max_fitted_shapelet_length_ : int
+    max_shapelet_length_ : int
         The maximum actual shapelet length fitted to train data.
     min_series_length_ : int
         The minimum length of series in train data.
@@ -351,7 +351,7 @@ class RandomShapeletTransform(BaseCollectionTransformer):
                 )
             )
         # find max shapelet length
-        self.max_fitted_shapelet_length_ = max(self.shapelets, key=lambda x: x[1])[1]
+        self.max_shapelet_length_ = max(self.shapelets, key=lambda x: x[1])[1]
 
     def _transform(self, X, y=None):
         """Transform X according to the extracted shapelets.
@@ -369,7 +369,7 @@ class RandomShapeletTransform(BaseCollectionTransformer):
         output = np.zeros((len(X), len(self.shapelets)))
 
         for i in range(0, len(X)):
-            if X[i].shape[1] < self.max_fitted_shapelet_length_:
+            if X[i].shape[1] < self.max_shapelet_length_:
                 raise ValueError(
                     "The shortest series in transform is smaller than "
                     "the min shapelet length, pad to min length prior to "
diff --git a/build_tools/pr_labeler.py b/build_tools/pr_labeler.py
index 3e00eb76..eed9ec4a 100644
--- a/build_tools/pr_labeler.py
+++ b/build_tools/pr_labeler.py
@@ -8,13 +8,14 @@ Based on the scikit-learn v1.3.1 label_title_regex.py script.
 import json
 import os
 import re
+import sys
 
 from github import Github
 
 context_dict = json.loads(os.getenv("CONTEXT_GITHUB"))
 
 repo = context_dict["repository"]
-g = Github(context_dict["token"])
+g = Github(sys.argv[1])
 repo = g.get_repo(repo)
 pr_number = context_dict["event"]["number"]
 pr = repo.get_pull(number=pr_number)
diff --git a/build_tools/pr_open_commenter.py b/build_tools/pr_open_commenter.py
index 6f226a4a..d23d66ff 100644
--- a/build_tools/pr_open_commenter.py
+++ b/build_tools/pr_open_commenter.py
@@ -97,8 +97,10 @@ pr.create_issue_comment(
 {title_labels_str}
 {content_labels_str}
 
-The "Checks" tab above or the panel below will show the status of our automated tests. From the panel below you can click on "Details" to the right of the check to see more information if there is a failure. If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR.
+The [Checks](https://github.com/aeon-toolkit/aeon/pull/{pr_number}/checks) tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.
 
-Don't hesitate to ask questions on the `aeon` Slack channel if you have any!
+If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR unless it is a draft.
+
+Don't hesitate to ask questions on the `aeon` [Slack](https://join.slack.com/t/aeon-toolkit/shared_invite/zt-22vwvut29-HDpCu~7VBUozyfL_8j3dLA) channel if you have any!
     """  # noqa
 )
diff --git a/sweep.yaml b/sweep.yaml
index 671c0f71..08422e7d 100644
--- a/sweep.yaml
+++ b/sweep.yaml
@@ -2,7 +2,7 @@ gha_enabled: True
 branch: "main"
 blocked_dirs: [".github/"]
 draft: False
-description: "aeon-toolkit/aeon is a Python project for machine learning algorithms using time series data. Write code that adheres to PEP8 and is formatted to the Black code style. The projects documentation is built using Sphinx and MyST, and unit testing is done using pytest."
+description: "aeon-toolkit/aeon is a Python project for machine learning algorithms using time series data.  We have generalised tests for all of our ML algorithms which are in a separate class. Write code that adheres to PEP8 and is formatted to the Black code style. The projects documentation is built using Sphinx and MyST, and unit testing is done using pytest."
 
 docs:
   scikit-learn: ["https://scikit-learn.org/stable/", "We use the scikit-learn framework for our machine learning algorithms."]
@@ -21,4 +21,5 @@ sandbox:
   check:
     - git add . && pre-commit run --files {file_path} || exit 0
 
-rules: []
+rules:
+  - ""


Test patch:
diff --git a/aeon/datasets/tests/test_data_loaders.py b/aeon/datasets/tests/test_data_loaders.py
index 38d62e2b..8b76dd58 100644
--- a/aeon/datasets/tests/test_data_loaders.py
+++ b/aeon/datasets/tests/test_data_loaders.py
@@ -6,6 +6,7 @@ __all__ = []
 
 import os
 import shutil
+import tempfile
 
 import numpy as np
 import pandas as pd
@@ -158,6 +159,29 @@ def test__load_header_info():
         assert meta_data["classlabel"]
         assert meta_data["class_values"][0] == "1"
         assert meta_data["class_values"][1] == "2"
+    WRONG_STRINGS = ["@data 55", "@missing 42, @classlabel True"]
+    count = 1
+    with tempfile.TemporaryDirectory() as tmp:
+        for name in WRONG_STRINGS:
+            problem_name = f"temp{count}.ts"
+            load_path = os.path.join(tmp, problem_name)
+            temp_file = open(load_path, "w", encoding="utf-8")
+            temp_file.write(name)
+            temp_file.close()
+            with open(load_path, "r", encoding="utf-8") as file:
+                with pytest.raises(IOError):
+                    _load_header_info(file)
+            count = count + 1
+        name = "@missing true \n @classlabel True 0 1"
+        problem_name = "temp_correct.ts"
+        load_path = os.path.join(tmp, problem_name)
+        temp_file = open(load_path, "w", encoding="utf-8")
+        temp_file.write(name)
+        temp_file.close()
+        with open(load_path, "r", encoding="utf-8") as file:
+            meta = _load_header_info(file)
+            assert meta["missing"] is True
+            assert meta["classlabel"] is True
 
 
 def test__load_data():
@@ -182,6 +206,38 @@ def test__load_data():
         meta_data["equallength"] = True
         with pytest.raises(IOError):
             X, y, _ = _load_data(file, meta_data)
+    WRONG_DATA = [
+        "1.0,2.0,3.0:1.0,2.0,3.0, 4.0:0",
+        "1.0,2.0,3.0:1.0,2.0,3.0, 4.0:0\n1.0,2.0,3.0,4.0:0",
+    ]
+    meta_data = {"classlabel": True, "class_values": [0, 1], "equallength": False}
+    count = 1
+    with tempfile.TemporaryDirectory() as tmp:
+        for data in WRONG_DATA:
+            problem_name = f"temp{count}.ts"
+            load_path = os.path.join(tmp, problem_name)
+            temp_file = open(load_path, "w", encoding="utf-8")
+            temp_file.write(data)
+            temp_file.close()
+            with open(load_path, "r", encoding="utf-8") as file:
+                with pytest.raises(IOError):
+                    _load_data(file, meta_data)
+            count = count + 1
+        meta_data = {
+            "classlabel": True,
+            "class_values": [0, 1],
+            "equallength": True,
+            "univariate": True,
+        }
+        data = "1.0,2.0,3.0:0.0\n 1.0,2.0,3.0:1.0\n 2.0,3.0,4.0:0"
+        problem_name = "tempCorrect.ts"
+        load_path = os.path.join(tmp, problem_name)
+        temp_file = open(load_path, "w", encoding="utf-8")
+        temp_file.write(data)
+        temp_file.close()
+        with open(load_path, "r", encoding="utf-8") as file:
+            X, y, meta_data = _load_data(file, meta_data)
+            assert isinstance(X, np.ndarray)
 
 
 @pytest.mark.parametrize("return_X_y", [True, False])
diff --git a/aeon/datasets/tests/test_data_writers.py b/aeon/datasets/tests/test_data_writers.py
index 2ba1d683..c9b5a799 100644
--- a/aeon/datasets/tests/test_data_writers.py
+++ b/aeon/datasets/tests/test_data_writers.py
@@ -8,7 +8,12 @@ import pandas as pd
 import pytest
 
 from aeon.datasets import load_from_tsfile, write_to_tsfile
-from aeon.datasets._data_writers import _write_dataframe_to_tsfile
+from aeon.datasets._data_writers import (
+    _write_data_to_tsfile,
+    _write_dataframe_to_tsfile,
+    _write_header,
+    write_results_to_uea_format,
+)
 from aeon.datasets._dataframe_loaders import load_from_tsfile_to_dataframe
 from aeon.utils._testing.collection import (
     make_3d_test_data,
@@ -101,3 +106,71 @@ def test_write_dataframe_to_ts(tsfile_writer):
         pd.testing.assert_frame_equal(newX, X)
         y2 = pd.Series(y)
         pd.testing.assert_series_equal(y, y2)
+
+
+def test_write_data_to_tsfile():
+    with pytest.raises(TypeError, match="Wrong input data type"):
+        write_to_tsfile("A string", "path")
+    with pytest.raises(TypeError, match="Data provided must be a ndarray or a list"):
+        _write_data_to_tsfile("AFC", "49", "undefeated")
+    X, _ = make_3d_test_data(n_cases=6, n_timepoints=10, n_channels=1)
+    y = np.ndarray([0, 1, 1, 0, 1])
+    with pytest.raises(
+        IndexError,
+        match="The number of cases in X does not match the number of values in y",
+    ):
+        _write_data_to_tsfile(X, "temp", "temp", y=y)
+
+
+def test_write_results_to_uea_format():
+    with tempfile.TemporaryDirectory() as tmp:
+        y_true = np.array([0, 1, 1, 0, 0])
+        y_pred = np.array([0, 1, 1, 0])
+        with pytest.raises(
+            IndexError, match="The number of predicted values is not the same"
+        ):
+            write_results_to_uea_format(
+                "HC", "Testy", y_pred=y_pred, y_true=y_true, output_path=tmp
+            )
+        y_true = np.array([0, 1, 1, 0])
+        write_results_to_uea_format(
+            "HC",
+            "Testy",
+            y_pred=y_pred,
+            output_path=tmp,
+            full_path=False,
+            split="TEST",
+            timing_type="seconds",
+            first_line_comment="Hello",
+        )
+
+        probs = [[0.0, 1.0], [0.0, 1.0], [0.0, 1.0], [0.0, 1.0]]
+        write_results_to_uea_format(
+            "HC",
+            "Testy2",
+            y_pred=y_pred,
+            y_true=y_true,
+            output_path=tmp,
+            full_path=False,
+            split="TEST",
+            timing_type="seconds",
+            first_line_comment="Hello",
+            predicted_probs=probs,
+        )
+
+
+def test__write_header():
+    with tempfile.TemporaryDirectory() as tmp:
+        problem_name = "header.csv"
+        with pytest.raises(
+            ValueError, match="Cannot have class_labels true for a regression problem"
+        ):
+            _write_header(tmp, problem_name, class_labels=True, regression=True)
+    _write_header(
+        tmp,
+        problem_name,
+        suffix="_TRAIN",
+        extension=".csv",
+        comment="Hello",
+        regression=True,
+    )
diff --git a/aeon/datasets/tests/test_single_problem_loaders.py b/aeon/datasets/tests/test_single_problem_loaders.py
index b5649b5b..7b40d661 100644
--- a/aeon/datasets/tests/test_single_problem_loaders.py
+++ b/aeon/datasets/tests/test_single_problem_loaders.py
@@ -1,4 +1,6 @@
 """Test single problem loaders with varying return types."""
+import os
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -13,9 +15,11 @@ from aeon.datasets import (  # Univariate; Unequal length; Multivariate
     load_osuleaf,
     load_plaid,
     load_solar,
+    load_tsf_to_dataframe,
     load_unit_test,
     load_unit_test_tsf,
 )
+from aeon.datasets._single_problem_loaders import MODULE
 
 UNIVARIATE_PROBLEMS = [
     load_acsf1,
@@ -77,6 +81,18 @@ def test_load_unit_test_tsf():
     assert not tuple[4]
 
 
+def test_basic_load_tsf_to_dataframe():
+    """Simple loader test."""
+
+    full_path = os.path.join(MODULE, "data", "UnitTest", "UnitTest_Tsf_Loader.tsf")
+    df, metadata = load_tsf_to_dataframe(full_path)
+    assert isinstance(df, pd.DataFrame)
+    assert metadata["frequency"] == "yearly"
+    assert metadata["forecast_horizon"] == 4
+    assert metadata["contain_missing_values"] is False
+    assert metadata["contain_equal_length"] is False
+
+
 def test_load_solar():
     solar = load_solar(api_version=None)
     assert type(solar) == pd.Series
diff --git a/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py b/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py
index 1c7ec43d..01df9c0f 100644
--- a/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py
+++ b/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py
@@ -14,7 +14,7 @@ def test_shapelet_transform():
     # Assert at least one shapelet per class when max_shapelets < n_labels
     assert len(rst.shapelets) == 4
     X, y = make_3d_test_data(
-        n_cases=3, n_timepoints=rst.max_fitted_shapelet_length_ - 1, n_labels=4
+        n_cases=3, n_timepoints=rst.max_shapelet_length_ - 1, n_labels=4
     )
     with pytest.raises(
         ValueError,