Repository: sktime/sktime
Pull request #7452 (2024-11-26 20:18:47)

[BUG] fix `BaseDetector.change_points_to_segments`
This PR fixes the logic in `BaseDetector.change_points_to_segments` which was off and returning odd results, even failing if `start` was not set.

Fixes the call in `predict_segments` which was assuming `loc` rather than `iloc` indexing.

Adds tests for `change_points_to_segments`.


Patch:
diff --git a/sktime/detection/base/_base.py b/sktime/detection/base/_base.py
index f42f15047..f98b7d579 100644
--- a/sktime/detection/base/_base.py
+++ b/sktime/detection/base/_base.py
@@ -237,7 +237,7 @@ class BaseDetector(BaseEstimator):
             Labels for sequence ``X``.
 
             * If ``task`` is ``"anomaly_detection"``, the values are integer labels.
-              A value of 0 indicatesthat ``X``, at the same time index, has no anomaly.
+              A value of 0 indicates that ``X``, at the same time index, has no anomaly.
               Other values indicate an anomaly.
               Most detectors will return 0 or 1, but some may return more values,
               if they can detect different types of anomalies.
@@ -587,6 +587,7 @@ class BaseDetector(BaseEstimator):
         y : pd.Series with IntervalIndex
             A series with an index of intervals. Each interval is the range of a
             segment and the corresponding value is the label of the segment.
+            Values are ``iloc`` references to indices of ``X``.
 
             * If ``task`` is ``"anomaly_detection"`` or ``"change_point_detection"``,
               the intervals are intervals between changepoints/anomalies, and
@@ -599,7 +600,7 @@ class BaseDetector(BaseEstimator):
         task = self.get_tag("task")
         if task in ["anomaly_detection", "change_point_detection"]:
             return self.change_points_to_segments(
-                self.predict_points(X), start=X.index.min(), end=X.index.max()
+                self.predict_points(X), start=0, end=len(X)
             )
         elif task == "segmentation":
             return self._predict_segments(X)
@@ -865,12 +866,14 @@ class BaseDetector(BaseEstimator):
 
         Parameters
         ----------
-        y_sparse : pd.Series
-            A series containing the indexes of change points.
-        start : optional
+        y_sparse : pd.Series of int, sorted ascendingly
+            A series containing the iloc indexes of change points.
+        start : optional, default=0
             Starting point of the first segment.
-        end : optional
-            Ending point of the last segment
+            Must be before the first change point, i.e., < y_sparse[0].
+        end : optional, default=y_sparse[-1] + 1
+            End point of the last segment.
+            Must be after the last change point, i.e., > y_sparse[-1].
 
         Returns
         -------
@@ -884,7 +887,7 @@ class BaseDetector(BaseEstimator):
         >>> from sktime.detection.base import BaseDetector
         >>> change_points = pd.Series([1, 2, 5])
         >>> BaseDetector.change_points_to_segments(change_points, 0, 7)
-        [0, 1)   -1
+        [0, 1)    0
         [1, 2)    1
         [2, 5)    2
         [5, 7)    3
@@ -895,25 +898,26 @@ class BaseDetector(BaseEstimator):
 
         breaks = y_sparse.values
 
-        if start > breaks.min():
-            raise ValueError(
-                "The starting index must be before the first change point."
-            )
-        first_change_point = breaks.min()
+        if start is not None and start > breaks.min():
+            raise ValueError("The start index must be before the first change point.")
+        if end is not None and end < breaks.max():
+            raise ValueError("The end index must be after the last change point.")
+
+        if start is None:
+            start = 0
+        if end is None:
+            end = breaks[-1] + 1
 
-        if start is not None:
-            breaks = np.insert(breaks, 0, start)
-        if end is not None:
-            breaks = np.append(breaks, end)
+        breaks = np.insert(breaks, 0, start)
+        breaks = np.append(breaks, end)
 
         index = pd.IntervalIndex.from_breaks(breaks, copy=True, closed="left")
         segments = pd.Series(0, index=index)
 
-        in_range = index.left >= first_change_point
+        in_range = index.left >= start
 
         number_of_segments = in_range.sum()
-        segments.loc[in_range] = range(1, number_of_segments + 1)
-        segments.loc[~in_range] = -1
+        segments.loc[in_range] = range(0, number_of_segments)
 
         return segments
 


Test patch:
diff --git a/sktime/detection/tests/test_base.py b/sktime/detection/tests/test_base.py
index 06d79e80f..731551d3b 100644
--- a/sktime/detection/tests/test_base.py
+++ b/sktime/detection/tests/test_base.py
@@ -73,12 +73,30 @@ def test_dense_to_sparse(y_dense, y_sparse_expected):
         (
             pd.Series([1, 2, 5]),
             pd.Series(
-                [-1, 1, 2, 3],
+                [0, 1, 2, 3],
+                index=pd.IntervalIndex.from_breaks([-42, 1, 2, 5, 7], closed="left"),
+            ),
+            -42,
+            7,
+        ),
+        (
+            pd.Series([1, 2, 5]),
+            pd.Series(
+                [0, 1, 2, 3],
                 index=pd.IntervalIndex.from_breaks([0, 1, 2, 5, 7], closed="left"),
             ),
-            0,
+            None,
             7,
-        )
+        ),
+        (
+            pd.Series([1, 2, 5]),
+            pd.Series(
+                [0, 1, 2, 3],
+                index=pd.IntervalIndex.from_breaks([0, 1, 2, 5, 6], closed="left"),
+            ),
+            None,
+            None,
+        ),
     ],
 )
 def test_change_points_to_segments(change_points, expected_segments, start, end):