Repository: aeon-toolkit/aeon (python)

Issue:
[BUG] 
### Describe the bug

An error with Numba(ConstantInferenceError) is preventing me to use clustering.

### Steps/Code to reproduce the bug

from aeon.clustering.k_means import TimeSeriesKMeans

# This is a just a list of numpy arrays
with open('dataCluster100.pkl', 'rb') as f:
        dataCluster99 = pickle.load(f)

X_train = np.array([arr[np.newaxis, :] for arr in dataCluster99])

k_means = TimeSeriesKMeans(
    n_clusters=13,  # Number of desired centers
    init_algorithm="forgy",  # Center initialisation technique
    max_iter=10,  # Maximum number of iterations for refinement on training set
    metric="dtw",  # Distance metric to use
    averaging_method="dba",  # Averaging technique to use
    random_state=1,
)

k_means.fit(X_train)

### Expected results

N/A

### Actual results

ConstantInferenceError                    Traceback (most recent call last)
[<ipython-input-5-2c4a67d93a28>](https://localhost:8080/#) in <cell line: 15>()
     13 )
     14 
---> 15 k_means.fit(X_train)

6 frames
[/usr/local/lib/python3.10/dist-packages/numba/core/dispatcher.py](https://localhost:8080/#) in error_rewrite(e, issue_type)
    407                 raise e
    408             else:
--> 409                 raise e.with_traceback(None)
    410 
    411         argtypes = []

ConstantInferenceError: Failed in nopython mode pipeline (step: nopython rewrites)
Constant inference not possible for: $const288.2 + $292format_value.5

File "../usr/local/lib/python3.10/dist-packages/aeon/clustering/metrics/averaging/_barycenter_averaging.py", line 137:
def _ba_update(
    <source elided>
        else:
            raise ValueError(f"Metric must be a known string, got {metric}")
            ^

### Versions

<details>
System:
    python: 3.10.12 (main, Jun  7 2023, 12:45:35) [GCC 9.4.0]
executable: /usr/bin/python3
   machine: Linux-5.15.107+-x86_64-with-glibc2.31

Python dependencies:
          pip: 23.1.2
   setuptools: 67.7.2
      sklearn: None
         aeon: 0.3.0
  statsmodels: 0.13.5
        numpy: 1.22.4
        scipy: 1.10.1
       pandas: 1.5.3
   matplotlib: 3.7.1
       joblib: 1.2.0
        numba: 0.56.4
     pmdarima: None
      tsfresh: None
</details>

Pull request description:
[BUG] DBA numba error
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at our contribution guide: https://github.com/aeon-toolkit/aeon/blob/main/CONTRIBUTING.md

Feel free to delete sections of this template if they do not apply to your PR, avoid submitting a blank template or empty sections.
-->

#### Reference Issues/PRs
Issue Fixes #530 


<!--
Example: Fixes #1234. See also #3456.

Please use keywords (e.g., Fixes) to create link to the issues or pull requests you resolved, so that they will automatically be closed when your pull request is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->

#### What does this implement/fix? Explain your changes.
This PR fixes a bug that was occurring for DBA for anyone using a version <0.57. The reason for the bug was numba
doesnt like f strings (apparently) below 0.57. This was the offending line:

`raise ValueError(f"Metric must be a known string, got {metric}") `

This has now been removed. In addition we suspected at first it was an isinstance call as this is an experimental feature in numba so I also removed the occurence of it (as I thought this was the bug) which also removes the warning.

<!--
A clear and concise description of what you have implemented.
-->

#### Does your contribution introduce a new dependency? If yes, which one?
No

<!--
If your contribution does add a dependency, we may suggest adding it as an optional/soft dependency to keep external dependencies of the core aeon package to a minimum.
-->

#### What should a reviewer concentrate their feedback on?

<!--
This section is particularly useful if you have a pull request that is still in development. You can guide the reviews to focus on the parts that are ready for their comments. We suggest using bullets (indicated by * or -) and filled checkboxes [x] here
-->

#### Did you add any tests for the change?

<!--
This section is useful if you have added a test in addition to the existing ones. This will ensure that further changes to these files won't introduce the same kind of bug. It is considered good practice to add tests with newly added code to enforce the fact that the code actually works. This will reduce the chance of introducing logical bugs.
-->

#### Any other comments?

<!--
Please be aware that we are a loose team of volunteers so patience is necessary; assistance handling other issues is very welcome. We value all user contributions, no matter how minor they are. If we are slow to review, either the pull request needs some benchmarking, tinkering, convincing, etc. or more likely the reviewers are simply busy. In either case, we ask for your understanding during the review process.
-->

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/aeon-toolkit/aeon/blob/main/.all-contributorsrc).
- [x] Optionally, I've updated aeon's [CODEOWNERS](https://github.com/aeon-toolkit/aeon/blob/main/CODEOWNERS) to receive notifications about future changes to these files.
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG] indicating whether the PR topic is related to enhancement, maintenance, documentation, or bug.
- [x] For new estimators, I've added the estimator to the online documentation.


<!--
Thanks for contributing!
-->


Patch:
diff --git a/aeon/clustering/metrics/averaging/_barycenter_averaging.py b/aeon/clustering/metrics/averaging/_barycenter_averaging.py
index f788db62..15cca346 100644
--- a/aeon/clustering/metrics/averaging/_barycenter_averaging.py
+++ b/aeon/clustering/metrics/averaging/_barycenter_averaging.py
@@ -110,7 +110,6 @@ def _ba_update(
 ) -> Tuple[np.ndarray, float]:
     X_size, X_dims, X_timepoints = X.shape
     sum = np.zeros(X_timepoints)
-
     alignment = np.zeros((X_dims, X_timepoints))
     cost = 0.0
     for i in range(X_size):
@@ -134,7 +133,9 @@ def _ba_update(
                 curr_ts, center, window, independent, c
             )
         else:
-            raise ValueError(f"Metric must be a known string, got {metric}")
+            # When numba version > 0.57 add more informative error with what metric
+            # was passed.
+            raise ValueError("Metric parameter invalid")
         for j, k in curr_alignment:
             alignment[:, k] += curr_ts[:, j]
             sum[k] += 1
diff --git a/aeon/distances/_distance.py b/aeon/distances/_distance.py
index c2da8f19..6674a685 100644
--- a/aeon/distances/_distance.py
+++ b/aeon/distances/_distance.py
@@ -132,7 +132,9 @@ def distance(
     elif metric == "lcss":
         return lcss_distance(x, y, kwargs.get("window"), kwargs.get("epsilon", 1.0))
     elif metric == "erp":
-        return erp_distance(x, y, kwargs.get("window"), kwargs.get("g", 0.0))
+        return erp_distance(
+            x, y, kwargs.get("window"), kwargs.get("g", 0.0), kwargs.get("g_arr", None)
+        )
     elif metric == "edr":
         return edr_distance(x, y, kwargs.get("window"), kwargs.get("epsilon"))
     elif metric == "twe":
@@ -243,7 +245,9 @@ def pairwise_distance(
             x, y, kwargs.get("window"), kwargs.get("epsilon", 1.0)
         )
     elif metric == "erp":
-        return erp_pairwise_distance(x, y, kwargs.get("window"), kwargs.get("g", 0.0))
+        return erp_pairwise_distance(
+            x, y, kwargs.get("window"), kwargs.get("g", 0.0), kwargs.get("g_arr", None)
+        )
     elif metric == "edr":
         return edr_pairwise_distance(x, y, kwargs.get("window"), kwargs.get("epsilon"))
     elif metric == "twe":
@@ -374,7 +378,9 @@ def alignment_path(
             x, y, kwargs.get("window"), kwargs.get("epsilon", 1.0)
         )
     elif metric == "erp":
-        return erp_alignment_path(x, y, kwargs.get("window"), kwargs.get("g", 0.0))
+        return erp_alignment_path(
+            x, y, kwargs.get("window"), kwargs.get("g", 0.0), kwargs.get("g_arr", None)
+        )
     elif metric == "edr":
         return edr_alignment_path(x, y, kwargs.get("window"), kwargs.get("epsilon"))
     elif metric == "twe":
@@ -460,7 +466,9 @@ def cost_matrix(
     elif metric == "lcss":
         return lcss_cost_matrix(x, y, kwargs.get("window"), kwargs.get("epsilon", 1.0))
     elif metric == "erp":
-        return erp_cost_matrix(x, y, kwargs.get("window"), kwargs.get("g", 0.0))
+        return erp_cost_matrix(
+            x, y, kwargs.get("window"), kwargs.get("g", 0.0), kwargs.get("g_arr", None)
+        )
     elif metric == "edr":
         return edr_cost_matrix(x, y, kwargs.get("window"), kwargs.get("epsilon"))
     elif metric == "twe":
diff --git a/aeon/distances/_erp.py b/aeon/distances/_erp.py
index 56e966ee..fdf426b6 100644
--- a/aeon/distances/_erp.py
+++ b/aeon/distances/_erp.py
@@ -34,7 +34,8 @@ def erp_distance(
     x: np.ndarray,
     y: np.ndarray,
     window: float = None,
-    g: Union[float, np.ndarray] = 0.0,
+    g: float = 0.0,
+    g_arr: np.ndarray = None,
 ) -> float:
     """Compute the ERP distance between two time series.
 
@@ -58,10 +59,10 @@ def erp_distance(
     window: float, defaults=None
         The window to use for the bounding matrix. If None, no bounding matrix
         is used.
-    g: float or np.ndarray of shape (n_channels), defaults=0.
-        The reference value to penalise gaps. The default is 0. If it is an array
-        then it must be the length of the number of channels in x and y. If a single
-        value is provided then that value is used across each channel
+    g: float.
+        The reference value to penalise gaps. The default is 0.
+    g_arr: np.ndarray of shape (n_channels), defaults=None
+        Numpy array that must be the length of the number of channels in x and y.
 
     Returns
     -------
@@ -91,10 +92,10 @@ def erp_distance(
         _x = x.reshape((1, x.shape[0]))
         _y = y.reshape((1, y.shape[0]))
         bounding_matrix = create_bounding_matrix(_x.shape[1], _y.shape[1], window)
-        return _erp_distance(_x, _y, bounding_matrix, g)
+        return _erp_distance(_x, _y, bounding_matrix, g, g_arr)
     if x.ndim == 2 and y.ndim == 2:
         bounding_matrix = create_bounding_matrix(x.shape[1], y.shape[1], window)
-        return _erp_distance(x, y, bounding_matrix, g)
+        return _erp_distance(x, y, bounding_matrix, g, g_arr)
     raise ValueError("x and y must be 1D or 2D")
 
 
@@ -104,6 +105,7 @@ def erp_cost_matrix(
     y: np.ndarray,
     window: float = None,
     g: Union[float, np.ndarray] = 0.0,
+    g_arr: np.ndarray = None,
 ) -> np.ndarray:
     """Compute the ERP cost matrix between two time series.
 
@@ -121,10 +123,10 @@ def erp_cost_matrix(
     window: float, defaults=None
         The window to use for the bounding matrix. If None, no bounding matrix
         is used.
-    g: float or np.ndarray of shape (n_channels), defaults=0.
-        The reference value to penalise gaps. The default is 0. If it is an array
-        then it must be the length of the number of channels in x and y. If a single
-        value is provided then that value is used across each channel.
+    g: float.
+        The reference value to penalise gaps. The default is 0.
+    g_arr: np.ndarray of shape (n_channels), defaults=None
+        Numpy array that must be the length of the number of channels in x and y.
 
     Returns
     -------
@@ -158,10 +160,10 @@ def erp_cost_matrix(
         _x = x.reshape((1, x.shape[0]))
         _y = y.reshape((1, y.shape[0]))
         bounding_matrix = create_bounding_matrix(_x.shape[1], _y.shape[1], window)
-        return _erp_cost_matrix(_x, _y, bounding_matrix, g)
+        return _erp_cost_matrix(_x, _y, bounding_matrix, g, g_arr)
     if x.ndim == 2 and y.ndim == 2:
         bounding_matrix = create_bounding_matrix(x.shape[1], y.shape[1], window)
-        return _erp_cost_matrix(x, y, bounding_matrix, g)
+        return _erp_cost_matrix(x, y, bounding_matrix, g, g_arr)
     raise ValueError("x and y must be 1D or 2D")
 
 
@@ -170,9 +172,12 @@ def _erp_distance(
     x: np.ndarray,
     y: np.ndarray,
     bounding_matrix: np.ndarray,
-    g: Union[float, np.ndarray],
+    g: float,
+    g_arr: np.ndarray,
 ) -> float:
-    return _erp_cost_matrix(x, y, bounding_matrix, g)[x.shape[1] - 1, y.shape[1] - 1]
+    return _erp_cost_matrix(x, y, bounding_matrix, g, g_arr)[
+        x.shape[1] - 1, y.shape[1] - 1
+    ]
 
 
 @njit(cache=True, fastmath=True)
@@ -180,15 +185,16 @@ def _erp_cost_matrix(
     x: np.ndarray,
     y: np.ndarray,
     bounding_matrix: np.ndarray,
-    g: Union[float, np.ndarray],
+    g: float,
+    g_arr: np.ndarray,
 ) -> np.ndarray:
     x_size = x.shape[1]
     y_size = y.shape[1]
 
     cost_matrix = np.zeros((x_size + 1, y_size + 1))
 
-    gx_distance, x_sum = _precompute_g(x, g)
-    gy_distance, y_sum = _precompute_g(y, g)
+    gx_distance, x_sum = _precompute_g(x, g, g_arr)
+    gy_distance, y_sum = _precompute_g(y, g, g_arr)
 
     cost_matrix[1:, 0] = x_sum
     cost_matrix[0, 1:] = y_sum
@@ -208,15 +214,15 @@ def _erp_cost_matrix(
 
 @njit(cache=True, fastmath=True)
 def _precompute_g(
-    x: np.ndarray, g: Union[float, np.ndarray]
+    x: np.ndarray, g: float, g_array: np.ndarray
 ) -> Tuple[np.ndarray, float]:
     gx_distance = np.zeros(x.shape[1])
-    if isinstance(g, float):
+    if g_array is None:
         g_arr = np.full(x.shape[0], g)
     else:
-        if g.shape[0] != x.shape[0]:
+        if g_array.shape[0] != x.shape[0]:
             raise ValueError("g must be a float or an array with shape (x.shape[0],)")
-        g_arr = g
+        g_arr = g_array
     x_sum = 0
 
     for i in range(x.shape[1]):
@@ -231,7 +237,8 @@ def erp_pairwise_distance(
     X: np.ndarray,
     y: np.ndarray = None,
     window: float = None,
-    g: Union[float, np.ndarray] = 0.0,
+    g: float = 0.0,
+    g_arr: np.ndarray = None,
 ) -> np.ndarray:
     """Compute the erp pairwise distance between a set of time series.
 
@@ -251,10 +258,10 @@ def erp_pairwise_distance(
     window: float, default=None
         The window to use for the bounding matrix. If None, no bounding matrix
         is used.
-    g: float or np.ndarray of shape (n_channels), defaults=0
-        The reference value to penalise gaps. The default is 0. If it is an array
-        then it must be the length of the number of channels in x and y. If a single
-        value is provided then that value is used across each channel.
+    g: float.
+        The reference value to penalise gaps. The default is 0.
+    g_arr: np.ndarray of shape (n_channels), defaults=None
+        Numpy array that must be the length of the number of channels in x and y.
 
     Returns
     -------
@@ -297,18 +304,21 @@ def erp_pairwise_distance(
     if y is None:
         # To self
         if X.ndim == 3:
-            return _erp_pairwise_distance(X, window, g)
+            return _erp_pairwise_distance(X, window, g, g_arr)
         if X.ndim == 2:
             _X = X.reshape((X.shape[0], 1, X.shape[1]))
-            return _erp_pairwise_distance(_X, window, g)
+            return _erp_pairwise_distance(_X, window, g, g_arr)
         raise ValueError("x and y must be 2D or 3D arrays")
     _x, _y = reshape_pairwise_to_multiple(X, y)
-    return _erp_from_multiple_to_multiple_distance(_x, _y, window, g)
+    return _erp_from_multiple_to_multiple_distance(_x, _y, window, g, g_arr)
 
 
 @njit(cache=True, fastmath=True)
 def _erp_pairwise_distance(
-    X: np.ndarray, window: float, g: Union[float, np.ndarray]
+    X: np.ndarray,
+    window: float,
+    g: float,
+    g_arr: np.ndarray,
 ) -> np.ndarray:
     n_instances = X.shape[0]
     distances = np.zeros((n_instances, n_instances))
@@ -316,7 +326,7 @@ def _erp_pairwise_distance(
 
     for i in range(n_instances):
         for j in range(i + 1, n_instances):
-            distances[i, j] = _erp_distance(X[i], X[j], bounding_matrix, g)
+            distances[i, j] = _erp_distance(X[i], X[j], bounding_matrix, g, g_arr)
             distances[j, i] = distances[i, j]
 
     return distances
@@ -324,7 +334,11 @@ def _erp_pairwise_distance(
 
 @njit(cache=True, fastmath=True)
 def _erp_from_multiple_to_multiple_distance(
-    x: np.ndarray, y: np.ndarray, window: float, g: Union[float, np.ndarray]
+    x: np.ndarray,
+    y: np.ndarray,
+    window: float,
+    g: float,
+    g_arr: np.ndarray,
 ) -> np.ndarray:
     n_instances = x.shape[0]
     m_instances = y.shape[0]
@@ -333,7 +347,7 @@ def _erp_from_multiple_to_multiple_distance(
 
     for i in range(n_instances):
         for j in range(m_instances):
-            distances[i, j] = _erp_distance(x[i], y[j], bounding_matrix, g)
+            distances[i, j] = _erp_distance(x[i], y[j], bounding_matrix, g, g_arr)
     return distances
 
 
@@ -342,7 +356,8 @@ def erp_alignment_path(
     x: np.ndarray,
     y: np.ndarray,
     window: float = None,
-    g: Union[float, np.ndarray] = 0.0,
+    g: float = 0.0,
+    g_arr: np.ndarray = None,
 ) -> Tuple[List[Tuple[int, int]], float]:
     """Compute the erp alignment path between two time series.
 
@@ -360,10 +375,10 @@ def erp_alignment_path(
     window: float, default=None
         The window to use for the bounding matrix. If None, no bounding matrix
         is used.
-    g: float or np.ndarray of shape (n_channels), defaults=0.
-        The reference value to penalise gaps. The default is 0. If it is an array
-        then it must be the length of the number of channels in x and y. If a single
-        value is provided then that value is used across each channel.
+    g: float.
+        The reference value to penalise gaps. The default is 0.
+    g_arr: np.ndarray of shape (n_channels), defaults=None
+        Numpy array that must be the length of the number of channels in x and y.
 
     Returns
     -------
@@ -390,7 +405,7 @@ def erp_alignment_path(
     """
     bounding_matrix = create_bounding_matrix(x.shape[-1], y.shape[-1], window)
     cost_matrix = _add_inf_to_out_of_bounds_cost_matrix(
-        erp_cost_matrix(x, y, window, g), bounding_matrix
+        erp_cost_matrix(x, y, window, g, g_arr), bounding_matrix
     )
     return (
         compute_min_return_path(cost_matrix),


Test patch:
diff --git a/aeon/distances/tests/test_numba_distance_parameters.py b/aeon/distances/tests/test_numba_distance_parameters.py
index cded18a7..808f5014 100644
--- a/aeon/distances/tests/test_numba_distance_parameters.py
+++ b/aeon/distances/tests/test_numba_distance_parameters.py
@@ -33,7 +33,9 @@ def _test_distance_params(
         curr_results = []
         for x, y in test_ts:
             if g_none:
-                param_dict["g"] = np.std([x, y], axis=0).sum(axis=1)
+                param_dict["g_arr"] = np.std([x, y], axis=0).sum(axis=1)
+                if "g" in param_dict:
+                    del param_dict["g"]
             results = []
             results.append(distance_func(x, y, **param_dict))
             results.append(distance(x, y, metric=distance_str, **param_dict))