Repository: sktime/sktime

Issue:
[ENH] Replace Attrs with Dataclasses
### Is your feature request related to a problem? Please describe.

The `attrs` package is used in only two places across the entire repository: `sktime/annotation/ggs.py` and `sktime/annotation/igts.py`. Since it is not widely used throughout the package, perhaps we should replace it with `dataclasses` and remove the `attrs` optional dependency dependency.

#### Describe the solution you'd like

* Remove the `get_GGS` method since isolating `attrs` will no longer be needed. https://github.com/sktime/sktime/blob/main/sktime/annotation/ggs.py#L49.
* Remove the `get_IGTS` method since isolating `attrs` will no longer be needed. https://github.com/sktime/sktime/blob/main/sktime/annotation/igts.py#L104.
* Remove `attrs` as a dependency.

#### All current features used from `attrs` appear to have equivalents in `dataclasses`

* `attrs.field(init=False, default=1)` is equivalent to `dataclasses.field(init=False, default=1)`.
* `attrs.asdict` is equivalent `dataclasses.asdict`.

There may be subtle differences between `attrs` and `dataclasses` that are hard for me to see without implementing the changes.

#### What do we lose from using `dataclasses` instead of `attrs`

* `attrs` classes automatically implement a default `__eq__` method, `dataclasses` do not. I don't think comparing instances of the GGS or IGTS models is going to be high priority for most users so I think this is an acceptable loss. Happy to hear otherwise.
* The `attrs` package can do data validation and type checking but this feature is not currently being used in `sktime`. See the `Validators` section of the `attrs` documentation `https://www.attrs.org/en/stable/init.html#validators`.

#### Describe alternatives you've considered

#### Additional comments

At a glance, the `attrs`/`dataclass` style looks quite far away from the usual tagging style elsewhere in `sktime`. I think removing the `attrs` dependency is worthwhile for the time begin but more work will need to be done to bring it more in line with the rest of `sktime`.



Discussion:
Agree, very useful suggestion! Helps to reduce dependency footprint, and will probably help structuring the annotation module.

Resolving patch:
diff --git a/sktime/annotation/ggs.py b/sktime/annotation/ggs.py
--- a/sktime/annotation/ggs.py
+++ b/sktime/annotation/ggs.py
@@ -34,6 +34,7 @@
 
 import logging
 import math
+from dataclasses import asdict, dataclass, field
 from typing import Dict, List, Tuple
 
 import numpy as np
@@ -46,334 +47,324 @@
 logger = logging.getLogger(__name__)
 
 
-def get_GGS():
-    """Return GGS class, wrapper to isolate attrs."""
-    from attrs import define, field
+@dataclass
+class GGS:
+    """Greedy Gaussian Segmentation.
 
-    @define
-    class GGS:
-        """Greedy Gaussian Segmentation.
+    The method approxmates solutions for the problem of breaking a
+    multivariate time series into segments, where the data in each segment
+    could be modeled as independent samples from a multivariate Gaussian
+    distribution. It uses a dynamic programming search algorithm with
+    a heuristic that allows finding approximate solution in linear time with
+    respect to the data length and always yields locally optimal choice.
+
+    Greedy Gaussian Segmentation (GGS) fits a segmented gaussian model (SGM)
+    to the data by computing the approximate solution to the combinatorial
+    problem of finding the approximate covariance-regularized  maximum
+    log-likelihood for fixed number of change points and a reagularization
+    strength. It follows an interative procedure
+    where a new breakpoint is added and then adjusting all breakpoints to
+    (approximately) maximize the objective. It is similar to the top-down
+    search used in other change point detection problems.
+
+    Parameters
+    ----------
+    k_max: int, default=10
+        Maximum number of change points to find. The number of segments is thus k+1.
+    lamb: : float, default=1.0
+        Regularization parameter lambda (>= 0), which controls the amount of
+        (inverse) covariance regularization, see Eq (1) in [1]_. Regularization
+        is introduced to reduce issues for high-dimensional problems. Setting
+        ``lamb`` to zero will ignore regularization, whereas large values of
+        lambda will favour simpler models.
+    max_shuffles: int, default=250
+        Maximum number of shuffles
+    verbose: bool, default=False
+        If ``True`` verbose output is enabled.
+    random_state: int or np.random.RandomState, default=None
+        Either random seed or an instance of ``np.random.RandomState``
+
+    Attributes
+    ----------
+    change_points_: array_like, default=[]
+        Locations of change points as integer indexes. By convention change points
+        include the identity segmentation, i.e. first and last index + 1 values.
+    _intermediate_change_points: List[List[int]], default=[]
+        Intermediate values of change points for each value of k = 1...k_max
+    _intermediate_ll: List[float], default=[]
+        Intermediate values for log-likelihood for each value of k = 1...k_max
+
+    Notes
+    -----
+    Based on the work from [1]_.
 
-        The method approxmates solutions for the problem of breaking a
-        multivariate time series into segments, where the data in each segment
-        could be modeled as independent samples from a multivariate Gaussian
-        distribution. It uses a dynamic programming search algorithm with
-        a heuristic that allows finding approximate solution in linear time with
-        respect to the data length and always yields locally optimal choice.
+    - source code adapted based on: https://github.com/cvxgrp/GGS
+    - paper available at: https://stanford.edu/~boyd/papers/pdf/ggs.pdf
 
-        Greedy Gaussian Segmentation (GGS) fits a segmented gaussian model (SGM)
-        to the data by computing the approximate solution to the combinatorial
-        problem of finding the approximate covariance-regularized  maximum
-        log-likelihood for fixed number of change points and a reagularization
-        strength. It follows an interative procedure
-        where a new breakpoint is added and then adjusting all breakpoints to
-        (approximately) maximize the objective. It is similar to the top-down
-        search used in other change point detection problems.
+    References
+    ----------
+    .. [1] Hallac, D., Nystrup, P. & Boyd, S.,
+    "Greedy Gaussian segmentation of multivariate time series.",
+    Adv Data Anal Classif 13, 727–751 (2019).
+    https://doi.org/10.1007/s11634-018-0335-0
+    """
+
+    k_max: int = 10
+    lamb: float = 1.0
+    max_shuffles: int = 250
+    verbose: bool = False
+    random_state: int = None
+
+    change_points_: npt.ArrayLike = field(init=False, default_factory=list)
+    _intermediate_change_points: List[List[int]] = field(
+        init=False, default_factory=list
+    )
+    _intermediate_ll: List[float] = field(init=False, default_factory=list)
+
+    def initialize_intermediates(self) -> None:
+        """Initialize the state fo the estimator."""
+        self._intermediate_change_points = []
+        self._intermediate_ll = []
+
+    def log_likelihood(self, data: npt.ArrayLike) -> float:
+        """Compute the GGS log-likelihood of the segmented Gaussian model.
 
         Parameters
         ----------
-        k_max: int, default=10
-            Maximum number of change points to find. The number of segments is thus k+1.
-        lamb: : float, default=1.0
-            Regularization parameter lambda (>= 0), which controls the amount of
-            (inverse) covariance regularization, see Eq (1) in [1]_. Regularization
-            is introduced to reduce issues for high-dimensional problems. Setting
-            ``lamb`` to zero will ignore regularization, whereas large values of
-            lambda will favour simpler models.
-        max_shuffles: int, default=250
-            Maximum number of shuffles
-        verbose: bool, default=False
-            If ``True`` verbose output is enabled.
-        random_state: int or np.random.RandomState, default=None
-            Either random seed or an instance of ``np.random.RandomState``
-
-        Attributes
-        ----------
-        change_points_: array_like, default=[]
-            Locations of change points as integer indexes. By convention change points
+        data: array_like
+            2D `array_like` representing time series with sequence index along
+            the first dimension and value series as columns.
+
+        Returns
+        -------
+        log_likelihood
+        """
+        nrows, ncols = data.shape
+        cov = np.cov(data.T, bias=True)
+        (_, logdet) = np.linalg.slogdet(
+            cov + float(self.lamb) * np.identity(ncols) / nrows
+        )
+
+        return nrows * logdet - float(self.lamb) * np.trace(
+            np.linalg.inv(cov + float(self.lamb) * np.identity(ncols) / nrows)
+        )
+
+    def cumulative_log_likelihood(
+        self, data: npt.ArrayLike, change_points: List[int]
+    ) -> float:
+        """Calculate cumulative GGS log-likelihood for all segments.
+
+        Args
+        ----
+        data: array_like
+            2D `array_like` representing time series with sequence index along
+            the first dimension and value series as columns.
+        change_points: list of ints
+            Locations of change points as integer indexes.
+            By convention, change points
             include the identity segmentation, i.e. first and last index + 1 values.
-        _intermediate_change_points: List[List[int]], default=[]
-            Intermediate values of change points for each value of k = 1...k_max
-        _intermediate_ll: List[float], default=[]
-            Intermediate values for log-likelihood for each value of k = 1...k_max
 
-        Notes
-        -----
-        Based on the work from [1]_.
+        Returns
+        -------
+        log_likelihood: cumulative log likelihood
+        """
+        log_likelihood = 0
+        for start, stop in zip(change_points[:-1], change_points[1:]):
+            segment = data[start:stop, :]
+            log_likelihood -= self.log_likelihood(segment)
+        return log_likelihood
+
+    def add_new_change_point(self, data: npt.ArrayLike) -> Tuple[int, float]:
+        """Add change point.
 
-        - source code adapted based on: https://github.com/cvxgrp/GGS
-        - paper available at: https://stanford.edu/~boyd/papers/pdf/ggs.pdf
+        This methods finds a new change point by that splits the segment and
+        optimizes the objective function. See section 3.1 on split subroutine
+        in [1]_.
 
-        References
+        Parameters
         ----------
-        .. [1] Hallac, D., Nystrup, P. & Boyd, S.,
-        "Greedy Gaussian segmentation of multivariate time series.",
-        Adv Data Anal Classif 13, 727–751 (2019).
-        https://doi.org/10.1007/s11634-018-0335-0
-        """
+        data: array_like
+            2D `array_like` representing time series with sequence index along
+            the first dimension and value series as columns.
 
-        k_max: int = 10
-        lamb: float = 1.0
-        max_shuffles: int = 250
-        verbose: bool = False
-        random_state: int = None
-
-        change_points_: npt.ArrayLike = field(init=False, default=[])
-        _intermediate_change_points: List[List[int]] = field(init=False, default=[])
-        _intermediate_ll: List[float] = field(init=False, default=[])
-
-        def initialize_intermediates(self) -> None:
-            """Initialize the state fo the estimator."""
-            self._intermediate_change_points = []
-            self._intermediate_ll = []
-
-        def log_likelihood(self, data: npt.ArrayLike) -> float:
-            """Compute the GGS log-likelihood of the segmented Gaussian model.
-
-            Parameters
-            ----------
-            data: array_like
-                2D `array_like` representing time series with sequence index along
-                the first dimension and value series as columns.
-
-            Returns
-            -------
-            log_likelihood
-            """
-            nrows, ncols = data.shape
-            cov = np.cov(data.T, bias=True)
-            (_, logdet) = np.linalg.slogdet(
-                cov + float(self.lamb) * np.identity(ncols) / nrows
+        Returns
+        -------
+        index: change point index
+        gll: gained log likelihood
+        """
+        # Initialize parameters
+        m, n = data.shape
+        orig_mean = np.mean(data, axis=0)
+        orig_cov = np.cov(data.T, bias=True)
+        orig_ll = self.log_likelihood(data)
+        total_sum = m * (orig_cov + np.outer(orig_mean, orig_mean))
+        mu_left = data[0, :] / n
+        mu_right = (m * orig_mean - data[0, :]) / (m - 1)
+        runSum = np.outer(data[0, :], data[0, :])
+        # Loop through all samples
+        # find point where breaking the segment would have the largest LL increase
+        min_ll = orig_ll
+        new_index = 0
+        for i in range(2, m - 1):
+            # Update parameters
+            runSum = runSum + np.outer(data[i - 1, :], data[i - 1, :])
+            mu_left = ((i - 1) * mu_left + data[i - 1, :]) / (i)
+            mu_right = ((m - i + 1) * mu_right - data[i - 1, :]) / (m - i)
+            sigLeft = runSum / (i) - np.outer(mu_left, mu_left)
+            sigRight = (total_sum - runSum) / (m - i) - np.outer(mu_right, mu_right)
+
+            # Compute Cholesky, LogDet, and Trace
+            Lleft = np.linalg.cholesky(sigLeft + float(self.lamb) * np.identity(n) / i)
+            Lright = np.linalg.cholesky(
+                sigRight + float(self.lamb) * np.identity(n) / (m - i)
             )
-
-            return nrows * logdet - float(self.lamb) * np.trace(
-                np.linalg.inv(cov + float(self.lamb) * np.identity(ncols) / nrows)
+            ll_left = 2 * sum(map(math.log, np.diag(Lleft)))
+            ll_right = 2 * sum(map(math.log, np.diag(Lright)))
+            (trace_left, trace_right) = (0, 0)
+            if self.lamb > 0:
+                trace_left = math.pow(np.linalg.norm(np.linalg.inv(Lleft)), 2)
+                trace_right = math.pow(np.linalg.norm(np.linalg.inv(Lright)), 2)
+            LL = (
+                i * ll_left
+                - float(self.lamb) * trace_left
+                + (m - i) * ll_right
+                - float(self.lamb) * trace_right
             )
+            # Keep track of the best point so far
+            if LL < min_ll:
+                min_ll = LL
+                new_index = i
+        # Return break, increase in LL
+        return new_index, min_ll - orig_ll
+
+    def adjust_change_points(
+        self, data: npt.ArrayLike, change_points: List[int], new_index: List[int]
+    ) -> List[int]:
+        """Adjust change points.
+
+        This method adjusts the positions of all change points until the
+        result is 1-OPT, i.e., no change of any one breakpoint improves
+        the objective.
+
+        Parameters
+        ----------
+        data: array_like
+            2D `array_like` representing time series with sequence index along
+            the first dimension and value series as columns.
+        change_points: list of ints
+            Locations of change points as integer indexes.
+            By convention, change points
+            include the identity segmentation, i.e. first and last index + 1 values.
+        new_index: list of ints
+            New change points
 
-        def cumulative_log_likelihood(
-            self, data: npt.ArrayLike, change_points: List[int]
-        ) -> float:
-            """Calculate cumulative GGS log-likelihood for all segments.
-
-            Args
-            ----
-            data: array_like
-                2D `array_like` representing time series with sequence index along
-                the first dimension and value series as columns.
-            change_points: list of ints
-                Locations of change points as integer indexes.
-                By convention, change points
-                include the identity segmentation, i.e. first and last index + 1 values.
-
-            Returns
-            -------
-            log_likelihood: cumulative log likelihood
-            """
-            log_likelihood = 0
+        Returns
+        -------
+        change_points: list of ints
+            Locations of change points as integer indexes.
+            By convention, change points
+            include the identity segmentation, i.e. first and last index + 1 values.
+        """
+        rng = check_random_state(self.random_state)
+        bp = change_points[:]
+
+        # Just one breakpoint, no need to adjust anything
+        if len(bp) == 3:
+            return bp
+        # Keep track of what change_points have changed,
+        # so that we don't have to adjust ones which we know are constant
+        last_pass = {}
+        this_pass = {b: 0 for b in bp}
+        for i in new_index:
+            this_pass[i] = 1
+        for _ in range(self.max_shuffles):
+            last_pass = dict(this_pass)
+            this_pass = {b: 0 for b in bp}
+            switch_any = False
+            ordering = list(range(1, len(bp) - 1))
+            rng.shuffle(ordering)
+            for i in ordering:
+                # Check if we need to adjust it
+                if (
+                    last_pass[bp[i - 1]] == 1
+                    or last_pass[bp[i + 1]] == 1
+                    or this_pass[bp[i - 1]] == 1
+                    or this_pass[bp[i + 1]] == 1
+                ):
+                    tempData = data[bp[i - 1] : bp[i + 1], :]
+                    ind, val = self.add_new_change_point(tempData)
+                    if bp[i] != ind + bp[i - 1] and val != 0:
+                        last_pass[ind + bp[i - 1]] = last_pass[bp[i]]
+                        del last_pass[bp[i]]
+                        del this_pass[bp[i]]
+                        this_pass[ind + bp[i - 1]] = 1
+                        if self.verbose:
+                            logger.info(
+                                f"Moving {bp[i]} to {ind + bp[i - 1]}"
+                                f"length = {tempData.shape[0]}, {ind}"
+                            )
+                        bp[i] = ind + bp[i - 1]
+                        switch_any = True
+            if not switch_any:
+                return bp
+        return bp
+
+    def identity_segmentation(self, data: npt.ArrayLike) -> List[int]:
+        """Initialize change points."""
+        return [0, data.shape[0] + 1]
+
+    def find_change_points(self, data: npt.ArrayLike) -> List[int]:
+        """
+        Search iteratively  for up to ``k_max`` change points.
+
+        Parameters
+        ----------
+        data: array_like
+            2D `array_like` representing time series with sequence index along
+            the first dimension and value series as columns.
+
+        Returns
+        -------
+        The K change points, along with all intermediate change points (for k < K)
+        and their corresponding covariance-regularized maximum likelihoods.
+        """
+        change_points = self.identity_segmentation(data)
+        self._intermediate_change_points = [change_points[:]]
+        self._intermediate_ll = [self.cumulative_log_likelihood(data, change_points)]
+
+        # Start GGS Algorithm
+        for _ in range(self.k_max):
+            new_index = -1
+            new_value = +1
+            # For each segment, find change point and increase in LL
             for start, stop in zip(change_points[:-1], change_points[1:]):
                 segment = data[start:stop, :]
-                log_likelihood -= self.log_likelihood(segment)
-            return log_likelihood
-
-        def add_new_change_point(self, data: npt.ArrayLike) -> Tuple[int, float]:
-            """Add change point.
-
-            This methods finds a new change point by that splits the segment and
-            optimizes the objective function. See section 3.1 on split subroutine
-            in [1]_.
-
-            Parameters
-            ----------
-            data: array_like
-                2D `array_like` representing time series with sequence index along
-                the first dimension and value series as columns.
-
-            Returns
-            -------
-            index: change point index
-            gll: gained log likelihood
-            """
-            # Initialize parameters
-            m, n = data.shape
-            orig_mean = np.mean(data, axis=0)
-            orig_cov = np.cov(data.T, bias=True)
-            orig_ll = self.log_likelihood(data)
-            total_sum = m * (orig_cov + np.outer(orig_mean, orig_mean))
-            mu_left = data[0, :] / n
-            mu_right = (m * orig_mean - data[0, :]) / (m - 1)
-            runSum = np.outer(data[0, :], data[0, :])
-            # Loop through all samples
-            # find point where breaking the segment would have the largest LL increase
-            min_ll = orig_ll
-            new_index = 0
-            for i in range(2, m - 1):
-                # Update parameters
-                runSum = runSum + np.outer(data[i - 1, :], data[i - 1, :])
-                mu_left = ((i - 1) * mu_left + data[i - 1, :]) / (i)
-                mu_right = ((m - i + 1) * mu_right - data[i - 1, :]) / (m - i)
-                sigLeft = runSum / (i) - np.outer(mu_left, mu_left)
-                sigRight = (total_sum - runSum) / (m - i) - np.outer(mu_right, mu_right)
-
-                # Compute Cholesky, LogDet, and Trace
-                Lleft = np.linalg.cholesky(
-                    sigLeft + float(self.lamb) * np.identity(n) / i
-                )
-                Lright = np.linalg.cholesky(
-                    sigRight + float(self.lamb) * np.identity(n) / (m - i)
-                )
-                ll_left = 2 * sum(map(math.log, np.diag(Lleft)))
-                ll_right = 2 * sum(map(math.log, np.diag(Lright)))
-                (trace_left, trace_right) = (0, 0)
-                if self.lamb > 0:
-                    trace_left = math.pow(np.linalg.norm(np.linalg.inv(Lleft)), 2)
-                    trace_right = math.pow(np.linalg.norm(np.linalg.inv(Lright)), 2)
-                LL = (
-                    i * ll_left
-                    - float(self.lamb) * trace_left
-                    + (m - i) * ll_right
-                    - float(self.lamb) * trace_right
-                )
-                # Keep track of the best point so far
-                if LL < min_ll:
-                    min_ll = LL
-                    new_index = i
-            # Return break, increase in LL
-            return new_index, min_ll - orig_ll
-
-        def adjust_change_points(
-            self, data: npt.ArrayLike, change_points: List[int], new_index: List[int]
-        ) -> List[int]:
-            """Adjust change points.
-
-            This method adjusts the positions of all change points until the
-            result is 1-OPT, i.e., no change of any one breakpoint improves
-            the objective.
-
-            Parameters
-            ----------
-            data: array_like
-                2D `array_like` representing time series with sequence index along
-                the first dimension and value series as columns.
-            change_points: list of ints
-                Locations of change points as integer indexes.
-                By convention, change points
-                include the identity segmentation, i.e. first and last index + 1 values.
-            new_index: list of ints
-                New change points
-
-            Returns
-            -------
-            change_points: list of ints
-                Locations of change points as integer indexes.
-                By convention, change points
-                include the identity segmentation, i.e. first and last index + 1 values.
-            """
-            rng = check_random_state(self.random_state)
-            bp = change_points[:]
-
-            # Just one breakpoint, no need to adjust anything
-            if len(bp) == 3:
-                return bp
-            # Keep track of what change_points have changed,
-            # so that we don't have to adjust ones which we know are constant
-            last_pass = {}
-            this_pass = {b: 0 for b in bp}
-            for i in new_index:
-                this_pass[i] = 1
-            for _ in range(self.max_shuffles):
-                last_pass = dict(this_pass)
-                this_pass = {b: 0 for b in bp}
-                switch_any = False
-                ordering = list(range(1, len(bp) - 1))
-                rng.shuffle(ordering)
-                for i in ordering:
-                    # Check if we need to adjust it
-                    if (
-                        last_pass[bp[i - 1]] == 1
-                        or last_pass[bp[i + 1]] == 1
-                        or this_pass[bp[i - 1]] == 1
-                        or this_pass[bp[i + 1]] == 1
-                    ):
-                        tempData = data[bp[i - 1] : bp[i + 1], :]
-                        ind, val = self.add_new_change_point(tempData)
-                        if bp[i] != ind + bp[i - 1] and val != 0:
-                            last_pass[ind + bp[i - 1]] = last_pass[bp[i]]
-                            del last_pass[bp[i]]
-                            del this_pass[bp[i]]
-                            this_pass[ind + bp[i - 1]] = 1
-                            if self.verbose:
-                                logger.info(
-                                    f"Moving {bp[i]} to {ind + bp[i - 1]}"
-                                    f"length = {tempData.shape[0]}, {ind}"
-                                )
-                            bp[i] = ind + bp[i - 1]
-                            switch_any = True
-                if not switch_any:
-                    return bp
-            return bp
+                ind, val = self.add_new_change_point(segment)
+                if val < new_value:
+                    new_index = ind + start
+                    new_value = val
+
+            # Check if our algorithm is finished
+            if new_value == 0:
+                logger.info("Adding change points!")
+                return change_points
+
+            # Add new change point
+            change_points.append(new_index)
+            change_points.sort()
+            if self.verbose:
+                logger.info(f"Change point occurs at: {new_index}, LL: {new_value}")
+
+            # Adjust current locations of the change points
+            change_points = self.adjust_change_points(data, change_points, [new_index])
+            change_points = change_points[:]
 
-        def identity_segmentation(self, data: npt.ArrayLike) -> List[int]:
-            """Initialize change points."""
-            return [0, data.shape[0] + 1]
-
-        def find_change_points(self, data: npt.ArrayLike) -> List[int]:
-            """
-            Search iteratively  for up to ``k_max`` change points.
-
-            Parameters
-            ----------
-            data: array_like
-                2D `array_like` representing time series with sequence index along
-                the first dimension and value series as columns.
-
-            Returns
-            -------
-            The K change points, along with all intermediate change points (for k < K)
-            and their corresponding covariance-regularized maximum likelihoods.
-            """
-            change_points = self.identity_segmentation(data)
-            self._intermediate_change_points = [change_points[:]]
-            self._intermediate_ll = [
-                self.cumulative_log_likelihood(data, change_points)
-            ]
-
-            # Start GGS Algorithm
-            for _ in range(self.k_max):
-                new_index = -1
-                new_value = +1
-                # For each segment, find change point and increase in LL
-                for start, stop in zip(change_points[:-1], change_points[1:]):
-                    segment = data[start:stop, :]
-                    ind, val = self.add_new_change_point(segment)
-                    if val < new_value:
-                        new_index = ind + start
-                        new_value = val
-
-                # Check if our algorithm is finished
-                if new_value == 0:
-                    logger.info("Adding change points!")
-                    return change_points
-
-                # Add new change point
-                change_points.append(new_index)
-                change_points.sort()
-                if self.verbose:
-                    logger.info(f"Change point occurs at: {new_index}, LL: {new_value}")
-
-                # Adjust current locations of the change points
-                change_points = self.adjust_change_points(
-                    data, change_points, [new_index]
-                )
-                change_points = change_points[:]
-
-                # Calculate likelihood
-                ll = self.cumulative_log_likelihood(data, change_points)
-                self._intermediate_change_points.append(change_points[:])
-                self._intermediate_ll.append(ll)
-
-            return change_points
-
-    return GGS
+            # Calculate likelihood
+            ll = self.cumulative_log_likelihood(data, change_points)
+            self._intermediate_change_points.append(change_points[:])
+            self._intermediate_ll.append(ll)
+
+        return change_points
 
 
 class GreedyGaussianSegmentation(BaseEstimator):
@@ -437,8 +428,6 @@ class GreedyGaussianSegmentation(BaseEstimator):
        https://doi.org/10.1007/s11634-018-0335-0
     """
 
-    _tags = {"python_dependencies": "attrs"}
-
     def __init__(
         self,
         k_max: int = 10,
@@ -457,8 +446,7 @@ def __init__(
         _check_estimator_deps(self)
         super().__init__()
 
-        self._adaptee_class = get_GGS()
-        self._adaptee = self._adaptee_class(
+        self._adaptee = GGS(
             k_max=k_max,
             lamb=lamb,
             max_shuffles=max_shuffles,
@@ -544,9 +532,16 @@ def get_params(self, deep: bool = True) -> Dict:
             Dictionary with the estimator's initialization parameters, with
             keys being argument names and values being argument values.
         """
-        from attrs import asdict
-
-        return asdict(self._adaptee, filter=lambda attr, value: attr.init is True)
+        attrs_to_ignore = [
+            "change_points_",
+            "_intermediate_change_points",
+            "_intermediate_ll",
+        ]
+        params = asdict(self._adaptee)
+        params = {
+            key: value for key, value in params.items() if key not in attrs_to_ignore
+        }
+        return params
 
     def set_params(self, **parameters):
         """Set the parameters of this object.
diff --git a/sktime/annotation/igts.py b/sktime/annotation/igts.py
--- a/sktime/annotation/igts.py
+++ b/sktime/annotation/igts.py
@@ -15,7 +15,7 @@
     https://www.sciencedirect.com/science/article/abs/pii/S1574119217300081
 """
 
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass, field
 from typing import Dict, List
 
 import numpy as np
@@ -101,189 +101,183 @@ def generate_segments_pandas(X: npt.ArrayLike, change_points: List) -> npt.Array
         yield X[interval.left : interval.right, :]
 
 
-def get_IGTS():
-    """Return IGTS class, wrapper to isolate attrs."""
-    from attrs import define, field
+@dataclass
+class IGTS:
+    """Information Gain based Temporal Segmentation (IGTS).
 
-    @define
-    class IGTS:
-        """Information Gain based Temporal Segmentation (IGTS).
+    IGTS is a n unsupervised method for segmenting multivariate time series
+    into non-overlapping segments by locating change points that for which
+    the information gain is maximized.
 
-        IGTS is a n unsupervised method for segmenting multivariate time series
-        into non-overlapping segments by locating change points that for which
-        the information gain is maximized.
+    Information gain (IG) is defined as the amount of entropy lost by the
+    segmentation.
+    The aim is to find the segmentation that have the maximum information
+    gain for a specified number of segments.
 
-        Information gain (IG) is defined as the amount of entropy lost by the
-        segmentation.
-        The aim is to find the segmentation that have the maximum information
-        gain for a specified number of segments.
+    IGTS uses top-down search method to greedily find the next change point
+    location that creates the maximum information gain. Once this is found, it
+    repeats the process until it finds `k_max` splits of the time series.
+
+    .. note::
+
+    IGTS does not work very well for univariate series but it can still be
+    used if the original univariate series are augmented by an extra feature
+    dimensions. A technique proposed in the paper [1]_ us to subtract the
+    series from it's largest element and append to the series.
+
+    Parameters
+    ----------
+    k_max: int, default=10
+        Maximum number of change points to find. The number of segments is thus k+1.
+    step: : int, default=5
+        Step size, or stride for selecting candidate locations of change points.
+        Fox example a `step=5` would produce candidates [0, 5, 10, ...].
+        Has the same meaning as `step` in `range` function.
 
-        IGTS uses top-down search method to greedily find the next change point
-        location that creates the maximum information gain. Once this is found, it
-        repeats the process until it finds `k_max` splits of the time series.
+    Attributes
+    ----------
+    intermediate_results_: list of `ChangePointResult`
+        Intermediate segmentation results for each k value, where k=1, 2, ..., k_max
 
-        .. note::
+    Notes
+    -----
+    Based on the work from [1]_.
+    - alt. py implementation: https://github.com/cruiseresearchgroup/IGTS-python
+    - MATLAB version: https://github.com/cruiseresearchgroup/IGTS-matlab
+    - paper available at:
 
-        IGTS does not work very well for univariate series but it can still be
-        used if the original univariate series are augmented by an extra feature
-        dimensions. A technique proposed in the paper [1]_ us to subtract the
-        series from it's largest element and append to the series.
+    References
+    ----------
+    .. [1] Sadri, Amin, Yongli Ren, and Flora D. Salim.
+    "Information gain-based metric for recognizing transitions in human
+    activities.", Pervasive and Mobile Computing, 38, 92-109, (2017).
+    https://www.sciencedirect.com/science/article/abs/pii/S1574119217300081
+
+    Example
+    -------
+    >>> from sktime.annotation.datagen import piecewise_normal_multivariate
+    >>> from sklearn.preprocessing import MinMaxScaler
+    >>> X = piecewise_normal_multivariate(lengths=[10, 10, 10, 10],
+    ... means=[[0.0, 1.0], [11.0, 10.0], [5.0, 3.0], [2.0, 2.0]],
+    ... variances=0.5)
+    >>> X_scaled = MinMaxScaler(feature_range=(0, 1)).fit_transform(X)
+    >>> from sktime.annotation.igts import InformationGainSegmentation
+    >>> igts = InformationGainSegmentation(k_max=3, step=2)
+    >>> y = igts.fit_predict(X_scaled)
+    """
+
+    # init attributes
+    k_max: int = 10
+    step: int = 5
+
+    # computed attributes
+    intermediate_results_: List = field(init=False, default_factory=list)
+
+    def identity(self, X: npt.ArrayLike) -> List[int]:
+        """Return identity segmentation, i.e. terminal indexes of the data."""
+        return sorted([0, X.shape[0]])
+
+    def get_candidates(self, n_samples: int, change_points: List[int]) -> List[int]:
+        """Generate candidate change points.
+
+        Also exclude existing change points.
 
         Parameters
         ----------
-        k_max: int, default=10
-            Maximum number of change points to find. The number of segments is thus k+1.
-        step: : int, default=5
-            Step size, or stride for selecting candidate locations of change points.
-            Fox example a `step=5` would produce candidates [0, 5, 10, ...].
-            Has the same meaning as `step` in `range` function.
-
-        Attributes
-        ----------
-        intermediate_results_: list of `ChangePointResult`
-            Intermediate segmentation results for each k value, where k=1, 2, ..., k_max
+        n_samples: int
+            Length of the time series.
+        change_points: list of ints
+            Current set of change points, that will be used to exclude values
+            from candidates.
+
+        TODO: exclude points within a neighborhood of existing
+        change points with neighborhood radius
+        """
+        return sorted(
+            set(range(0, n_samples, self.step)).difference(set(change_points))
+        )
 
-        Notes
-        -----
-        Based on the work from [1]_.
-        - alt. py implementation: https://github.com/cruiseresearchgroup/IGTS-python
-        - MATLAB version: https://github.com/cruiseresearchgroup/IGTS-matlab
-        - paper available at:
+    @staticmethod
+    def information_gain_score(X: npt.ArrayLike, change_points: List[int]) -> float:
+        """Calculate the information gain score.
 
-        References
+        The formula is based on equation (2) from [1]_.
+
+        Parameters
         ----------
-        .. [1] Sadri, Amin, Yongli Ren, and Flora D. Salim.
-        "Information gain-based metric for recognizing transitions in human
-        activities.", Pervasive and Mobile Computing, 38, 92-109, (2017).
-        https://www.sciencedirect.com/science/article/abs/pii/S1574119217300081
+        X: array_like
+            Time series data as a 2D numpy array with sequence index along rows
+            and value series in columns.
+
+        change_points: list of ints
+            Locations of change points as integer indexes.
+            By convention, change points
+            include the identity segmentation, i.e. first and last index + 1 values.
 
-        Example
+        Returns
         -------
-        >>> from sktime.annotation.datagen import piecewise_normal_multivariate
-        >>> from sklearn.preprocessing import MinMaxScaler
-        >>> X = piecewise_normal_multivariate(lengths=[10, 10, 10, 10],
-        ... means=[[0.0, 1.0], [11.0, 10.0], [5.0, 3.0], [2.0, 2.0]],
-        ... variances=0.5)
-        >>> X_scaled = MinMaxScaler(feature_range=(0, 1)).fit_transform(X)
-        >>> from sktime.annotation.igts import InformationGainSegmentation
-        >>> igts = InformationGainSegmentation(k_max=3, step=2)
-        >>> y = igts.fit_predict(X_scaled)
+        information_gain: float
+            Information gain score for the segmentation corresponding to the change
+            points.
         """
+        cp = change_points
+        segment_entropies = [
+            seg.shape[0] * entropy(seg) for seg in generate_segments(X, cp)
+        ]
+        return entropy(X) - sum(segment_entropies) / X.shape[0]
 
-        # init attributes
-        k_max: int = 10
-        step: int = 5
-
-        # computed attributes
-        intermediate_results_: List = field(init=False, default=[])
-
-        def identity(self, X: npt.ArrayLike) -> List[int]:
-            """Return identity segmentation, i.e. terminal indexes of the data."""
-            return sorted([0, X.shape[0]])
-
-        def get_candidates(self, n_samples: int, change_points: List[int]) -> List[int]:
-            """Generate candidate change points.
+    def find_change_points(self, X: npt.ArrayLike) -> List[int]:
+        """Find change points.
 
-            Also exclude existing change points.
+        Using a top-down search method, iteratively identify at most
+        `k_max` change points that increase the information gain score
+        the most.
 
-            Parameters
-            ----------
-            n_samples: int
-                Length of the time series.
-            change_points: list of ints
-                Current set of change points, that will be used to exclude values
-                from candidates.
+        Parameters
+        ----------
+        X: array_like
+            Time series data as a 2D numpy array with sequence index along rows
+            and value series in columns.
 
-            TODO: exclude points within a neighborhood of existing
-            change points with neighborhood radius
-            """
-            return sorted(
-                set(range(0, n_samples, self.step)).difference(set(change_points))
+        Returns
+        -------
+        change_points: list of ints
+            Locations of change points as integer indexes.
+            By convention, change points
+            include the identity segmentation, i.e. first and last index + 1 values.
+        """
+        n_samples, n_series = X.shape
+        if n_series == 1:
+            raise ValueError(
+                "Detected univariate series, IGTS will not work properly"
+                " in this case. Consider augmenting your series to multivariate."
             )
-
-        @staticmethod
-        def information_gain_score(X: npt.ArrayLike, change_points: List[int]) -> float:
-            """Calculate the information gain score.
-
-            The formula is based on equation (2) from [1]_.
-
-            Parameters
-            ----------
-            X: array_like
-                Time series data as a 2D numpy array with sequence index along rows
-                and value series in columns.
-
-            change_points: list of ints
-                Locations of change points as integer indexes.
-                By convention, change points
-                include the identity segmentation, i.e. first and last index + 1 values.
-
-            Returns
-            -------
-            information_gain: float
-                Information gain score for the segmentation corresponding to the change
-                points.
-            """
-            cp = change_points
-            segment_entropies = [
-                seg.shape[0] * entropy(seg) for seg in generate_segments(X, cp)
-            ]
-            return entropy(X) - sum(segment_entropies) / X.shape[0]
-
-        def find_change_points(self, X: npt.ArrayLike) -> List[int]:
-            """Find change points.
-
-            Using a top-down search method, iteratively identify at most
-            `k_max` change points that increase the information gain score
-            the most.
-
-            Parameters
-            ----------
-            X: array_like
-                Time series data as a 2D numpy array with sequence index along rows
-                and value series in columns.
-
-            Returns
-            -------
-            change_points: list of ints
-                Locations of change points as integer indexes.
-                By convention, change points
-                include the identity segmentation, i.e. first and last index + 1 values.
-            """
-            n_samples, n_series = X.shape
-            if n_series == 1:
-                raise ValueError(
-                    "Detected univariate series, IGTS will not work properly"
-                    " in this case. Consider augmenting your series to multivariate."
-                )
-            self.intermediate_results_ = []
-
-            # by convention initialize with the identity segmentation
-            current_change_points = self.identity(X)
-
-            for k in range(self.k_max):
-                ig_max = 0
-                # find a point which maximizes score
-                for candidate in self.get_candidates(n_samples, current_change_points):
-                    try_change_points = {candidate}
-                    try_change_points.update(current_change_points)
-                    try_change_points = sorted(try_change_points)
-                    ig = self.information_gain_score(X, try_change_points)
-                    if ig > ig_max:
-                        ig_max = ig
-                        best_candidate = candidate
-
-                current_change_points.append(best_candidate)
-                current_change_points.sort()
-                self.intermediate_results_.append(
-                    ChangePointResult(
-                        k=k, score=ig_max, change_points=current_change_points.copy()
-                    )
+        self.intermediate_results_ = []
+
+        # by convention initialize with the identity segmentation
+        current_change_points = self.identity(X)
+
+        for k in range(self.k_max):
+            ig_max = 0
+            # find a point which maximizes score
+            for candidate in self.get_candidates(n_samples, current_change_points):
+                try_change_points = {candidate}
+                try_change_points.update(current_change_points)
+                try_change_points = sorted(try_change_points)
+                ig = self.information_gain_score(X, try_change_points)
+                if ig > ig_max:
+                    ig_max = ig
+                    best_candidate = candidate
+
+            current_change_points.append(best_candidate)
+            current_change_points.sort()
+            self.intermediate_results_.append(
+                ChangePointResult(
+                    k=k, score=ig_max, change_points=current_change_points.copy()
                 )
+            )
 
-            return current_change_points
-
-    return IGTS
+        return current_change_points
 
 
 class SegmentationMixin:
@@ -388,8 +382,6 @@ class InformationGainSegmentation(SegmentationMixin, BaseEstimator):
     >>> y = igts.fit_predict(X_scaled) # doctest: +SKIP
     """
 
-    _tags = {"python_dependencies": "attrs"}
-
     def __init__(
         self,
         k_max: int = 10,
@@ -401,8 +393,7 @@ def __init__(
         _check_estimator_deps(self)
         super().__init__()
 
-        self._adaptee_class = get_IGTS()
-        self._adaptee = self._adaptee_class(
+        self._adaptee = IGTS(
             k_max=k_max,
             step=step,
         )
@@ -484,9 +475,13 @@ def get_params(self, deep: bool = True) -> Dict:
             Dictionary with the estimator's initialization parameters, with
             keys being argument names and values being argument values.
         """
-        from attrs import asdict
-
-        return asdict(self._adaptee, filter=lambda attr, value: attr.init is True)
+        params = asdict(self._adaptee)
+        params = {
+            key: value
+            for key, value in params.items()
+            if key != "intermediate_results_"
+        }
+        return params
 
     def set_params(self, **parameters):
         """Set the parameters of this object.


Test patch:
diff --git a/sktime/annotation/tests/test_ggs.py b/sktime/annotation/tests/test_ggs.py
--- a/sktime/annotation/tests/test_ggs.py
+++ b/sktime/annotation/tests/test_ggs.py
@@ -3,8 +3,7 @@
 import numpy as np
 import pytest
 
-from sktime.annotation.ggs import GreedyGaussianSegmentation, get_GGS
-from sktime.utils.validation._dependencies import _check_soft_dependencies
+from sktime.annotation.ggs import GGS, GreedyGaussianSegmentation
 
 
 @pytest.fixture
@@ -14,23 +13,14 @@ def univariate_mean_shift():
     return x[:, np.newaxis]
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_GGS_find_change_points(univariate_mean_shift):
     """Test the GGS core estimator."""
-    GGS = get_GGS()
     ggs = GGS(k_max=10, lamb=1.0)
     pred = ggs.find_change_points(univariate_mean_shift)
     assert isinstance(pred, list)
     assert len(pred) == 5
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_GreedyGaussianSegmentation(univariate_mean_shift):
     """Test the GreedyGaussianSegmentation."""
     ggs = GreedyGaussianSegmentation(k_max=5, lamb=0.5)
diff --git a/sktime/annotation/tests/test_igts.py b/sktime/annotation/tests/test_igts.py
--- a/sktime/annotation/tests/test_igts.py
+++ b/sktime/annotation/tests/test_igts.py
@@ -3,8 +3,7 @@
 import numpy as np
 import pytest
 
-from sktime.annotation.igts import InformationGainSegmentation, entropy, get_IGTS
-from sktime.utils.validation._dependencies import _check_soft_dependencies
+from sktime.annotation.igts import IGTS, InformationGainSegmentation, entropy
 
 
 @pytest.fixture
@@ -19,46 +18,27 @@ def test_entropy():
     assert entropy(np.ones((10, 1))) == 0.0
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_igts_identity():
     """Test identity segmentation."""
-    IGTS = get_IGTS()
     X = np.random.random(12).reshape(4, 3)
     id_change_points = IGTS().identity(X)
     assert id_change_points == [0, 4]
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_igts_get_candidates():
     """Test get_candidates function."""
-    IGTS = get_IGTS()
     candidates = IGTS(step=2).get_candidates(n_samples=10, change_points=[0, 4, 6])
     assert candidates == [2, 8]
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_IGTS_find_change_points(multivariate_mean_shift):
     """Test the IGTS core estimator."""
-    IGTS = get_IGTS()
     igts = IGTS(k_max=3, step=1)
     pred = igts.find_change_points(multivariate_mean_shift)
     assert isinstance(pred, list)
     assert len(pred) == 5
 
 
-@pytest.mark.skipif(
-    not _check_soft_dependencies("attrs", severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_InformationGainSegmentation(multivariate_mean_shift):
     """Test the InformationGainSegmentation."""
     igts = InformationGainSegmentation(k_max=3, step=1)