Repository: aeon-toolkit/aeon (python)

Issue:
[MNT] Range-based AD metrics: The functions in `_continuous.py` need to be updated to use the new implementations
Once, #2781 is merged, we need to update the following metrics to also use the new implementation of the range-based precision and recall metrics:

- `f_score_at_k_ranges`
- `rp_rr_auc_score`

In file `aeon/benchmarking/metrics/anomaly_detection/_continuous.py`; Please also update the tests to not require the soft-dependency `prts` anymore.

Pull request description:
[ENH] Update `_continuous.py` functions
#### Reference Issues/PRs

Fixes #2785 

#### What does this implement/fix? Explain your changes.

Updates the _continuous functions to take the new metrics implementation. Also updates test_ad_metrics to remove prts dependency.

Patch:
diff --git a/aeon/benchmarking/metrics/anomaly_detection/_continuous.py b/aeon/benchmarking/metrics/anomaly_detection/_continuous.py
index ce84ee33..ade8c102 100644
--- a/aeon/benchmarking/metrics/anomaly_detection/_continuous.py
+++ b/aeon/benchmarking/metrics/anomaly_detection/_continuous.py
@@ -18,12 +18,16 @@ import numpy as np
 from sklearn.metrics import auc, f1_score, precision_recall_curve
 from sklearn.metrics import roc_auc_score as _roc_auc_score
 
+from aeon.benchmarking.metrics.anomaly_detection._range_metrics import (
+    range_f_score,
+    range_precision,
+    range_recall,
+)
 from aeon.benchmarking.metrics.anomaly_detection._util import check_y
 from aeon.benchmarking.metrics.anomaly_detection.thresholding import (
     top_k_points_threshold,
     top_k_ranges_threshold,
 )
-from aeon.utils.validation._dependencies import _check_soft_dependencies
 
 
 def roc_auc_score(y_true: np.ndarray, y_score: np.ndarray) -> float:
@@ -142,9 +146,8 @@ def f_score_at_k_ranges(
     finding a threshold on the scoring that produces at least k anomalous ranges. If `k`
     is not specified, the number of anomalies within the ground truth is used as `k`.
 
-    This implementation uses the community package
-    `prts <https://pypi.org/project/prts/>`_ as a soft-dependency to compute the
-    range-based F-score.
+    This implementation uses the aeon's implementation from `_range_metrics.py` to
+    compute the range-based F-score.
 
     Parameters
     ----------
@@ -167,12 +170,6 @@ def f_score_at_k_ranges(
     aeon.benchmarking.metrics.anomaly_detection.thresholding.top_k_ranges_threshold
         Function used to find the threshold.
     """
-    _check_soft_dependencies(
-        "prts", obj="f_score_at_k_ranges", suppress_import_stdout=True
-    )
-
-    from prts import ts_fscore
-
     y_true, y_pred = check_y(y_true, y_score, force_y_pred_continuous=True)
     if np.unique(y_score).shape[0] == 1:
         warnings.warn(
@@ -183,7 +180,8 @@ def f_score_at_k_ranges(
 
     threshold = top_k_ranges_threshold(y_true, y_score, k)
     y_pred = y_score >= threshold
-    return ts_fscore(y_true, y_pred, p_alpha=1, r_alpha=1, cardinality="reciprocal")
+    # Corrected p_alpha to 1 to match original prts behavior expectation
+    return range_f_score(y_true, y_pred, p_alpha=1, r_alpha=1, cardinality="reciprocal")
 
 
 def rp_rr_auc_score(
@@ -199,8 +197,6 @@ def rp_rr_auc_score(
 
     Computes the area under the precision recall curve when using the range-based
     precision and range-based recall metric introduced by Tatbul et al. at NeurIPS 2018
-    [1]_. This implementation uses the community package
-    `prts <https://pypi.org/project/prts/>`_ as a soft-dependency.
 
     This metric only considers the top-k predicted anomaly ranges within the scoring by
     finding a threshold on the scoring that produces at least k anomalous ranges. If `k`
@@ -242,12 +238,6 @@ def rp_rr_auc_score(
        1920–30. 2018.
        http://papers.nips.cc/paper/7462-precision-and-recall-for-time-series.pdf.
     """
-    _check_soft_dependencies(
-        "prts", obj="f_score_at_k_ranges", suppress_import_stdout=True
-    )
-
-    from prts import ts_precision, ts_recall
-
     y_true, y_pred = check_y(y_true, y_score, force_y_pred_continuous=True)
     if np.unique(y_score).shape[0] == 1:
         warnings.warn(
@@ -279,10 +269,10 @@ def rp_rr_auc_score(
     precisions = np.zeros_like(thresholds)
     for i, threshold in enumerate(thresholds):
         y_pred = (y_score >= threshold).astype(np.int64)
-        recalls[i] = ts_recall(
+        recalls[i] = range_recall(
             y_true, y_pred, alpha=r_alpha, cardinality=cardinality, bias=bias
         )
-        precisions[i] = ts_precision(
+        precisions[i] = range_precision(
             y_true, y_pred, alpha=p_alpha, cardinality=cardinality, bias=bias
         )
     # first sort by recall, then by precision to break ties
diff --git a/aeon/benchmarking/metrics/anomaly_detection/_range_metrics.py b/aeon/benchmarking/metrics/anomaly_detection/_range_metrics.py
index 7143100c..6308cd9b 100644
--- a/aeon/benchmarking/metrics/anomaly_detection/_range_metrics.py
+++ b/aeon/benchmarking/metrics/anomaly_detection/_range_metrics.py
@@ -30,10 +30,6 @@ def range_precision(
     predicted continuous anomaly range the overlap size, position, and cardinality is
     considered. For more details, please refer to the paper [1]_.
 
-    The `alpha` parameter for the existence reward was removed. Because precision
-    emphasizes prediction quality, there is no need for an existence reward and this
-    value should always be set to 0.
-
     Parameters
     ----------
     y_true : np.ndarray
@@ -41,7 +37,7 @@ def range_precision(
     y_pred : np.ndarray
         Anomaly scores for each point of the time series of shape (n_instances,).
     alpha : float
-        DEPRECATED. Default is 0 = no existence reward.
+        Default is 0 = no existence reward.
     cardinality : {'reciprocal', 'one', 'udf_gamma'}
         Cardinality type.
     bias : {'flat', 'front', 'middle', 'back'}
@@ -60,13 +56,6 @@ def range_precision(
        1920–30. 2018.
        http://papers.nips.cc/paper/7462-precision-and-recall-for-time-series.pdf.
     """
-    if alpha != 0:
-        warnings.warn(
-            "The alpha parameter should not be used in range precision. This "
-            "parameter is removed in 1.3.0.",
-            stacklevel=2,
-            category=FutureWarning,
-        )
     y_true, y_pred = check_y(y_true, y_pred, force_y_pred_continuous=False)
     if np.unique(y_pred).shape[0] == 1:
         warnings.warn(
@@ -160,9 +149,6 @@ def range_f_score(
     its optimal value at 1 and its worst value at 0. This implementation uses the
     range-based precision and range-based recall as basis.
 
-    The `p_alpha` parameter for the potential existance reward in the calculation of
-    range-based precision was removed. `p_alpha` should always be set to 0, anyway.
-
     Parameters
     ----------
     y_true : np.ndarray
@@ -173,7 +159,7 @@ def range_f_score(
         F-score beta determines the weight of recall in the combined score.
         beta < 1 lends more weight to precision, while beta > 1 favors recall.
     p_alpha : float
-        DEPRECATED. Default is 0 = no existence reward for precision.
+        Default is 0 = no existence reward for precision.
     r_alpha : float
         Weight of the existence reward. If 0: no existence reward, if 1: only
         existence reward.
@@ -197,14 +183,6 @@ def range_f_score(
        1920–30. 2018.
        http://papers.nips.cc/paper/7462-precision-and-recall-for-time-series.pdf.
     """
-    if p_alpha != 0:
-        warnings.warn(
-            "The p_alpha parameter should not be used. This parameter is removed "
-            "in 1.3.0.",
-            stacklevel=2,
-            category=FutureWarning,
-        )
-
     y_true, y_pred = check_y(y_true, y_pred, force_y_pred_continuous=False)
     if np.unique(y_pred).shape[0] == 1:
         warnings.warn(
@@ -216,7 +194,9 @@ def range_f_score(
     y_pred_ranges = _binary_to_ranges(y_pred)
     y_true_ranges = _binary_to_ranges(y_true)
 
-    precision = _ts_precision(y_pred_ranges, y_true_ranges, cardinality, p_bias)
+    precision = _ts_precision(
+        y_pred_ranges, y_true_ranges, cardinality, p_bias, p_alpha
+    )
     recall = _ts_recall(y_pred_ranges, y_true_ranges, cardinality, r_bias, r_alpha)
 
     if precision + recall > 0:
diff --git a/aeon/benchmarking/metrics/anomaly_detection/_range_ts_metrics.py b/aeon/benchmarking/metrics/anomaly_detection/_range_ts_metrics.py
index 12ad2c0e..1791d367 100644
--- a/aeon/benchmarking/metrics/anomaly_detection/_range_ts_metrics.py
+++ b/aeon/benchmarking/metrics/anomaly_detection/_range_ts_metrics.py
@@ -233,7 +233,7 @@ def _binary_to_ranges(binary_sequence):
     "Please use range_precision instead.",
     category=FutureWarning,
 )
-def ts_precision(y_pred, y_real, gamma="one", bias_type="flat"):
+def ts_precision(y_pred, y_real, gamma="one", bias_type="flat", alpha=0.0):
     """
     Calculate Precision for time series anomaly detection.
 
@@ -317,10 +317,12 @@ def ts_precision(y_pred, y_real, gamma="one", bias_type="flat"):
     flat_y_pred = _flatten_ranges(y_pred_ranges)
     flat_y_real = _flatten_ranges(y_real_ranges)
 
-    return _ts_precision(flat_y_pred, flat_y_real, gamma, bias_type)
+    return _ts_precision(flat_y_pred, flat_y_real, gamma, bias_type, alpha=alpha)
 
 
-def _ts_precision(y_pred_ranges, y_real_ranges, gamma="one", bias_type="flat"):
+def _ts_precision(
+    y_pred_ranges, y_real_ranges, gamma="one", bias_type="flat", alpha=0.0
+):
     """
     Implement range-based precision for time series anomaly detection.
 
@@ -371,13 +373,20 @@ def _ts_precision(y_pred_ranges, y_real_ranges, gamma="one", bias_type="flat"):
         overlap_reward = _calculate_overlap_reward_precision(
             pred_range, overlap_set, bias_type
         )
+
+        existence_reward = 1.0 if overlap_set else 0.0
         gamma_value = _gamma_select(cardinality, gamma)
-        total_overlap_reward += gamma_value * overlap_reward
+
+        score_i = alpha * existence_reward + (1 - alpha) * (
+            gamma_value * overlap_reward
+        )
+
+        total_overlap_reward += score_i
+
         total_cardinality += 1
 
-    precision = (
-        total_overlap_reward / total_cardinality if total_cardinality > 0 else 0.0
-    )
+    precision = total_overlap_reward / total_cardinality if total_cardinality else 0.0
+
     return precision
 
 
@@ -609,7 +618,7 @@ def ts_fscore(
        Processing Systems (NeurIPS 2018), Montréal, Canada.
        http://papers.nips.cc/paper/7462-precision-and-recall-for-time-series.pdf
     """
-    precision = ts_precision(y_pred, y_real, gamma, p_bias)
+    precision = ts_precision(y_pred, y_real, gamma, p_bias, alpha=p_alpha)
     recall = ts_recall(y_pred, y_real, gamma, r_bias, r_alpha)
 
     if precision + recall > 0:


Test patch:
diff --git a/aeon/benchmarking/metrics/anomaly_detection/tests/test_ad_metrics.py b/aeon/benchmarking/metrics/anomaly_detection/tests/test_ad_metrics.py
index fd14aa39..e2e5364e 100644
--- a/aeon/benchmarking/metrics/anomaly_detection/tests/test_ad_metrics.py
+++ b/aeon/benchmarking/metrics/anomaly_detection/tests/test_ad_metrics.py
@@ -20,9 +20,8 @@ from aeon.benchmarking.metrics.anomaly_detection._range_metrics import (
     range_recall,
 )
 from aeon.testing.data_generation import make_example_1d_numpy
-from aeon.utils.validation._dependencies import _check_soft_dependencies
 
-pr_metrics = [pr_auc_score]
+pr_metrics = [pr_auc_score, rp_rr_auc_score]
 range_metrics = [
     range_roc_auc_score,
     range_pr_auc_score,
@@ -32,6 +31,7 @@ range_metrics = [
 other_metrics = [
     roc_auc_score,
     f_score_at_k_points,
+    f_score_at_k_ranges,
 ]
 continuous_metrics = [*pr_metrics, *other_metrics, *range_metrics]
 binary_metrics = [
@@ -40,21 +40,6 @@ binary_metrics = [
     range_recall,
 ]
 
-if _check_soft_dependencies("prts", severity="none"):
-    pr_metrics.append(rp_rr_auc_score)
-    other_metrics.extend(
-        [
-            f_score_at_k_ranges,
-            rp_rr_auc_score,
-        ]
-    )
-    continuous_metrics.extend(
-        [
-            rp_rr_auc_score,
-            f_score_at_k_ranges,
-        ]
-    )
-
 metrics = [*pr_metrics, *range_metrics, *other_metrics]
 
 
@@ -148,10 +133,6 @@ def test_edge_cases_pr_metrics(metric):
     assert score <= 0.2, f"{metric.__name__}(y_true, y_inverted)={score} is not <= 0.2"
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_range_based_f1():
     """Test range-based F1 score."""
     y_pred = np.array([0, 1, 1, 0])
@@ -160,10 +141,6 @@ def test_range_based_f1():
     np.testing.assert_almost_equal(result, 0.66666, decimal=4)
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_range_based_precision():
     """Test range-based precision."""
     y_pred = np.array([0, 1, 1, 0])
@@ -172,10 +149,6 @@ def test_range_based_precision():
     assert result == 0.5
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_range_based_recall():
     """Test range-based recall."""
     y_pred = np.array([0, 1, 1, 0])
@@ -184,10 +157,6 @@ def test_range_based_recall():
     assert result == 1
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_rf1_value_error():
     """Test range-based F1 score raises ValueError on binary predictions."""
     y_pred = np.array([0, 0.2, 0.7, 0])
@@ -211,10 +180,6 @@ def test_pr_curve_auc():
 #     np.testing.assert_almost_equal(result, 0.8333, decimal=4)
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_range_based_p_range_based_r_curve_auc():
     """Test range-based precision-recall curve AUC."""
     y_pred = np.array([0, 0.1, 1.0, 0.5, 0.1, 0])
@@ -223,10 +188,6 @@ def test_range_based_p_range_based_r_curve_auc():
     np.testing.assert_almost_equal(result, 0.9792, decimal=4)
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_range_based_p_range_based_r_auc_perfect_hit():
     """Test range-based precision-recall curve AUC with perfect hit."""
     y_pred = np.array([0, 0, 0.5, 0.5, 0, 0])
@@ -235,10 +196,6 @@ def test_range_based_p_range_based_r_auc_perfect_hit():
     np.testing.assert_almost_equal(result, 1.0000, decimal=4)
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("prts", severity="none"),
-    reason="required soft dependency prts not available",
-)
 def test_f_score_at_k_ranges():
     """Test range-based F1 score at k ranges."""
     y_pred = np.array([0.4, 0.1, 1.0, 0.5, 0.1, 0, 0.4, 0.5])