Repository: aeon-toolkit/aeon
Pull request #857 (2023-10-22 16:19:23)

[ENH] Shapelet transform changes
I inadvertently reverted the changes to STC from #820 when sorting out the precommit  errors. This puts them back in, see 

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

I have added the following labels to this PR based on the title: **[ $\\color{#FEF1BE}{\\textsf{enhancement}}$ ]**.
I have added the following labels to this PR based on the changes made: **[ $\\color{#BCAE15}{\\textsf{classification}}$, $\\color{#7E0206}{\\textsf{regression}}$, $\\color{#41A8F6}{\\textsf{transformations}}$ ]**. Feel free to change these if they do not properly represent the PR.

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.

Don't hesitate to ask questions on the `aeon` Slack channel if you have any!
    
sweep-ai[bot]: ## Apply [Sweep Rules](https://docs.sweep.dev/usage/config#rules) to your PR?
- [ ] **Apply:**  Comments should be instructive, such as inline and docstring comments. Remove any code that has been commented out.
- [ ] **Apply:**  Any clearly inefficient or redundant code can be optimized or refactored. Any improvements should not change the functionality of the code.
- [ ] **Apply:**  All public classes and functions should have a `numpydoc` style docstring. This should include a description of the class or function, the parameters, the class attributes or function return values, and a usage example for the class or function.
- [ ] **Apply:**  Update the relevant API page in `docs/api_reference/` when new public functions and classes are added and not included in the API documentation. For example, if a new function is added to `aeon/distances/`, a `sphinx.ext.autosummary` link should also be added to `docs/api_reference/distances.rst`. New sections in the page should not be created for individual functions and classes, add it to the most relevant existing one. Only add functions and classes which are not already in the relevant API page and avoid duplicate entries.

Patch:
diff --git a/aeon/transformations/collection/shapelet_based/_shapelet_transform.py b/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
index 0a012f20..70e8dc66 100644
--- a/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
+++ b/aeon/transformations/collection/shapelet_based/_shapelet_transform.py
@@ -84,14 +84,16 @@ class RandomShapeletTransform(BaseCollectionTransformer):
 
     Attributes
     ----------
-    n_classes : int
+    n_classes_ : int
         The number of classes.
-    n_instances : int
+    n_instances_ : int
         The number of train cases.
-    n_dims : int
+    n_channels_ : int
         The number of dimensions per case.
-    series_length : int
-        The length of each series.
+    max_fitted_shapelet_length_ : int
+        The maximum actual shapelet length fitted to train data.
+    min_series_length_ : int
+        The minimum length of series in train data.
     classes_ : list
         The classes labels.
     shapelets : list
@@ -140,6 +142,9 @@ class RandomShapeletTransform(BaseCollectionTransformer):
     _tags = {
         "output_data_type": "Tabular",
         "capability:multivariate": True,
+        "capability:unequal_length": True,
+        "X_inner_mtype": ["np-list", "numpy3D"],
+        "y_inner_type": "numpy1D",
         "requires_y": True,
     }
 
@@ -172,13 +177,14 @@ class RandomShapeletTransform(BaseCollectionTransformer):
         self.random_state = random_state
 
         # The following set in method fit
-        self.n_classes = 0
-        self.n_instances = 0
-        self.n_channels = 0
-        self.series_length = 0
+        self.n_classes_ = 0
+        self.n_instances_ = 0
+        self.n_channels_ = 0
+        self.min_series_length_ = 0
         self.classes_ = []
         self.shapelets = []
 
+        # Protected attributes
         self._n_shapelet_samples = n_shapelet_samples
         self._max_shapelets = max_shapelets
         self._max_shapelet_length = max_shapelet_length
@@ -190,12 +196,12 @@ class RandomShapeletTransform(BaseCollectionTransformer):
 
         super(RandomShapeletTransform, self).__init__()
 
-    def _fit(self, X, y=None):
+    def _fit(self, X, y):
         """Fit the shapelet transform to a specified X and y.
 
         Parameters
         ----------
-        X: np.ndarray shape (n_time_series, n_channels, series_length)
+        X: np.ndarray shape (n_time_series, n_channels, n_timepoints)
             The training input samples.
         y: array-like or list
             The class values for X.
@@ -208,32 +214,38 @@ class RandomShapeletTransform(BaseCollectionTransformer):
         self._n_jobs = check_n_jobs(self.n_jobs)
 
         self.classes_, self._class_counts = np.unique(y, return_counts=True)
-        self.n_classes = self.classes_.shape[0]
+        self.n_classes_ = self.classes_.shape[0]
         for index, classVal in enumerate(self.classes_):
             self._class_dictionary[classVal] = index
 
         le = preprocessing.LabelEncoder()
         y = le.fit_transform(y)
 
-        self.n_instances, self.n_channels, self.series_length = X.shape
+        self.n_instances_ = len(X)
+        self.n_channels_ = X[0].shape[0]
+        # Set series length to the minimum
+        self.min_series_length_ = X[0].shape[1]
+        for i in range(1, self.n_instances_):
+            if X[i].shape[1] < self.min_series_length_:
+                self.min_series_length_ = X[i].shape[1]
 
         if self.max_shapelets is None:
-            self._max_shapelets = min(10 * self.n_instances, 1000)
-        if self._max_shapelets < self.n_classes:
-            self._max_shapelets = self.n_classes
+            self._max_shapelets = min(10 * self.n_instances_, 1000)
+        if self._max_shapelets < self.n_classes_:
+            self._max_shapelets = self.n_classes_
         if self.max_shapelet_length is None:
-            self._max_shapelet_length = self.series_length
+            self._max_shapelet_length = self.min_series_length_
 
         time_limit = self.time_limit_in_minutes * 60
         start_time = time.time()
         fit_time = 0
 
-        max_shapelets_per_class = int(self._max_shapelets / self.n_classes)
+        max_shapelets_per_class = int(self._max_shapelets / self.n_classes_)
         if max_shapelets_per_class < 1:
             max_shapelets_per_class = 1
         # shapelet list content: quality, length, position, channel, inst_idx, cls_idx
         shapelets = List(
-            [List([(-1.0, -1, -1, -1, -1, -1)]) for _ in range(self.n_classes)]
+            [List([(-1.0, -1, -1, -1, -1, -1)]) for _ in range(self.n_classes_)]
         )
         n_shapelets_extracted = 0
 
@@ -319,7 +331,7 @@ class RandomShapeletTransform(BaseCollectionTransformer):
                 s[3],
                 s[4],
                 self.classes_[s[5]],
-                z_normalise_series(X[s[4], s[3], s[2] : s[2] + s[1]]),
+                z_normalise_series(X[s[4]][s[3]][s[2] : s[2] + s[1]]),
             )
             for class_shapelets in shapelets
             for s in class_shapelets
@@ -338,7 +350,8 @@ class RandomShapeletTransform(BaseCollectionTransformer):
                     sorted(range(s[1]), reverse=True, key=lambda j, sabs=sabs: sabs[j])
                 )
             )
-        return self
+        # find max shapelet length
+        self.max_fitted_shapelet_length_ = max(self.shapelets, key=lambda x: x[1])[1]
 
     def _transform(self, X, y=None):
         """Transform X according to the extracted shapelets.
@@ -355,6 +368,14 @@ 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_:
+                raise ValueError(
+                    "The shortest series in transform is smaller than "
+                    "the min shapelet length, pad to min length prior to "
+                    "calling transform."
+                )
+
         for i, series in enumerate(X):
             dists = Parallel(
                 n_jobs=self._n_jobs, backend=self.parallel_backend, prefer="threads"
@@ -399,7 +420,7 @@ class RandomShapeletTransform(BaseCollectionTransformer):
     def _extract_random_shapelet(
         self, X, y, i, shapelets, max_shapelets_per_class, rng
     ):
-        inst_idx = i % self.n_instances
+        inst_idx = i % self.n_instances_
         cls_idx = int(y[inst_idx])
         worst_quality = (
             shapelets[cls_idx][0][0]
@@ -411,10 +432,12 @@ class RandomShapeletTransform(BaseCollectionTransformer):
             rng.randint(0, self._max_shapelet_length - self.min_shapelet_length)
             + self.min_shapelet_length
         )
-        position = rng.randint(0, self.series_length - length)
-        dim = rng.randint(0, self.n_channels)
+        position = rng.randint(0, self.min_series_length_ - length)
+        channel = rng.randint(0, self.n_channels_)
 
-        shapelet = z_normalise_series(X[inst_idx, dim, position : position + length])
+        shapelet = z_normalise_series(
+            X[inst_idx][channel][position : position + length]
+        )
         sabs = np.abs(shapelet)
         sorted_indicies = np.array(
             sorted(range(length), reverse=True, key=lambda j: sabs[j])
@@ -427,14 +450,14 @@ class RandomShapeletTransform(BaseCollectionTransformer):
             sorted_indicies,
             position,
             length,
-            dim,
+            channel,
             inst_idx,
             self._class_counts[cls_idx],
-            self.n_instances - self._class_counts[cls_idx],
+            self.n_instances_ - self._class_counts[cls_idx],
             worst_quality,
         )
 
-        return np.round(quality, 8), length, position, dim, inst_idx, cls_idx
+        return np.round(quality, 8), length, position, channel, inst_idx, cls_idx
 
     @staticmethod
     @njit(fastmath=True, cache=True)


Test patch:
diff --git a/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py b/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py
new file mode 100644
index 00000000..bcf3ae21
--- /dev/null
+++ b/aeon/transformations/collection/shapelet_based/tests/test_shapelet_transform.py
@@ -0,0 +1,22 @@
+"""Test shapelet transform."""
+
+import pytest
+
+from aeon.transformations.collection.shapelet_based import RandomShapeletTransform
+from aeon.utils._testing.collection import make_3d_test_data
+
+
+def test_shapelet_transform():
+    """Test edge cases for RandomShapeletTransform."""
+    X, y = make_3d_test_data(n_cases=10, n_timepoints=20, n_labels=4)
+    rst = RandomShapeletTransform(max_shapelets=3, remove_self_similar=True)
+    rst._fit(X, y)
+    # 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=10, n_labels=4)
+    with pytest.raises(
+        ValueError,
+        match="The shortest series in transform is "
+        "smaller than the min shapelet length",
+    ):
+        rst._transform(X)