Repository: NorskRegnesentral/skchange
Pull request #80: Add the ESAC method for detecting changes in high-dimensional mean vectors
Author: login: peraugustmoen
Created: 2025-04-26T13:05:27Z | Merged: 2025-04-30T20:31:13Z

ESAC has been implemented & documented. All tests pass


Diff:
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..590d4af0
Binary files /dev/null and b/.DS_Store differ
diff --git a/interactive/explore_ESAC.py b/interactive/explore_ESAC.py
new file mode 100644
index 00000000..b36253e8
--- /dev/null
+++ b/interactive/explore_ESAC.py
@@ -0,0 +1,58 @@
+"""Interactive exploration of Seeded Binary Segmentation with ESAC penalty."""
+
+import plotly.express as px
+
+from skchange.change_detectors import SeededBinarySegmentation
+from skchange.change_scores import ESACScore
+from skchange.datasets import generate_alternating_data
+from skchange.utils.benchmarking.profiler import Profiler
+
+df = generate_alternating_data(
+    n_segments=2, mean=10, segment_length=20, p=1, random_state=7
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+## check sparsity
+df = generate_alternating_data(
+    n_segments=10,
+    mean=10,
+    segment_length=20,
+    p=1000,
+    random_state=7,
+    affected_proportion=0.001,
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+print("n : ", detector.fitted_score.n)
+print("p : ", detector.fitted_score.p)
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+# Profiling
+n = int(1e4)
+df = generate_alternating_data(n_segments=1, mean=0, segment_length=n, p=100)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+profiler = Profiler()
+profiler.start()
+detector.fit_predict(df)
+profiler.stop()
diff --git a/skchange/change_detectors/tests/test_moving_window.py b/skchange/change_detectors/tests/test_moving_window.py
index 71afe967..ecdf7b8b 100644
--- a/skchange/change_detectors/tests/test_moving_window.py
+++ b/skchange/change_detectors/tests/test_moving_window.py
@@ -67,6 +67,9 @@ def test_moving_window_scores(Score):
     """Test MovingWindow scores."""
     score = Score.create_test_instance()
     skip_if_no_test_data(score)
+    if score.get_tag("is_penalised"):
+        # Penalised scores cannot be forced to be non-negative in this test.
+        pytest.skip(f"{type(score).__name__} is penalised.")
 
     n_segments = 2
     seg_len = 50
diff --git a/skchange/change_scores/__init__.py b/skchange/change_scores/__init__.py
index 0fcf8da2..e1eaca9c 100644
--- a/skchange/change_scores/__init__.py
+++ b/skchange/change_scores/__init__.py
@@ -4,6 +4,7 @@
     ContinuousLinearTrendScore,
 )
 from skchange.change_scores._cusum import CUSUM
+from skchange.change_scores._esac_score import ESACScore
 from skchange.change_scores._from_cost import ChangeScore, to_change_score
 from skchange.change_scores._multivariate_gaussian_score import (
     MultivariateGaussianScore,
@@ -14,6 +15,7 @@
     ChangeScore,
     MultivariateGaussianScore,
     CUSUM,
+    ESACScore,
 ]
 
 __all__ = CHANGE_SCORES + [to_change_score]
diff --git a/skchange/change_scores/_esac_score.py b/skchange/change_scores/_esac_score.py
new file mode 100644
index 00000000..823d9c6b
--- /dev/null
+++ b/skchange/change_scores/_esac_score.py
@@ -0,0 +1,263 @@
+"""The ESAC score for detecting changes in the mean of high-dimensional data."""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ..utils.validation.parameters import check_larger_than
+from ._cusum import cusum_score
+
+
+@njit
+def transform_esac(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+        Compute ESAC scores from CUSUM scores.
+
+        This function calculates the penalised score for the ESAC algorithm,
+        as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+        scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+            cusum_scores (np.ndarray): A 2D array where each row represents the
+                CUSUM scores for a specific candidate change location.
+            a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+                a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+            nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+                nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+            t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+                to the element in a_s and nu_s.
+            threshold (np.ndarray): A 1D array of penalty values, corresponding to
+                \gamma(t) in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+            tuple[np.ndarray, np.ndarray]: A tuple containing:
+                - output_scores (np.ndarray): A 2D array of computed ESAC scores. The
+                    array has one column, where each element represents the maximum
+                    score for each candidate change location.
+                - sargmax (np.ndarray): A 1D array giving sparisty level at which the
+                    maximum score was achieved for each candidate change location.
+
+    References
+    ----------
+        ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+            sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+            3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        self.threshold_dense = threshold_dense
+        self.threshold_sparse = threshold_sparse
+        super().__init__()
+
+        check_larger_than(0.0, self.threshold_dense, "threshold_dense")
+        check_larger_than(0.0, self.threshold_sparse, "threshold_sparse")
+
+    def _fit(self, X: np.ndarray, y=None):
+        """Fit the change score evaluator.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data to evaluate. Must be a 2D array.
+        y : None
+            Ignored. Included for API consistency by convention.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+        """
+        self._sums = col_cumsum(X, init_zero=True)
+        self.n = X.shape[0]
+        self.p = X.shape[1]
+        n = self.n
+        p = self.p
+
+        if p == 1:
+            self.a_s = np.array([0.0])
+            self.nu_s = np.array([1.0])
+            self.t_s = np.array([1])
+            self.threshold = np.array(
+                [self.threshold_dense * (np.sqrt(p * np.log(n)) + np.log(n))]
+            )
+        else:
+            max_s = min(np.sqrt(p * np.log(n)), p)
+
+            # Generate log2ss from 0 to floor(log2(max_s))
+            log2ss = np.arange(0, np.floor(np.log2(max_s)) + 1)
+
+            # calculate candidate sparsity values
+            ss = 2**log2ss
+
+            # add p as candidate sparsity and reverse order
+            ss = np.concatenate(([p], ss[::-1]))
+            self.t_s = np.array(ss, dtype=float)
+            ss = np.array(ss, dtype=float)
+
+            # Initialize as array
+            self.a_s = np.zeros_like(ss, dtype=float)
+            self.a_s[1:] = np.sqrt(
+                2 * np.log(np.exp(1) * p * 4 * np.log(n) / ss[1:] ** 2)
+            )
+
+            # Calculate nu_as using the following formula:
+            # nu(a) = E(Z^2|Z^2 > a^2) = 1 + a * exp(log(phi(a)) - log(1 - Phi(a))),
+            # where Z ~ N(0, 1) and phi is the standard normal density function,
+            # and Phi is the standard normal cumulative distribution function.
+            log_dnorm = norm.logpdf(self.a_s)
+            log_pnorm_upper = norm.logsf(self.a_s)
+
+            self.nu_s = 1 + self.a_s * np.exp(log_dnorm - log_pnorm_upper)
+
+            self.threshold = np.zeros_like(ss, dtype=float)
+            self.threshold[0] = self.threshold_dense * (
+                np.sqrt(4 * p * np.log(n)) + 4 * np.log(n)
+            )
+            self.threshold[1:] = self.threshold_sparse * (
+                ss[1:] * np.log(np.exp(1) * p * 4 * np.log(n) / ss[1:] ** 2)
+                + 4 * np.log(n)
+            )
+
+        return self
+
+    def _evaluate(self, cuts: np.ndarray):
+        """Evaluate the change score for a split within an interval.
+
+        Evaluates the score for `X[start:split]` vs. `X[split:end]` for each
+        start, split, end in cuts.
+
+        Parameters
+        ----------
+        cuts : np.ndarray
+            A 2D array with three columns of integer locations.
+            The first column is the ``start``, the second is the ``split``, and the
+            third is the ``end`` of the interval to evaluate.
+            The difference between subsets ``X[start:split]`` and ``X[split:end]`` is
+            evaluated for each row in `cuts`.
+
+        Returns
+        -------
+        scores : np.ndarray
+            A 2D array of change scores. One row for each cut. The number of
+            columns is 1 if the change score is inherently multivariate. The number of
+            columns is equal to the number of columns in the input data if the score is
+            univariate. In this case, each column represents the univariate score for
+            the corresponding input data column.
+        """
+        starts = cuts[:, 0]
+        splits = cuts[:, 1]
+        ends = cuts[:, 2]
+        cusum_scores = cusum_score(starts, ends, splits, self._sums)
+        thresholded_cusum_scores, sargmaxes = transform_esac(
+            cusum_scores, self.a_s, self.nu_s, self.t_s, self.threshold
+        )
+        self.sargmaxes = sargmaxes
+        return thresholded_cusum_scores
+
+    @property
+    def min_size(self) -> int | None:
+        """Minimum size of the interval to evaluate.
+
+        The size of each interval is defined as ``cuts[i, 1] - cuts[i, 0]``.
+
+        Returns
+        -------
+        int or None
+            The minimum valid size of an interval to evaluate. If ``None``, it is
+            unknown what the minimum size is. E.g., the scorer may need to be fitted
+            first to determine the minimum size.
+        """
+        return 1
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            There are currently no reserved values for change scores.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        params = [
+            {"threshold_dense": 1.5, "threshold_sparse": 1.0},
+            {"threshold_dense": 2.0, "threshold_sparse": 2.0},
+        ]
+        return params


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR implements the new ESAC score estimator for change point detection by introducing the ESACScore class and its associated thresholding function, ESAC_Threshold, into the change scoring framework. Key changes include:
- Adding ESACScore and ESAC_Threshold to compute penalised CUSUM scores.
- Exposing ESACScore in the module’s __init__ for public use.
- Including an interactive exploration script for ESAC penalty analysis.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File                                               | Description                                                           |
| -------------------------------------------------- | --------------------------------------------------------------------- |
| skchange/change_scores/_esac_score.py                | Implements ESACScore and helper function ESAC_Threshold.               |
| skchange/change_scores/__init__.py                 | Integrates ESACScore into the public API.                             |
| interactive/explore_ESAC.py                        | Provides an interactive script to explore ESAC penalty behavior.        |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:06:04Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T18:00:13Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T18:03:20Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T18:06:11Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T18:08:35Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T18:09:45Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T09:49:50Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T09:51:01Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T09:56:42Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T09:57:12Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T10:01:33Z
user: login: peraugustmoen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T10:03:53Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T07:30:30Z
user: login: Tveten
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T12:47:03Z

Review comments:
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparisty level at which the maximum score was achieved.
path: skchange/change_scores/_esac_score.py
user: login: Copilot
body: Typo detected: 'sparisty' should be corrected to 'sparsity' for clarity.
```suggestion
                to the sparsity level at which the maximum score was achieved.
```
created_at: 2025-04-26T13:06:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+"""Interactive exploration of Seeded Binary Segmentation with ESAC penalty."""
+
+import plotly.express as px
+
+from skchange.change_detectors import SeededBinarySegmentation
+from skchange.change_scores import ESACScore
+from skchange.datasets import generate_alternating_data
+from skchange.utils.benchmarking.profiler import Profiler
+
+df = generate_alternating_data(
+    n_segments=2, mean=10, segment_length=20, p=1, random_state=7
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
path: interactive/explore_ESAC.py
user: login: Copilot
body: Typo in printed label: 'armaxes' should likely be 'sargmaxes' to match the variable name.
```suggestion
print("sargmaxes sparsity: ", detector.fitted_score.sargmaxes)
```
created_at: 2025-04-26T13:06:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+"""Interactive exploration of Seeded Binary Segmentation with ESAC penalty."""
+
+import plotly.express as px
+
+from skchange.change_detectors import SeededBinarySegmentation
+from skchange.change_scores import ESACScore
+from skchange.datasets import generate_alternating_data
+from skchange.utils.benchmarking.profiler import Profiler
+
+df = generate_alternating_data(
+    n_segments=2, mean=10, segment_length=20, p=1, random_state=7
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+## check sparsity
+df = generate_alternating_data(
+    n_segments=10,
+    mean=10,
+    segment_length=20,
+    p=1000,
+    random_state=7,
+    affected_proportion=0.001,
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+print("n : ", detector.fitted_score.n)
+print("p : ", detector.fitted_score.p)
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+# Profiling
+n = int(1e4)
+df = generate_alternating_data(n_segments=1, mean=0, segment_length=n, p=100)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+profiler = Profiler()
+profiler.start()
+detector.fit_predict(df)
+profiler.stop()
+
+
+# Test tuning
+df_train = generate_alternating_data(
+    n_segments=1, mean=0, segment_length=10000, p=1, random_state=9
+)
+df_test = generate_alternating_data(
+    n_segments=10, mean=5, segment_length=1000, p=1, random_state=5
+)
+detector = SeededBinarySegmentation(threshold_scale=None, min_segment_length=10)
path: interactive/explore_ESAC.py
user: login: Tveten
body: Does this line run? The `threshold_scale` parameter doesn't exist anymore.
created_at: 2025-04-26T18:00:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: Naming convention: functions only have lower_case letters, and preferably a verb. To be in line with scikit-learn and sktime conventions, I'd suggest `transform_esac` as the function name (:
created_at: 2025-04-26T18:03:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: Remove the template docstring. Replace the whole docstring by e.g. """The ESAC score for detecting changes in the mean of high-dimensional data."""
created_at: 2025-04-26T18:06:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: move super.__init__ to after the assignments of arguments to self
created_at: 2025-04-26T18:08:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
+        self.threshold_dense = threshold_dense
+        self.threshold_sparse = threshold_sparse
+
+    def _fit(self, X: np.ndarray, y=None):
+        """Fit the change score evaluator.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data to evaluate. Must be a 2D array.
+        y : None
+            Ignored. Included for API consistency by convention.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+        """
+        self._sums = col_cumsum(X, init_zero=True)
+        self.n = X.shape[0]
+        self.p = X.shape[1]
+        n = self.n
+        p = self.p
+
+        if p == 1:
+            self.a_s = np.array([0.0])
+            self.nu_s = np.array([1.0])
+            self.t_s = np.array([1])
+            self.threshold = np.array(
+                [self.threshold_dense * (np.sqrt(p * np.log(n)) + np.log(n))]
+            )
+        else:
+            max_s = min(np.sqrt(p * np.log(n)), p)
+
+            # Generate log2ss from 0 to floor(log2(max_s))
+            log2ss = np.arange(0, np.floor(np.log2(max_s)) + 1)
+
+            # calculate candidate sparsity values
+            ss = 2**log2ss
+
+            # add p as candidate sparsity and reverse order
+            ss = np.concatenate(([p], ss[::-1]))
+            self.t_s = np.array(ss, dtype=float)
+            ss = np.array(ss, dtype=float)
+
+            # Initialize as array
+            self.a_s = np.zeros_like(ss, dtype=float)
+            self.a_s[1:] = np.sqrt(
+                2 * np.log(np.exp(1) * p * 4 * np.log(n) / ss[1:] ** 2)
+            )
+
+            # Calculate nu_as
+            # nu_as = 1 + as * exp(dnorm(as, log=TRUE) - pnorm(as, lower.tail=FALSE,
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: I guess this R code comment can be removed?
created_at: 2025-04-26T18:09:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,70 @@
+"""Interactive exploration of Seeded Binary Segmentation with ESAC penalty."""
+
+import plotly.express as px
+
+from skchange.change_detectors import SeededBinarySegmentation
+from skchange.change_scores import ESACScore
+from skchange.datasets import generate_alternating_data
+from skchange.utils.benchmarking.profiler import Profiler
+
+df = generate_alternating_data(
+    n_segments=2, mean=10, segment_length=20, p=1, random_state=7
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+## check sparsity
+df = generate_alternating_data(
+    n_segments=10,
+    mean=10,
+    segment_length=20,
+    p=1000,
+    random_state=7,
+    affected_proportion=0.001,
+)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+print(detector.fit_predict(df))
+print("")
+print("a_s: ", detector.fitted_score.a_s)
+print("nu_s: ", detector.fitted_score.nu_s)
+print("t_s: ", detector.fitted_score.t_s)
+print("threshold: ", detector.fitted_score.threshold)
+print("armaxes sparsity: ", detector.fitted_score.sargmaxes)
+print("n : ", detector.fitted_score.n)
+print("p : ", detector.fitted_score.p)
+
+px.line(df)
+px.scatter(detector.scores, x="argmax", y="max", hover_data=["start", "end"])
+
+
+# Profiling
+n = int(1e4)
+df = generate_alternating_data(n_segments=1, mean=0, segment_length=n, p=100)
+detector = SeededBinarySegmentation(growth_factor=2, change_score=ESACScore())
+profiler = Profiler()
+profiler.start()
+detector.fit_predict(df)
+profiler.stop()
+
+
+# Test tuning
+df_train = generate_alternating_data(
+    n_segments=1, mean=0, segment_length=10000, p=1, random_state=9
+)
+df_test = generate_alternating_data(
+    n_segments=10, mean=5, segment_length=1000, p=1, random_state=5
+)
+detector = SeededBinarySegmentation(threshold_scale=None, min_segment_length=10)
path: interactive/explore_ESAC.py
user: login: peraugustmoen
body: removing!
created_at: 2025-04-27T09:49:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
path: skchange/change_scores/_esac_score.py
user: login: peraugustmoen
body: Fixed now.
created_at: 2025-04-27T09:51:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
path: skchange/change_scores/_esac_score.py
user: login: peraugustmoen
body: Done
created_at: 2025-04-27T09:56:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
path: skchange/change_scores/_esac_score.py
user: login: peraugustmoen
body: Done
created_at: 2025-04-27T09:57:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
path: skchange/change_scores/_esac_score.py
user: login: peraugustmoen
body: Any particular reason for doing this? I thought it was standard practice to initialize the parent class first
created_at: 2025-04-27T10:01:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
+        self.threshold_dense = threshold_dense
+        self.threshold_sparse = threshold_sparse
+
+    def _fit(self, X: np.ndarray, y=None):
+        """Fit the change score evaluator.
+
+        Parameters
+        ----------
+        X : np.ndarray
+            Data to evaluate. Must be a 2D array.
+        y : None
+            Ignored. Included for API consistency by convention.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+        """
+        self._sums = col_cumsum(X, init_zero=True)
+        self.n = X.shape[0]
+        self.p = X.shape[1]
+        n = self.n
+        p = self.p
+
+        if p == 1:
+            self.a_s = np.array([0.0])
+            self.nu_s = np.array([1.0])
+            self.t_s = np.array([1])
+            self.threshold = np.array(
+                [self.threshold_dense * (np.sqrt(p * np.log(n)) + np.log(n))]
+            )
+        else:
+            max_s = min(np.sqrt(p * np.log(n)), p)
+
+            # Generate log2ss from 0 to floor(log2(max_s))
+            log2ss = np.arange(0, np.floor(np.log2(max_s)) + 1)
+
+            # calculate candidate sparsity values
+            ss = 2**log2ss
+
+            # add p as candidate sparsity and reverse order
+            ss = np.concatenate(([p], ss[::-1]))
+            self.t_s = np.array(ss, dtype=float)
+            ss = np.array(ss, dtype=float)
+
+            # Initialize as array
+            self.a_s = np.zeros_like(ss, dtype=float)
+            self.a_s[1:] = np.sqrt(
+                2 * np.log(np.exp(1) * p * 4 * np.log(n) / ss[1:] ** 2)
+            )
+
+            # Calculate nu_as
+            # nu_as = 1 + as * exp(dnorm(as, log=TRUE) - pnorm(as, lower.tail=FALSE,
path: skchange/change_scores/_esac_score.py
user: login: peraugustmoen
body: Done
created_at: 2025-04-27T10:03:53Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: To be honest I'm just following the sktime template here. I've been wondering about the same thing and I'll try to figure it out.
created_at: 2025-04-28T07:30:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,293 @@
+"""Extension template for change scores as interval scorers.
+
+Adapted from the sktime extension templates.
+
+Purpose of this implementation template:
+    quick implementation of new estimators following the template
+    NOT a concrete class to import! This is NOT a base class or concrete class!
+    This is to be used as a "fill-in" coding template.
+
+How to use this implementation template to implement a new estimator:
+- make a copy of the template in a suitable location, give it a descriptive name.
+- work through all the "todo" comments below
+- fill in code for mandatory methods, and optionally for optional methods
+- you can add more private methods, but do not override BaseChangeScore's private
+    methods. An easy way to be safe is to prefix your methods with "_custom"
+- change docstrings for functions and the file
+- ensure interface compatibility by sktime.utils.estimator_checks.check_estimator
+- once complete: use as a local library, or contribute to skchange via PR
+- more details:
+  https://www.sktime.net/en/stable/developer_guide/add_estimators.html
+
+Mandatory implements:
+    fitting                  - _fit(self, X, y=None)
+    evaluating optimal param - _evaluate(self, cuts)
+
+Optional implements:
+    minimum size of interval  - min_size(self)
+    number of parameters      - get_param_size(self, p)
+
+Testing - required for sktime test framework and check_estimator usage:
+    get default parameters for test instance(s) - get_test_params()
+
+copyright: skchange developers, BSD-3-Clause License (see LICENSE file)
+"""
+
+import numpy as np
+from scipy.stats import norm
+
+from ..base import BaseIntervalScorer
+from ..utils.numba import njit
+from ..utils.numba.stats import col_cumsum
+from ._cusum import cusum_score
+
+
+# TODO: document this
+@njit
+def ESAC_Threshold(
+    cusum_scores: np.ndarray,
+    a_s: np.ndarray,
+    nu_s: np.ndarray,
+    t_s: np.ndarray,
+    threshold: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+    r"""
+    Compute ESAC scores from CUSUM scores.
+
+    This function calculates the penalised score for the ESAC algorithm,
+    as defined in Equation (6) in [1]_. The outputs are penalised CUSUM
+    scores computed from the input CUSUM scores.
+
+    Parameters
+    ----------
+        cusum_scores (np.ndarray): A 2D array where each row represents the
+            CUSUM scores for a specific time step.
+        a_s (np.ndarray): A 1D array of hard threshold values. Correspond to
+            a(t) as defined in Equation (4) in [1]_ for each t specified in t_s.
+        nu_s (np.ndarray): A 1D array of mean-centering terms. Correspond to
+            nu(t) as defined after Equation (4) in [1]_ for each t specified in t_s.
+        t_s (np.ndarray): A 1D array of candidate sparsity values corresponding
+            to the element in a_s and nu_s.
+        threshold (np.ndarray): A 1D array of penalty values, corresponding to \gamma(t)
+            in Equation (4) in [1]_, where t is as defined in t_s.
+
+    Returns
+    -------
+        tuple[np.ndarray, np.ndarray]: A tuple containing:
+            - output_scores (np.ndarray): A 2D array of computed ESAC scores. The array
+                has one column.
+            - sargmax (np.ndarray): A 1D array of indices or labels corresponding
+                to the sparsity level at which the maximum score was achieved.
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+    num_levels = len(threshold)
+    num_cusum_scores = len(cusum_scores)
+    output_scores = np.zeros(num_cusum_scores, dtype=np.float64)
+    sargmax = np.zeros(num_cusum_scores, dtype=np.int64)
+
+    for i in range(num_cusum_scores):
+        temp_max = -np.inf
+        for j in range(num_levels):
+            temp_vec = (cusum_scores[i])[np.abs(cusum_scores[i]) > a_s[j]]
+            if len(temp_vec) > 0:
+                temp = np.sum(temp_vec**2 - nu_s[j]) - threshold[j]
+                if temp > temp_max:
+                    temp_max = temp
+                    sargmax[i] = t_s[j]
+
+        output_scores[i] = temp_max
+
+    return output_scores.reshape(-1, 1), sargmax
+
+
+class ESACScore(BaseIntervalScorer):
+    """Custom score class.
+
+    This is the sparsity adaptive penalised CUSUM score for a change in the mean.
+    The ESAC score is a penalised version of the CUSUM score, where the CUSUM of
+    each time series is thresholded, mean-centered and penalised by a sparsity
+    dependant penalty. The score is defined in Equation (6) in [1]_.
+
+    Parameters
+    ----------
+    threshold_dense : float, optional, default=1.5
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        dense case where the candidate sparisity level t is greater than or equal to
+        sqrt(p * log(n)).
+    threshold_sparse : float, optional, default=1.0
+        The leading constant in the penalty function taken as in (8) in [1]_ in the
+        sparse case where the candidate sparisity level t is less than sqrt(p * log(n)).
+
+    References
+    ----------
+    ..  [1] Per August Jarval Moen, Ingrid Kristine Glad, Martin Tveten. Efficient
+        sparsity adaptive changepoint estimation. Electron. J. Statist. 18 (2)
+        3975 - 4038, 2024. https://doi.org/10.1214/24-EJS2294.
+    """
+
+    _tags = {
+        "authors": ["peraugustmoen", "Tveten"],
+        "maintainers": ["peraugustmoen", "Tveten"],
+        "task": "change_score",
+        "distribution_type": "Gaussian",
+        "is_conditional": False,
+        "is_aggregated": True,
+        "is_penalised": True,
+    }
+
+    def __init__(
+        self,
+        threshold_dense=1.5,
+        threshold_sparse=1.0,
+    ):
+        super().__init__()
path: skchange/change_scores/_esac_score.py
user: login: Tveten
body: I asked on the sktime Discord and got this reply. For the record:

Martin Tveten — 9:40 AM
I'm looking at the extension templates and have a question out of curiosity. super().__init__() should apparently always be called after writing parameters to self. Why is this? And why can't it be the first call in a subclass' __init__? What's an example of an estimator that would fail if super().__init__ was called before writing parameters to self?

Franz Kiraly — 10:01 AM
- it is the strongest API convention that all classes behave like data classes. So params need to be written to self and never mutated.
- the repr, i.e., print to console, requires the parameters to be written, in the style MyClass(param="paramvalue") etc. If an exception is thrown before the parameters are set, the repr will trigger another exception. So, as early as possible, we should set the parameters to minimzie the chance of that happening.
created_at: 2025-04-28T12:47:03Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-26T13:07:23Z
body: ## [Codecov](https://app.codecov.io/gh/NorskRegnesentral/skchange/pull/80?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NorskRegnesentral) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.00%. Comparing base [(`e139b3c`)](https://app.codecov.io/gh/NorskRegnesentral/skchange/commit/e139b3c8407745afb4e21021e69f73c06e5cea71?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NorskRegnesentral) to head [(`4464c66`)](https://app.codecov.io/gh/NorskRegnesentral/skchange/commit/4464c6616c80980d24a9b8f3684ed2745bbf137e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NorskRegnesentral).
> Report is 23 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #80      +/-   ##
==========================================
+ Coverage   98.97%   99.00%   +0.02%     
==========================================
  Files          51       52       +1     
  Lines        2642     2716      +74     
==========================================
+ Hits         2615     2689      +74     
  Misses         27       27              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/NorskRegnesentral/skchange/pull/80?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NorskRegnesentral).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NorskRegnesentral).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: peraugustmoen
created_at: 2025-04-27T10:27:36Z
body: Codecov is not happy with the test coverage now. I don't think this is caused by the small changes I have made..?
author_association: COLLABORATOR
user: login: Tveten
created_at: 2025-04-28T07:23:02Z
body: > Codecov is not happy with the test coverage now. I don't think this is caused by the small changes I have made..?

The tests with numba need to complete to complete to coverage report, so it always complains about the coverage for approx 10 minutes until those test have completed :)
author_association: COLLABORATOR
user: login: peraugustmoen
created_at: 2025-04-28T09:35:22Z
body: > > Codecov is not happy with the test coverage now. I don't think this is caused by the small changes I have made..?
> 
> The tests with numba need to complete to complete to coverage report, so it always complains about the coverage for approx 10 minutes until those test have completed :)

Ah, I see!
author_association: COLLABORATOR