Repository: sktime/sktime
Pull request #6265 (2024-04-06 09:20:54)

[ENH] Rework of base series annotator API
<!--
Welcome to sktime, and thanks for contributing!
Please have a look at our contribution guide:
https://www.sktime.net/en/latest/get_involved/contributing.html
-->

#### Reference Issues/PRs
<!--

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.
If no issue exists, you can open one here: https://github.com/sktime/sktime/issues
-->
See #3214 .


#### What does this implement/fix? Explain your changes.
<!--
A clear and concise description of what you have implemented.
-->

Refactors the `BaseSeriesAnnotator` class:

* Removes the `fmt` and `label` attributes.
* Adds the `learning_type` and `task` attributes.
* Adds default `predict` and `transform` method.
* Adds default `predict_points` and `predict_segments` methods.
* Adds methods for converting  between dense and sparse output formats.
* Adds default methods for `predict` and `transform`.

#### Does your contribution introduce a new dependency? If yes, which one?

<!--
Only relevant if you changed pyproject.toml.
We try to minimize dependencies in the core dependency set. There
are also further specific instructions to follow for soft dependencies.
See here for handling dependencies in sktime: https://www.sktime.net/en/latest/developer_guide/dependencies.html
-->

No.

#### 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?
<!--
We value all user contributions, no matter how small or complex they are. If you have any questions, feel free to post
in the dev-chat channel on the sktime discord https://discord.com/invite/54ACzaFsn7. If we are slow to review (>3 working days), likewise feel free to ping us on discord. Thank you 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
- [ ] I've added myself to the [list of contributors](https://github.com/sktime/sktime/blob/main/CONTRIBUTORS.md) with any new badges I've earned :-)
  How to: add yourself to the [all-contributors file](https://github.com/sktime/sktime/blob/main/.all-contributorsrc) in the `sktime` root directory (not the `CONTRIBUTORS.md`). Common badges: `code` - fixing a bug, or adding code logic. `doc` - writing or improving documentation or docstrings. `bug` - reporting or diagnosing a bug (get this plus `code` if you also fixed the bug in the PR).`maintenance` - CI, test framework, release.
  See here for [full badge reference](https://allcontributors.org/docs/en/emoji-key)
- [ ] Optionally, for added estimators: I've added myself and possibly to the `maintainers` tag - do this if you want to become the owner or maintainer of an estimator you added.
  See here for further details on the [algorithm maintainer role](https://www.sktime.net/en/latest/get_involved/governance.html#algorithm-maintainers).
- [ ] The PR title starts with either [ENH], [MNT], [DOC], or [BUG]. [BUG] - bugfix, [MNT] - CI, test framework, [ENH] - adding or improving code, [DOC] - writing or improving documentation or docstrings.

##### For new estimators
- [ ] I've added the estimator to the API reference - in `docs/source/api_reference/taskname.rst`, follow the pattern.
- [ ] I've added one or more illustrative usage examples to the docstring, in a pydocstyle compliant `Examples` section.
- [ ] If the estimator relies on a soft dependency, I've set the `python_dependencies` tag and ensured
  dependency isolation, see the [estimator dependencies guide](https://www.sktime.net/en/latest/developer_guide/dependencies.html#adding-a-soft-dependency).

<!--
Thanks for contributing!
-->


fkiraly: As discussed in the last developer meeting, the approach using tags is showcased here: https://github.com/sktime/sktime/pull/6271
fkiraly: PS: if you migrate to tags, you need to merge the parts of https://github.com/sktime/sktime/pull/6271 as well that add tags to the registry.
Alex-JG3: @fkiraly, @duydl I am at a small blocker and would appreciate your feedback.

### Converting between sparse and dense formats.

I have added the static methods `sparse_to_dense` and `dense_to_sparse` for converting between sparse and dense formats. I think having these methods is important because:

* `sparse_to_dense` can be used to convert the output from `predict_segments` or `predict_change_points` to the dense format.
* Suppose we have a segmentation annotator. One can get the segments in dense format by using `transform` but one needs `sparse_to_dense` to get change points (from `predict_change_points`) in dense format.

Here is the blocker. Suppose we have an array of anomalies in sparse format `[2, 5, 7]`. If we use `sparse_to_dense` on this array, we get,

```python
>>> import numpy as np
>>> from sktime.annotation.base._base import BaseSeriesAnnotator
>>> y_sparse = np.array([2, 5, 7])  # Indices of changepoints/anomalies
>>> BaseSeriesAnnotator.sparse_to_dense(y_sparse)
array([0, 0, 1, 0, 0, 1, 0, 1], dtype=int32)
```

This assumes that input `X` used to generate the array of anomalies contains 8 samples. This assumption is incorrect. To solve this, we need to pass the length of `X` to `sparse_to_dense` so we can pad the output with 0's such that it has the same length as `X`; this feels a little clumsy to me. We could make `dense_to_sparse` a private method that is only called from `transform` but I think `dense_to_sparse`/`sparse_to_dense` are handy bits of functionality that would be shame to lose.

* Do you think `dense_to_sparse`/`sparse_to_dense` would be useful to provide to users?
* Do you think passing the length of `X` to `sparse_to_dense` feels clumsy? If so can you think of an alternative?
fkiraly: Interesting. My opinion below.

### on the design

I think the key observation is that the functions `sparse_to_dense` and `dense_to_sparse` are meant to be inverse to each other. However, mathematically, from an "abstract data type" perspective, this is only true, and can only be true, given knowledge of the index of `X`.

In the "numpy" situation, the index of `X` is an abstract `RangeIndex`, and it is entirely determined by its length. Therefore, including length does not feel clumsy to me, but mathematically necessary.

### as public function

I also think it is beneficial to have these functions, at least we shouldn't remove them as some users might have been using them. I would add a length parameter, with a default that leads to current behaviour.

I would also leave them external, since very long-term, I think this should live in an abstract conversion module similar to `datatypes.convert` and adjacent functionality, and leaving them loose is closer to centralizing this functionaliy.

A similar case would be converting between different representations of segmentations.
duydl: > Do you think passing the length of X to sparse_to_dense feels clumsy? If so can you think of an alternative?

The change points outputs of ruptures library are sparse but include X length at the last index. This could be an alternative that makes sparse more equivalent to dense format. It might also be a little confusing though.

> Do you think dense_to_sparse/sparse_to_dense would be useful to provide to users?

@fkiraly I think these are new, so we don't have to worry about legacy problems. 

The functions would allow users to convert outputs to other formats directly. Do we still need the other workflow then; calling class methods again on the same input to get different formats would require either caching input and output or refitting, at least for the current estimators?

btw, great work on the converting methods. I have been wondering how to distinguish between dense format of two-class segmentation and change points. Some further additions I could think of:
* function converting from scores to labels
* check if y is not float in dense to sparse 



fkiraly: Interesting analysis, @duydl.

> The change points outputs of ruptures library are sparse but include X length at the last index. This could be an alternative that makes sparse more equivalent to dense format. It might also be a little confusing though.

Ouch - I agree with the "confusing" remark, this is basically a "data type representation hack", I would not recommend that.

I would take this as a validation of the observation that the information on index (including length) is crucial.

> @fkiraly I think these are new, so we don't have to worry about legacy problems.

Are they? Sorry, I haven't been monitoring the module closely.

If they are, how about we hide them in a private module for the time being, but keep them external to the class:

* private module gives us more flexibility to make breaking changes as wel consolidate the interface
* class external gives us more flexibility to move the eventual location, and avoid "mudballing" around these


Minor further points raised:

> function converting from scores to labels

Will this not be algorithm specific?

> i don't think sparse change point is equivalent to sparse segment i.e convertible

It is not equivalent, but there's a "canonical" one-way conversion, I thought?
Please correct me if you disagree.
duydl: > It is not equivalent, but there's a "canonical" one-way conversion, I thought?

Sorry, it was a little out of context. I am referencing to the code, there is a `change_points_to_segments(y_sparse):` that I don't think is feasible. 
fkiraly: @Alex-JG3, can you kindly give an update on timelines? I would like to get a working version in before the workstreams kick off May 1-7, to enable refactoring and extension.

Let me know if you don't find time, then I'll complete it.
Alex-JG3: Re the length issues, I have gone for adding it as an argument to `sparse_to_dense`.

> Are they? Sorry, I haven't been monitoring the module closely.

Yes they are new. Other annotators implement their own versions of these functions but there is no consistent way of doing these conversions.

@duydl, regarding your suggestions.

> function converting from scores to labels

I have left out the scoring for the time being. I think this stuff should go in another PR.

> check if y is not float in dense to sparse

Yes, I agree. I would like to leave this to a future PR.

> I think Dense scores should also be a possible output of transform.

I would prefer something like `score_samples` a bit like Isolation forests in sklearn. The output would be dense. https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.IsolationForest.html. Again, I would prefer to leave this for another PR.

## Timelines

Here is where I think this PR is for the changes listed at the top.

[x] Removes the `fmt` and `label` attributes.
[x] Adds the `learning_type` and `task` attributes.
[x] Adds default `predict_points` and `predict_segments` methods.
[x] Adds methods for converting between dense and sparse output formats.
[ ] Adds default methods for predict and transform

For the last one, there is default transform method but not a default predict method. I think a default predict method should be added when we start refactoring some of the annotators to use `predict_segments`/`predict_points`. All of the current annotators use `predict` so I think it would break too many of them to make the change here.

In terms of changes to `BaseSeriesAnnotator`, I think this is a natural place to stop for the time being. Please let me know if there are other things you think still need to be added.

## Extras things that should be added to this PR

1. Add new tags to the registry.
2. Unit tests for `BaseSeriesAnnotator`.
3. Update one of the algorithms (probably ClaSP) to use some of the new features of the `BaseSeriesAnnotator`.
4. Put `sparse_to_dense` and `dense_to_sparse` into their own private module.

@fkiraly, do you agree with this list? I think it would be easier if you, @fkiraly, handle 1 since I am not sure what you mean by this. For 2, I think the main methods to test for the time being are `dense_to_sparse` and `sparse_to_dense`. For 3, again I am happy to take a stab at this. For 4 , @fkiraly could you please take a look at this since I am not sure exactly what you mean? 

I can work on this tomorrow evening, Saturday and Sunday.

Also it would be good to get a review.
fkiraly: Thanks for the update! I think this is indeed close to finish, and the next steps as suggested make a lot of sense.

To answer in more detail:

>  I think it would be easier if you, @fkiraly, handle 1 since I am not sure what you mean by this

I think the tags have already been added, in `registry._tags` - a while back you asked the same already, so it's done.

> For 4 , @fkiraly could you please take a look at this since I am not sure exactly what you mean?

I don't think this is strictly necessary - I just meant, moving it to a different file that starts with an underscore, and importing from there, just to keep the separation. Not a blocker imo though.
Alex-JG3: @fkiraly, I have had a first rough attempt at refactoring `ClaSPSegmentation`. There are a few of things that would be good to get your feedback on:

* Despite the class name this algorithm seems to be closer to a change point detection algorithm than a segmentation algorithm. I have thus switched its `task` to `change_point_detection`.
* Since I have not changed `predict` in `BaseSeriesAnnotator` yet (since it will break a lot of the other annotators), I have had to implement `_predict` in `ClaSPSegmentation`.
* The `_predict_scores` method relied on `self.fmt` so I have changed it to return a tuple of scores and profiles for the time being.
fkiraly: > Despite the class name this algorithm seems to be closer to a change point detection algorithm than a segmentation algorithm. 

Yes, I agree, especially due to the sliding window approach applied internally.

It seems explicitly misclassified in the original publication as well - which is not unusual though, as there is a close correspondence  due to the 1:1 reduction mapping via "segments are the intervals between two adjacent changepoints".

Though the difference is also very subtle - if within-segment behaviour is qualitatively stationary, there is no generative difference between the two.

But, long story short, I would indeed classify it as changepoint detection algorithm.

> I have had to implement `_predict` in `ClaSPSegmentation`.
> The `_predict_scores` method relied on self.fmt so I have changed it to return a tuple of scores and profiles for the time being.

This makes sense to me, I think it is directly implied by the target interface definition.
fkiraly: PS: apologies for killing your CI multiple times - just giving the 0.29.0 release the right of way...
Alex-JG3: @fkiraly, the pipeline is failing due to a dependency that cannot be installed. Could you help with this? I am not sure what the problem is.
fkiraly: I cannot see the logs due to this issue with the logs: https://github.com/sktime/skpro/issues/304

However, if it is something about a SHA not matching, restarting the failed jobs should fix it, it is sporadic.
fkiraly: @benHeid or @yarnabrina, can you see the logs?
yarnabrina: Thus was the error

```
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    unknown package:
        Expected sha256 55b380cff92d493e465b23e96a4bce8eec1a7b02db92c7718bb0d883f244c2b9
             Got        f8a96f8553d80288955fd9fa54bb4f9e6aec5df2028074a8cc65a11510af611a
```

I restarted that job, hopefully it'd work this time. 

(Your logs issue by AVG is very weird, we need to find a way around soon I think) 
fkiraly: Tests pass - is this ready for a "first slice merge"?
Alex-JG3: > tests pass - is this ready for a "first slice merge"?

I would like to check this over one final time before merging. I have switched over to using `pandas.IntervalIndex` meaning I have also switched over from using an `iloc` style to a `loc` style like we discussed earlier. I think this will be better in the end but there are still a few rough edges.

I have time to work on this again on Thursday.
Alex-JG3: I am marking this as ready for review but there is one thing I haven't quite been able to find a good solution for. I want `dense_to_sparse` and `sparse_to_dense` to be opposites of each other.

Suppose we have the following dense segments.

```
[1, 1, 2, 2, 3, 3]
```

The sparse format of this would be,

```
[0, 2)  1
[2, 4)  2
[4, 5)  3
```

Converting this back into dense would give us,

```
[1, 1, 2, 2, 3, -1]
```

From the sparse representation, index 5 is not included in the interval for the third segment hence it is labelled -1. Here are a few options I considered for dealing with this:

## Set the final end point to infinity

If we have dense segments like,

```
[1, 1, 2, 2, 3, 3]
```

We cannot say when the third segment ends so we should assume it never ends. So the dense form would look like,

```
[0, 2)    1
[2, 4)    2
[4, Inf)  3
```

This might be okay for integers/floats but having infinite datetimes is nasty.

## Include the right limit of the final segment

Suppose again we have dense segments like,

```
[1, 1, 2, 2, 3, 3]
```

And in sparse format,

```
[0, 2)  1
[2, 4)  2
[4, 5)  3
```

When we convert back to dense, we could just say index 5 is labelled 3. This felt like it was going against the information in the sparse format and potentially confusing.

## Use closed intervals

I have opted for using left closed intervals. We could use left and right closed intervals like,

```
[0, 2]  1
[2, 4]  2
[4, 5]  3
```

But now index 2 is labelled both 1 and 2. I think using right closed intervals is less ambiguous. Unfortunately you don't seem to able to use a mix of left and right closed intervals.

The current solution feels like the best of a bad bunch. Please let me know what you think or if you have any other solutions.
fkiraly: I am currently reviewing and will write a longer review.

Regarding the last question, would a solution not listed clear things up: transforming the last interval to `[4, 6)` ?
Alex-JG3: > I am currently reviewing and will write a longer review.
> 
> Regarding the last question, would a solution not listed clear things up: transforming the last interval to `[4, 6)` ?

For that example yes but I think it becomes less clear if we have an index that is not equally spaces. For example, taking the example from before and making it into a proper pandas series we get,

```
0 1
1 1
2 2
3 2
4 3
5 3
```

the interval of the final segment could be `[4, 6)` and that would be sensible. But suppose the index is not equally spaces.

```
0.1 1
1.1 1
2.2 2
2.3 2
4.1 3
4.3 3
```

For example, I don’t think an interval of `[4.1, 5.1)` would be sensible for the third segment. For completeness, the sparse format of the series above would in the current implementation would be,

```
[0.1, 2.2) 1
[2.2, 4.1) 2
[4.1, 4.3) 3
```

fkiraly: > For that example yes but I think it becomes less clear if we have an index that is not equally spaces. 

I see. How about making the last interval run over by the last difference, then?
In the unequally spaced example, [4.1, 4.) then.

I would also like to summon @vascosch92 who might have a useful opinion here, given sequences are involved.

The greater question is coming up with mutually inverse functions from a value representation to a segment representation, as above. This is not possible if the target index is not known (only partial inverses), but the function assume we know `X.index` - see above, discussion starts here in a self-contained form: https://github.com/sktime/sktime/pull/6265#issuecomment-2118828155
VascoSch92: > I would also like to summon @VascoSch92 who might have a useful opinion here, given both sequences and sets are involved at the same time - hope you enjoy this 😁
:-) 

Just some thoughts from my side:

I think the solution of @Alex-JG3 is good. What are you doing here is to represent in an intelligent way the values of a column/serie, i.e., you want to group interval of **contiguous** indexes where the column/serie is constant.

After that if you want to consider closed/open/right-closed/... interval, it is just taste, but it should be written clearly in the documentation or/and in the docstring.

The choice of taking left-closed and right-open interval ( for example  `[4.1, 4.5)` ) seems legit, as it is also what Python do with lists and slicing, i.e., a list start from index `0` and ends at index `length-1`.

> For that example yes but I think it becomes less clear if we have an index that is not equally spaces.
I see your point but 
- the index of a `df` is always monotonically increasing (is correct? I'm not sure now)
- you said that you consider interval of contiguous indices with the same value.
Therefore, it should be clear.


fkiraly: > the index of a df is always monotonically increasing (is correct? I'm not sure now)

Not for `pandas.DataFrame` in general, but we can enforce this in our output assumptions (should be tested, let's make a note of this!)
Alex-JG3: > the index of a df is always monotonically increasing (is correct? I'm not sure now)

No the index is not currently always monotonically increasing but it should be. In fact I think some of the new methods will break if index is not monotonically increasing. We need to enforce this.

I have added deprecation warnings in clasp and the pyod annotator. I think those are the only two classes that are affected by deprecation. Currently, the deprecation message feels very brief:

```
The fmt argument is going to be removed.
```

Is this sufficient? Do we want to point a specific version when these arguments will be depracted?
fkiraly: > Is this sufficient? Do we want to point a specific version when these arguments will be depracted?

I would say sth like

`f"Warning from {type(self).__name__}: fmt argument will be removed in 0.31.0. For behaviour equivalent to current fmt=a usage, do X; for fmt=b usage, do Y"`
Alex-JG3: I have rewritten the deprecation warning. I have also removed the deprecation warning for `labels` for the pyod adapter since the changes to `BaseSeriesAnnotator` do not deal with scores properly yet. This will come in a future PR.

If everyone else is happy are we ready to merge?
Alex-JG3: Could someone please rerun the CI/CD pipeline? Looks like the docs jobs has failed due to running out of time to build. I don't think this as a result of changes introduced in this PR.
fkiraly: Restarted - I also updated the deprecation messages and added release manager notes.

Question, it seems we want to remove the `labels` param in `PyODAnnotator` too - should there be a deprecation warning as well?
fkiraly: ok, let´s leave `indicator` in for now, then.

Patch:
diff --git a/extension_templates/annotation.py b/extension_templates/annotation.py
index e01c6678a..02b256596 100644
--- a/extension_templates/annotation.py
+++ b/extension_templates/annotation.py
@@ -42,16 +42,6 @@ class MySeriesAnnotator(BaseSeriesAnnotator):
 
     Parameters
     ----------
-    fmt : str {"dense", "sparse"}, optional (default="dense")
-        Annotation output format:
-        * If "sparse", a sub-series of labels for only the outliers in X is returned,
-        * If "dense", a series of labels for all values in X is returned.
-    labels : str {"indicator", "score"}, optional (default="indicator")
-        Annotation output labels:
-        * If "indicator", returned values are boolean, indicating whether a value is an
-        outlier,
-        * If "score", returned values are floats, giving the outlier score.
-
     parama : int
         descriptive explanation of parama
     paramb : string, optional (default='default')
@@ -69,6 +59,12 @@ class MySeriesAnnotator(BaseSeriesAnnotator):
     and so on
     """
 
+    # Change the `task` and `learning_type` as needed
+    _tags = {
+        "task": "segmentation",
+        "learning_type": "unsupervised",
+    }
+
     # todo: add any hyper-parameters and components to constructor
     def __init__(
         self,
@@ -77,8 +73,6 @@ class MySeriesAnnotator(BaseSeriesAnnotator):
         est2=None,
         paramb="default",
         paramc=None,
-        fmt="dense",
-        labels="indicator",
     ):
         # estimators should precede parameters
         #  if estimators have default values, set None and initialize below
@@ -89,8 +83,7 @@ class MySeriesAnnotator(BaseSeriesAnnotator):
         self.paramb = paramb
         self.paramc = paramc
 
-        # leave this as is
-        super().__init__(fmt=fmt, labels=labels)
+        super().__init__()
 
         # todo: optional, parameter checking logic (if applicable) should happen here
         # if writes derived values to self, should *not* overwrite self.parama etc
diff --git a/sktime/annotation/adapters/_pyod.py b/sktime/annotation/adapters/_pyod.py
index 99504f638..89ba16130 100644
--- a/sktime/annotation/adapters/_pyod.py
+++ b/sktime/annotation/adapters/_pyod.py
@@ -7,6 +7,7 @@ from sklearn.base import clone
 
 from sktime.annotation.base._base import BaseSeriesAnnotator
 from sktime.utils.validation._dependencies import _check_soft_dependencies
+from sktime.utils.warnings import warn
 
 __author__ = ["mloning", "satya-pattnaik", "fkiraly"]
 
@@ -21,22 +22,34 @@ class PyODAnnotator(BaseSeriesAnnotator):
     estimator : PyOD estimator
         See ``https://pyod.readthedocs.io/en/latest/`` documentation for a detailed
         description of all options.
-    fmt : str {"dense", "sparse"}, optional (default="dense")
-        Annotation output format:
-        * If "sparse", a sub-series of labels for only the outliers in X is returned,
-        * If "dense", a series of labels for all values in X is returned.
-    labels : str {"indicator", "score"}, optional (default="indicator")
-        Annotation output labels:
-        * If "indicator", returned values are boolean, indicating whether a value is an
-        outlier,
-        * If "score", returned values are floats, giving the outlier score.
     """
 
-    _tags = {"python_dependencies": "pyod"}
+    _tags = {
+        "python_dependencies": "pyod",
+        "task": "anomaly_detection",
+        "learning_type": "unsupervised",
+    }
 
-    def __init__(self, estimator, fmt="dense", labels="indicator"):
+    # todo 0.31.0: remove fmt argument and warning
+    def __init__(self, estimator, fmt="deprecated", labels="indicator"):
         self.estimator = estimator  # pyod estimator
-        super().__init__(fmt=fmt, labels=labels)
+        self.fmt = fmt
+        self.labels = labels
+
+        super().__init__()
+
+        if fmt == "deprecated":
+            self._fmt = "sparse"
+            warn(
+                f"Warning from {type(self).__name__}: fmt argument will be removed in"
+                " 0.31.0. For behaviour equivalent to fmt=dense, use transform instead "
+                "of predict. In 0.31.0 the behaviour of predict will equivalent to the"
+                " current behaviour of predict when fmt=sparse.",
+                DeprecationWarning,
+                obj=self,
+            )
+        else:
+            self._fmt = fmt
 
     def _fit(self, X, Y=None):
         """Fit to training data.
@@ -77,9 +90,8 @@ class PyODAnnotator(BaseSeriesAnnotator):
         Returns
         -------
         Y : pd.Series - annotations for sequence X
-            exact format depends on annotation type
         """
-        fmt = self.fmt
+        fmt = self._fmt
         labels = self.labels
 
         X_np = X.to_numpy()
diff --git a/sktime/annotation/base/_base.py b/sktime/annotation/base/_base.py
index 4d14b50f6..d7ec54a5a 100644
--- a/sktime/annotation/base/_base.py
+++ b/sktime/annotation/base/_base.py
@@ -22,27 +22,32 @@ State:
 __author__ = ["satya-pattnaik ", "fkiraly"]
 __all__ = ["BaseSeriesAnnotator"]
 
+import numpy as np
+import pandas as pd
+
 from sktime.base import BaseEstimator
-from sktime.utils.validation.annotation import check_fmt, check_labels
 from sktime.utils.validation.series import check_series
 
 
 class BaseSeriesAnnotator(BaseEstimator):
     """Base series annotator.
 
-    Parameters
-    ----------
-    fmt : str {"dense", "sparse"}, optional (default="dense")
-        Annotation output format:
-        * If "sparse", a sub-series of labels for only the outliers in X is returned,
-        * If "dense", a series of labels for all values in X is returned.
-    labels : str {"indicator", "score", "int_label"}, optional (default="indicator")
-        Annotation output labels:
-        * If "indicator", returned values are boolean, indicating whether a value is an
-        outlier,
-        * If "score", returned values are floats, giving the outlier score.
-        * If "int_label", returned values are integers indicating which segment the
-        value belongs to.
+    Developers should set the task and learning_type tags in the derived class.
+
+    task : str {"segmentation", "change_point_detection", "anomaly_detection"}
+        The main annotation task:
+        * If ``segmentation``, the annotator divides timeseries into discrete chunks
+        based on certain criteria. The same label can be applied at mulitple
+        disconnected regions of the timeseries.
+        * If ``change_point_detection``, the annotator finds points where the
+        statistical properties of the timeseries change significantly.
+        * If ``anomaly_detection``, the annotator finds points that differ significantly
+        from the normal statistical properties of the timeseries.
+
+    learning_type : str {"supervised", "unsupervised"}
+        Annotation learning type:
+        * If ``supervised``, the annotator learns from labelled data.
+        * If ``unsupervised``, the annotator learns from unlabelled data.
 
     Notes
     -----
@@ -58,12 +63,16 @@ class BaseSeriesAnnotator(BaseEstimator):
 
     _tags = {
         "object_type": "series-annotator",  # type of object
+        "learning_type": "None",  # Tag to determine test in test_all_annotators
+        "task": "None",  # Tag to determine test in test_all_annotators
+        #
+        # todo: distribution_type? we may have to refactor this, seems very soecufuc
         "distribution_type": "None",  # Tag to determine test in test_all_annotators
     }  # for unit test cases
 
-    def __init__(self, fmt="dense", labels="indicator"):
-        self.fmt = fmt
-        self.labels = labels
+    def __init__(self):
+        self.task = self.get_class_tag("task")
+        self.learning_type = self.get_class_tag("learning_type")
 
         self._is_fitted = False
 
@@ -92,8 +101,6 @@ class BaseSeriesAnnotator(BaseEstimator):
         Creates fitted model that updates attributes ending in "_". Sets
         _is_fitted flag to True.
         """
-        check_labels(self.labels)
-        check_fmt(self.fmt)
         X = check_series(X)
 
         if Y is not None:
@@ -134,6 +141,27 @@ class BaseSeriesAnnotator(BaseEstimator):
 
         return Y
 
+    def transform(self, X):
+        """Create annotations on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate (time series).
+
+        Returns
+        -------
+        Y : pd.Series
+            Annotations for sequence X. The returned annotations will be in the dense
+            format.
+        """
+        if self.task == "anomaly_detection" or self.task == "change_point_detection":
+            Y = self.predict_points(X)
+        elif self.task == "segmentation":
+            Y = self.predict_segments(X)
+
+        return self.sparse_to_dense(Y, X.index)
+
     def predict_scores(self, X):
         """Return scores for predicted annotations on test/deployment data.
 
@@ -314,3 +342,341 @@ class BaseSeriesAnnotator(BaseEstimator):
         self._fit(self._X, self._Y)
 
         return self
+
+    def predict_segments(self, X):
+        """Predict segments on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            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.
+        """
+        if self.task == "anomaly_detection":
+            raise RuntimeError(
+                "Anomaly detection annotators should not be used for segmentation."
+            )
+        self.check_is_fitted()
+        X = check_series(X)
+
+        if self.task == "change_point_detection":
+            return self.segments_to_change_points(self.predict_points(X))
+        elif self.task == "segmentation":
+            return self._predict_segments(X)
+
+    def predict_points(self, X):
+        """Predict changepoints/anomalies on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            A series whose values are the changepoints/anomalies in X.
+        """
+        self.check_is_fitted()
+        X = check_series(X)
+
+        if self.task == "anomaly_detection" or self.task == "change_point_detection":
+            return self._predict_points(X)
+        elif self.task == "segmentation":
+            return self.segments_to_change_points(self.predict_segments(X))
+
+    def _predict_segments(self, X):
+        """Predict segments on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            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.
+        """
+        raise NotImplementedError("abstract method")
+
+    def _predict_points(self, X):
+        """Predict changepoints/anomalies on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            A series whose values are the changepoints/anomalies in X.
+        """
+        raise NotImplementedError("abstract method")
+
+    @staticmethod
+    def sparse_to_dense(y_sparse, index):
+        """Convert the sparse output from an annotator to a dense format.
+
+        Parameters
+        ----------
+        y_sparse : pd.Series
+            * If ``y_sparse`` is a series with an index of intervals, it should
+              represent segments where each value of the series is label of a segment.
+              Unclassified intervals should be labelled -1. Segments must never have
+              the label 0.
+            * If the index of ``y_sparse`` is not a set of intervals, the values of the
+              series should represent the indexes of changepoints/anomalies.
+        index : array-like
+            Indices that are to be annotated according to ``y_sparse``.
+
+        Returns
+        -------
+        pd.Series
+            A series with an index of ``index`` is returned.
+            * If ``y_sparse`` is a series of changepoints/anomalies then the returned
+              series is labelled 0 and 1 dependendy on whether the index is associated
+              with an anomaly/changepoint. Where 1 means anomaly/changepoint.
+            * If ``y_sparse`` is a series of segments then the returned series is
+              labelled depending on the segment its indexes fall into. Indexes that
+              fall into no segments are labelled -1.
+
+        Examples
+        --------
+        >>> import pandas as pd
+        >>> from sktime.annotation.base._base import BaseSeriesAnnotator
+        >>> y_sparse = pd.Series([2, 5, 7])  # Indices of changepoints/anomalies
+        >>> index = range(0, 8)
+        >>> BaseSeriesAnnotator.sparse_to_dense(y_sparse, index=index)
+        0    0
+        1    0
+        2    1
+        3    0
+        4    0
+        5    1
+        6    0
+        7    1
+        dtype: int64
+        >>> y_sparse = pd.Series(
+        ...     [1, 2, 1],
+        ...     index=pd.IntervalIndex.from_arrays(
+        ...         [0, 4, 6], [4, 6, 10], closed="left"
+        ...     )
+        ... )
+        >>> index = range(10)
+        >>> BaseSeriesAnnotator.sparse_to_dense(y_sparse, index=index)
+        0    1
+        1    1
+        2    1
+        3    1
+        4    2
+        5    2
+        6    1
+        7    1
+        8    1
+        9    1
+        dtype: int64
+        """
+        if isinstance(y_sparse.index.dtype, pd.IntervalDtype):
+            # Segmentation case
+            y_dense = BaseSeriesAnnotator._sparse_segments_to_dense(y_sparse, index)
+            return y_dense
+        else:
+            # Anomaly/changepoint detection case
+            y_dense = BaseSeriesAnnotator._sparse_points_to_dense(y_sparse, index)
+            return y_dense
+
+    @staticmethod
+    def _sparse_points_to_dense(y_sparse, index):
+        """Label the indexes in ``index`` if they are in ``y_sparse``.
+
+        Parameters
+        ----------
+        y_sparse: pd.Series
+            The values of the series must be the indexes of changepoints/anomalies.
+        index: array-like
+            Array of indexes that are to be labelled according to ``y_sparse``.
+
+        Returns
+        -------
+        pd.Series
+            A series with an index of ``index``. Its values are 1 if the index is in
+            y_sparse and 0 otherwise.
+        """
+        y_dense = pd.Series(np.zeros(len(index)), index=index, dtype="int64")
+        y_dense[y_sparse.values] = 1
+        return y_dense
+
+    @staticmethod
+    def _sparse_segments_to_dense(y_sparse, index):
+        """Find the label for each index in ``index`` from sparse segments.
+
+        Parameters
+        ----------
+        y_sparse : pd.Series
+            A sparse representation of segments. The index must be the pandas interval
+            datatype and the values must be the integer labels of the segments.
+        index : array-like
+            List of indexes that are to be labelled according to ``y_sparse``.
+
+        Returns
+        -------
+        pd.Series
+            A series with the same index as ``index`` where each index is labelled
+            according to ``y_sparse``. Indexes that do not fall within any index are
+            labelled -1.
+        """
+        if y_sparse.index.is_overlapping:
+            raise NotImplementedError(
+                "Cannot convert overlapping segments to a dense formet yet."
+            )
+
+        interval_indexes = y_sparse.index.get_indexer(index)
+
+        # Negative indexes do not fall within any interval so they are ignored
+        interval_labels = y_sparse.iloc[
+            interval_indexes[interval_indexes >= 0]
+        ].to_numpy()
+
+        # -1 is used to represent points do not fall within a segment
+        labels_dense = interval_indexes.copy()
+        labels_dense[labels_dense >= 0] = interval_labels
+
+        y_dense = pd.Series(labels_dense, index=index)
+        return y_dense
+
+    @staticmethod
+    def dense_to_sparse(y_dense):
+        """Convert the dense output from an annotator to a sparse format.
+
+        Parameters
+        ----------
+        y_dense : pd.Series
+            * If ``y_sparse`` contains only 1's and 0's, the 1's represent change
+              points or anomalies.
+            * If ``y_sparse`` contains only contains integers greater than 0, it is an
+              an array of segments.
+
+        Returns
+        -------
+        pd.Series
+            * If ``y_sparse`` is a series of changepoints/anomalies, a pandas series
+              will be returned containing the indexes of the changepoints/anomalies
+            * If ``y_sparse`` is a series of segments, a series with an interval
+              datatype index will be returned. The values of the series will be the
+              labels of segments.
+        """
+        if 0 in y_dense.values:
+            # y_dense is a series of change points
+            change_points = np.where(y_dense.values != 0)[0]
+            return pd.Series(change_points)
+        else:
+            segment_start_indexes = np.where(y_dense.diff() != 0)[0]
+            segment_end_indexes = np.roll(segment_start_indexes, -1)
+
+            # The final index is always the end of a segment
+            segment_end_indexes[-1] = y_dense.index[-1]
+
+            segment_labels = y_dense.iloc[segment_start_indexes].to_numpy()
+            interval_index = pd.IntervalIndex.from_arrays(
+                segment_start_indexes, segment_end_indexes, closed="left"
+            )
+            y_sparse = pd.Series(segment_labels, index=interval_index)
+
+            # -1 represents unclassified regions so we remove them
+            y_sparse = y_sparse.loc[y_sparse != -1]
+            return y_sparse
+
+    @staticmethod
+    def change_points_to_segments(y_sparse, start=None, end=None):
+        """Convert an series of change point indexes to segments.
+
+        Parameters
+        ----------
+        y_sparse : pd.Series
+            A series containing the indexes of change points.
+        start : optional
+            Starting point of the first segment.
+        end : optional
+            Ending point of the last segment
+
+        Returns
+        -------
+        pd.Series
+            A series with an interval index indicating the start and end points of the
+            segments. The values of the series are the labels of the segments.
+
+        Examples
+        --------
+        >>> import pandas as pd
+        >>> from sktime.annotation.base._base import BaseSeriesAnnotator
+        >>> change_points = pd.Series([1, 2, 5])
+        >>> BaseSeriesAnnotator.change_points_to_segments(change_points, 0, 7)
+        [0, 1)   -1
+        [1, 2)    1
+        [2, 5)    2
+        [5, 7)    3
+        dtype: int64
+        """
+        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:
+            breaks = np.insert(breaks, 0, start)
+        if end is not None:
+            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
+
+        number_of_segments = in_range.sum()
+        segments.loc[in_range] = range(1, number_of_segments + 1)
+        segments.loc[~in_range] = -1
+
+        return segments
+
+    @staticmethod
+    def segments_to_change_points(y_sparse):
+        """Convert segments to change points.
+
+        Parameters
+        ----------
+        y_sparse : pd.DataFrame
+            A series of segments. The index must be the interval data type and the
+            values should be the integer labels of the segments.
+
+        Returns
+        -------
+        pd.Series
+            A series containing the indexes of the start of each segment.
+
+        Examples
+        --------
+        >>> import pandas as pd
+        >>> from sktime.annotation.base._base import BaseSeriesAnnotator
+        >>> segments = pd.Series(
+        ...     [3, -1, 2],
+        ...     index=pd.IntervalIndex.from_breaks([2, 5, 7, 9], closed="left")
+        ... )
+        >>> BaseSeriesAnnotator.segments_to_change_points(segments)
+        0    2
+        1    5
+        2    7
+        dtype: int64
+        """
+        change_points = pd.Series(y_sparse.index.left)
+        return change_points
diff --git a/sktime/annotation/clasp.py b/sktime/annotation/clasp.py
index 63a32faf5..c0ca92b83 100644
--- a/sktime/annotation/clasp.py
+++ b/sktime/annotation/clasp.py
@@ -23,6 +23,7 @@ import pandas as pd
 
 from sktime.transformations.series.clasp import ClaSPTransformer
 from sktime.utils.validation.series import check_series
+from sktime.utils.warnings import warn
 
 
 def find_dominant_window_sizes(X, offset=0.05):
@@ -188,10 +189,6 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
         size of window for sliding, based on the period length of the data
     n_cps :                 int, default = 1
         the number of change points to search
-    fmt :                  str {"dense", "sparse"}, optional (default="sparse")
-        Annotation output format:
-        * If "sparse", a pd.Series of the found Change Points is returned
-        * If "dense", a pd.IndexSeries with the Segmentation of X is returned
     exclusion_radius : int
         Exclusion Radius for change points to be non-trivial matches
 
@@ -219,16 +216,36 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
     """
 
     _tags = {
+        "task": "change_point_detection",
+        "learning_type": "unsupervised",
         "univariate-only": True,
         "fit_is_empty": True,
         "python_dependencies": "numba",
     }  # for unit test cases
 
-    def __init__(self, period_length=10, n_cps=1, fmt="sparse", exclusion_radius=0.05):
+    # todo 0.31.0: remove fmt argument, remove _fmt attribute and warning
+    def __init__(
+        self, period_length=10, n_cps=1, fmt="deprecated", exclusion_radius=0.05
+    ):
         self.period_length = int(period_length)
         self.n_cps = n_cps
         self.exclusion_radius = exclusion_radius
-        super().__init__(fmt)
+        self.fmt = fmt
+
+        super().__init__()
+
+        if fmt == "deprecated":
+            self._fmt = "sparse"
+            warn(
+                f"Warning from {type(self).__name__}: fmt argument will be removed in"
+                " 0.31.0. For behaviour equivalent to fmt=dense, use transform instead "
+                "of predict. In 0.31.0 the behaviour of predict will equivalent to the"
+                " current behaviour of predict when fmt=sparse.",
+                DeprecationWarning,
+                obj=self,
+            )
+        else:
+            self._fmt = fmt
 
     def _fit(self, X, Y=None):
         """Do nothing, as there is no need to fit a model for ClaSP.
@@ -257,19 +274,30 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
         Returns
         -------
         Y : pd.Series or an IntervalSeries
-            Annotations for sequence X exact format depends on annotation type.
-            fmt=sparse : only the found change point locations are returned
-            fnt=dense : an interval series is returned which contains the segmetation.
+            Change points in sequence X.
         """
-        self.found_cps, self.profiles, self.scores = self._run_clasp(X)
+        change_points = self._predict_points(X)
+        if self._fmt == "dense":
+            return self.change_points_to_segments(
+                change_points, X.index.min(), X.index.max()
+            )
+        return change_points
 
-        # Change Points
-        if self.fmt == "sparse":
-            return pd.Series(self.found_cps)
+    def _predict_points(self, X):
+        """Predict changepoints on test/deployment data.
 
-        # Segmentation
-        elif self.fmt == "dense":
-            return self._get_interval_series(X, self.found_cps)
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            Series containing the indexes of the changepoints in X.
+        """
+        self.found_cps, self.profiles, self.scores = self._run_clasp(X)
+        return pd.Series(self.found_cps)
 
     def _predict_scores(self, X):
         """Return scores in ClaSP's profile for each annotation.
@@ -286,15 +314,15 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
         """
         self.found_cps, self.profiles, self.scores = self._run_clasp(X)
 
-        # Scores of the Change Points
-        if self.fmt == "sparse":
-            return pd.Series(self.scores)
-
-        # Full Profile of Segmentation
-        # ClaSP creates multiple profiles. Hard to map.
-        # Thus, we return the main (first) one
-        elif self.fmt == "dense":
-            return pd.Series(self.profiles[0])
+        if self._fmt == "sparse":
+            # Scores of the Change Points
+            scores = pd.Series(self.scores)
+            return scores
+        elif self._fmt == "dense":
+            # ClaSP creates multiple profiles. Hard to map. Thus, we return the main
+            # (first) one
+            profile = pd.Series(self.profiles[0])
+            return profile
 
     def get_fitted_params(self):
         """Get fitted parameters.
diff --git a/sktime/annotation/ggs.py b/sktime/annotation/ggs.py
index 9861df340..db2c9a8a6 100644
--- a/sktime/annotation/ggs.py
+++ b/sktime/annotation/ggs.py
@@ -425,7 +425,11 @@ class GreedyGaussianSegmentation(BaseSeriesAnnotator):
        https://doi.org/10.1007/s11634-018-0335-0
     """
 
-    _tags = {"fit_is_empty": True}
+    _tags = {
+        "fit_is_empty": True,
+        "task": "segmentation",
+        "learning_type": "unsupervised",
+    }
 
     def __init__(
         self,
@@ -443,7 +447,7 @@ class GreedyGaussianSegmentation(BaseSeriesAnnotator):
         self.random_state = random_state
 
         _check_estimator_deps(self)
-        super().__init__(fmt="dense", labels="int_label")
+        super().__init__()
 
         self._adaptee = GGS(
             k_max=k_max,
diff --git a/sktime/annotation/hmm.py b/sktime/annotation/hmm.py
index bc65dcde4..42c5fc9d1 100644
--- a/sktime/annotation/hmm.py
+++ b/sktime/annotation/hmm.py
@@ -128,7 +128,12 @@ class HMM(BaseSeriesAnnotator):
     """
 
     # plan to update to make multivariate.
-    _tags = {"univariate-only": True, "fit_is_empty": True}
+    _tags = {
+        "univariate-only": True,
+        "fit_is_empty": True,
+        "task": "segmentation",
+        "learning_type": "unsupervised",
+    }
 
     def __init__(
         self,
@@ -139,7 +144,7 @@ class HMM(BaseSeriesAnnotator):
         self.initial_probs = initial_probs
         self.emission_funcs = emission_funcs
         self.transition_prob_mat = transition_prob_mat
-        super().__init__(fmt="dense", labels="int_label")
+        super().__init__()
         self._validate_init()
 
     def _validate_init(self):
diff --git a/sktime/annotation/hmm_learn/base.py b/sktime/annotation/hmm_learn/base.py
index d43a22bab..15dae8e89 100644
--- a/sktime/annotation/hmm_learn/base.py
+++ b/sktime/annotation/hmm_learn/base.py
@@ -23,6 +23,8 @@ class BaseHMMLearn(BaseSeriesAnnotator):
         "univariate-only": True,
         "fit_is_empty": True,
         "python_dependencies": "hmmlearn",
+        "task": "segmentation",
+        "learning_type": "unsupervised",
     }  # for unit test cases
     _hmm_estimator = None
 
diff --git a/sktime/registry/_tags.py b/sktime/registry/_tags.py
index d5edd23be..7f418f5de 100644
--- a/sktime/registry/_tags.py
+++ b/sktime/registry/_tags.py
@@ -1651,6 +1651,24 @@ ESTIMATOR_TAG_REGISTER = [
         "bool",
         "whether estimator remembers all data seen as self._X, self._y, etc",
     ),
+    (
+        "distribution_type",
+        "estimator",
+        "str",
+        "distribution type of data as str",
+    ),
+    (
+        "task",
+        "series-annotator",
+        "str",
+        "subtype of series annotator, e.g., 'anomaly_detection', 'segmentation'",
+    ),
+    (
+        "learning_type",
+        "series-annotator",
+        "str",
+        "type of learning, e.g., 'supervised', 'unsupervised'",
+    ),
     (
         "reserved_params",
         "estimator",
diff --git a/sktime/utils/validation/annotation.py b/sktime/utils/validation/annotation.py
index 76b5e6ca1..bafad89c0 100644
--- a/sktime/utils/validation/annotation.py
+++ b/sktime/utils/validation/annotation.py
@@ -39,3 +39,32 @@ def check_labels(labels):
     valid_labels = ["indicator", "score", "int_label"]
     if labels not in valid_labels:
         raise ValueError(f"`labels` must be in: {valid_labels}, but found: {labels}.")
+
+
+def check_task(task):
+    """Check annotation task.
+
+    Parameters
+    ----------
+    task : str {"segmentation", "change_point_detection", "anomaly_detection"}
+        Annotation task.
+    """
+    valid_tasks = ["segmentation", "change_point_detection", "anomaly_detection"]
+    if task not in valid_tasks:
+        raise ValueError(f"`task` must be in: {valid_tasks}, but found: {task}.")
+
+
+def check_learning_type(learning_type):
+    """Check learning type.
+
+    Parameters
+    ----------
+    learning_type : str {"supervised", "unsupervised"}
+        Annotation Learning type.
+    """
+    valid_learning_types = ["supervised", "unsupervised"]
+    if learning_type not in valid_learning_types:
+        raise ValueError(
+            f"`learning_type` must be in: {valid_learning_types}, "
+            f"but found: {learning_type}."
+        )


Test patch:
diff --git a/sktime/annotation/tests/test_all_annotators.py b/sktime/annotation/tests/test_all_annotators.py
index bc819cf47..2054ec924 100644
--- a/sktime/annotation/tests/test_all_annotators.py
+++ b/sktime/annotation/tests/test_all_annotators.py
@@ -10,6 +10,7 @@ import pytest
 from sktime.registry import all_estimators
 from sktime.tests.test_switch import run_test_for_class
 from sktime.utils._testing.annotation import make_annotation_problem
+from sktime.utils.validation.annotation import check_learning_type, check_task
 
 ALL_ANNOTATORS = all_estimators(estimator_types="series-annotator", return_names=False)
 
@@ -31,3 +32,10 @@ def test_output_type(Estimator):
     )
     y_pred = estimator.predict(arg)
     assert isinstance(y_pred, (pd.Series, np.ndarray))
+
+
+@pytest.mark.parametrize("Estimator", ALL_ANNOTATORS)
+def test_annotator_tags(Estimator):
+    """Check the learning_type and task tags are valid."""
+    check_task(Estimator.get_class_tag("task"))
+    check_learning_type(Estimator.get_class_tag("learning_type"))
diff --git a/sktime/annotation/tests/test_base.py b/sktime/annotation/tests/test_base.py
new file mode 100644
index 000000000..53adb6ede
--- /dev/null
+++ b/sktime/annotation/tests/test_base.py
@@ -0,0 +1,136 @@
+"""Tests for the BaseSeriesAnnotator class."""
+
+__author__ = ["Alex-JG3"]
+__all__ = []
+
+import pandas as pd
+import pytest
+from pandas import testing
+
+from sktime.annotation.base._base import BaseSeriesAnnotator
+
+
+@pytest.mark.parametrize(
+    "y_sparse, y_dense_expected, index",
+    [
+        (pd.Series([1, 3]), pd.Series([0, 1, 0, 1]), pd.RangeIndex(0, 4, 1)),
+        (pd.Series([1, 3]), pd.Series([0, 1, 0, 1, 0, 0]), pd.RangeIndex(0, 6, 1)),
+        (
+            pd.Series(
+                [1, 2],
+                index=pd.IntervalIndex.from_arrays([0, 3], [3, 5], closed="left"),
+            ),
+            pd.Series([1, 1, 1, 2, 2, -1]),
+            pd.RangeIndex(0, 6, 1),
+        ),
+        (
+            pd.Series(
+                [1, 2],
+                index=pd.IntervalIndex.from_arrays([0, 3], [3, 5], closed="left"),
+            ),
+            pd.Series([1, 1, 1, 2, 2, -1, -1]),
+            pd.RangeIndex(0, 7, 1),
+        ),
+        (
+            pd.Series(
+                [1, 2],
+                index=pd.IntervalIndex.from_arrays([2, 4], [3, 6], closed="left"),
+            ),
+            pd.Series([-1, -1, 1, -1, 2, 2, -1]),
+            pd.RangeIndex(0, 7, 1),
+        ),
+    ],
+)
+def test_sparse_to_dense(y_sparse, y_dense_expected, index):
+    """Test converting from sparse to dense."""
+    y_dense_actual = BaseSeriesAnnotator.sparse_to_dense(y_sparse, index=index)
+    testing.assert_series_equal(y_dense_actual, y_dense_expected)
+
+
+@pytest.mark.parametrize(
+    "y_dense, y_sparse_expected",
+    [
+        (pd.Series([0, 1, 0, 1]), pd.Series([1, 3])),
+        (pd.Series([0, 1, 0, 1, 0, 0]), pd.Series([1, 3])),
+        (
+            pd.Series([-1, -1, -1, 1, 1, -1, 2]),
+            pd.Series(
+                [1, 2],
+                index=pd.IntervalIndex.from_arrays([3, 6], [5, 6], closed="left"),
+            ),
+        ),
+    ],
+)
+def test_dense_to_sparse(y_dense, y_sparse_expected):
+    """Test converting from dense to sparse."""
+    y_sparse_actual = BaseSeriesAnnotator.dense_to_sparse(y_dense)
+    testing.assert_series_equal(y_sparse_actual, y_sparse_expected)
+
+
+@pytest.mark.parametrize(
+    "change_points, expected_segments, start, end",
+    [
+        (
+            pd.Series([1, 2, 5]),
+            pd.Series(
+                [-1, 1, 2, 3],
+                index=pd.IntervalIndex.from_breaks([0, 1, 2, 5, 7], closed="left"),
+            ),
+            0,
+            7,
+        )
+    ],
+)
+def test_change_points_to_segments(change_points, expected_segments, start, end):
+    """Test converting change points to segments."""
+    actual_segments = BaseSeriesAnnotator.change_points_to_segments(
+        change_points, start, end
+    )
+    testing.assert_series_equal(actual_segments, expected_segments)
+
+
+@pytest.mark.parametrize(
+    "segments, expected_change_points",
+    [
+        (
+            pd.Series(
+                [1, -1, 2],
+                index=pd.IntervalIndex.from_breaks([2, 5, 7, 9], closed="left"),
+            ),
+            pd.Series([2, 5, 7]),
+        )
+    ],
+)
+def test_segments_to_change_points(segments, expected_change_points):
+    """Test converting change points to segments."""
+    actual_change_points = BaseSeriesAnnotator.segments_to_change_points(segments)
+    testing.assert_series_equal(
+        actual_change_points, expected_change_points, check_dtype=False
+    )
+
+
+@pytest.mark.parametrize(
+    "y_sparse, index, y_dense_expected",
+    [
+        (
+            pd.Series(
+                [1, 2, 1],
+                index=pd.IntervalIndex.from_arrays([0, 2, 4], [1, 3, 5]),
+            ),
+            [0, 1, 2, 3, 4, 5, 6],
+            pd.Series([-1, 1, -1, 2, -1, 1, -1]),
+        )
+    ],
+)
+def test_sparse_segments_to_dense(y_sparse, index, y_dense_expected):
+    y_dense_actual = BaseSeriesAnnotator._sparse_segments_to_dense(y_sparse, index)
+    testing.assert_series_equal(y_dense_expected, y_dense_actual)
+
+
+@pytest.mark.parametrize(
+    "y_sparse, index, y_dense_expected",
+    [(pd.Series([2, 4]), [0, 1, 2, 3, 4, 5, 6], pd.Series([0, 0, 1, 0, 1, 0, 0]))],
+)
+def test_sparse_points_to_dense(y_sparse, index, y_dense_expected):
+    y_dense_actual = BaseSeriesAnnotator._sparse_points_to_dense(y_sparse, index)
+    testing.assert_series_equal(y_dense_actual, y_dense_expected)
diff --git a/sktime/annotation/tests/test_clasp.py b/sktime/annotation/tests/test_clasp.py
index 9244d225a..d585eaf1a 100644
--- a/sktime/annotation/tests/test_clasp.py
+++ b/sktime/annotation/tests/test_clasp.py
@@ -49,7 +49,8 @@ def test_clasp_dense():
     clasp = ClaSPSegmentation(period_size, n_cps=1, fmt="dense")
     clasp.fit(ts)
     segmentation = clasp.predict(ts)
-    scores = clasp.predict_scores(ts)
 
-    assert len(segmentation) == 2 and segmentation[0].right == 893
-    assert np.argmax(scores) == 893
+    profile = clasp.predict_scores(ts)
+
+    assert len(segmentation) == 2 and segmentation.index[0].right == 893
+    assert np.argmax(profile) == 893