blob: 54cfdb9adbebaee938de1b4ab1d9965582057c85 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import copy
from dataclasses import dataclass, replace
from typing import List, Optional, Sequence, SupportsFloat, Tuple
from scipy.stats import ttest_ind_from_stats
from otava.change_point_divisive.base import (
BaseStats,
CandidateChangePoint,
ChangePoint,
GenericStats,
SignificanceTester,
)
from otava.change_point_divisive.calculator import PairDistanceCalculator
from otava.change_point_divisive.detector import ChangePointDetector
from otava.change_point_divisive.significance_test import (
PermutationsSignificanceTester,
PermutationStats,
)
@dataclass
class TTestStats(BaseStats):
"""
Statistics related to the calculation of a two-sided Student's T-test.
Note that p-value is already in BaseStats.
"""
tstatistic: float = 0.0
degrees_of_freedom: int = 0
def copy(self):
# replace() preserves the subclass
return replace(self)
def to_json(self):
obj = super().to_json()
obj["tstatistic"] = self.tstatistic
obj["degrees_of_freedom"] = self.degrees_of_freedom
return obj
# Generic Change Point List
GenCPList = List[ChangePoint[GenericStats]]
# Permutation Change Point List
PermCPList = List[ChangePoint[PermutationStats]]
# T-test Change Point List
TtestCPList = List[ChangePoint[TTestStats]]
# TODO: Move to change_point_divisive.significance_test
class TTestSignificanceTester(SignificanceTester):
"""
Uses two-sided Student's T-test to decide if a candidate change point
splits the series into pieces that are significantly different from each other.
This test is good if the data between the change points have normal distribution.
It works well even with tiny numbers of points (<10).
"""
def compare(self, left: Sequence[SupportsFloat], right: Sequence[SupportsFloat]) -> TTestStats:
# defaults
p = 1.0
t = 0.0
base_stats = super().compare(left, right)
df = len(left) + len(right) - 2
# While "degrees of freedom" is a parameter that is here coming out of the use of T-Test,
# this is actually the same requirement as can be expressed more intuitively as:
# We need at least 3 data points to find a statistically significant change point.
# This is because if we had only 2 points, they can be either equal or different, but if they
# are different, we have no context for how different they have to be to be statistically significant.
if df > 0:
(t, p) = ttest_ind_from_stats(
base_stats.mean_1, base_stats.std_1, len(left), base_stats.mean_2, base_stats.std_2, len(right), alternative="two-sided"
)
return TTestStats(pvalue=p, mean_1=base_stats.mean_1, mean_2=base_stats.mean_2, std_1=base_stats.std_1, std_2=base_stats.std_2, tstatistic=t, degrees_of_freedom=df)
def change_point(
self,
candidate: CandidateChangePoint,
series: Sequence[SupportsFloat],
intervals: List[slice],
) -> ChangePoint[TTestStats]:
left, right = self.get_sides(candidate, series, intervals)
stats = self.compare(left, right)
return ChangePoint.from_candidate(candidate, stats)
def merge(
change_points: TtestCPList, series: Sequence[SupportsFloat], max_pvalue: float, min_magnitude: float
) -> TtestCPList:
"""
Merge step of the change point detection process from "Hunter: Using Change Point Detection
to Hunt for Performance Regressions" by Fleming et al. (https://doi.org/10.1145/3578244.3583719).
Parameters:
:param max_pvalue: maximum accepted pvalue
:param min_magnitude: minimum accepted relative change
"""
tester = TTestSignificanceTester(max_pvalue)
while change_points:
# Select the change point with weakest unacceptable P-value
# If all points have acceptable P-values, select the change-point with
# the least relative change:
weakest_cp = max(change_points, key=lambda c: c.stats.pvalue)
if weakest_cp.stats.pvalue < max_pvalue:
weakest_cp = min(change_points, key=lambda c: c.stats.change_magnitude())
if weakest_cp.stats.change_magnitude() > min_magnitude:
return change_points
# Remove duplicate change points from the list (that is, at same index)
weakest_cp_index = None
for i, cp in enumerate(change_points):
if cp.index == weakest_cp.index:
weakest_cp_index = i
break
assert weakest_cp_index is not None
del change_points[weakest_cp_index]
# We can't continue yet, because by removing a change_point
# the adjacent change points changed their properties.
# Recompute the adjacent change point stats:
intervals = tester.get_intervals(change_points)
def recompute(index: int):
if index < 0 or index >= len(change_points):
return
cp = change_points[index]
change_points[index] = tester.change_point(cp.to_candidate(), series, intervals)
recompute(weakest_cp_index)
recompute(weakest_cp_index + 1)
return change_points
def split(series: Sequence[SupportsFloat], window_len: int = 30, max_pvalue: float = 0.001,
new_points: Optional[int] = None, old_cp: Optional[TtestCPList] = None) -> TtestCPList:
"""
Split step of the change point detection process from "Hunter: Using Change Point Detection
to Hunt for Performance Regressions" by Fleming et al. (https://doi.org/10.1145/3578244.3583719).
"""
assert window_len >= 2, "Window length must be at least 2"
start = 0
step = int(window_len / 2)
change_points = []
# N new_points are appended to the end of series. Typically N=1.
# old_cp are the weak change points from before new points were added.
# We now just identify change points in the tail of the series, beginning at
# max(old_cp[-1], a step that is over 2 window_len from the end)
if new_points is not None and old_cp is not None:
change_points = old_cp[:]
steps_needed = new_points/window_len + 4
max_start = len(series) - steps_needed*window_len
for c in old_cp:
if c.index < max_start:
start = c.index
for s in range(0, len(series), step):
if s < max_start and start < s:
start = s
tester = TTestSignificanceTester(max_pvalue)
while start < len(series):
# Sliding window series[start : end]
end = min(start + window_len, len(series))
algo = ChangePointDetector(significance_tester=tester, calculator=PairDistanceCalculator)
new_change_points = algo.get_change_points(series, start, end)
last_new_change_point_index = new_change_points[-1].index if new_change_points else 0
start = max(last_new_change_point_index, start + step)
# incremental Otava can duplicate an old cp
cpindexes = [cp.index for cp in change_points]
for cp in new_change_points:
if cp.index not in cpindexes:
change_points += [cp]
cpindexes += [cp.index]
# Sort change points by index; required by get_intervals() and maintained by merge()
change_points.sort(key=lambda cp: cp.index)
intervals = tester.get_intervals(change_points)
return [tester.change_point(cp.to_candidate(), series, intervals) for cp in change_points]
def compute_change_points_orig(series: Sequence[SupportsFloat], max_pvalue: float = 0.001, seed: Optional[int] = None) -> Tuple[PermCPList, Optional[PermCPList]]:
"""
The original algorithm presented in "A Nonparametric Approach for Multiple Change Point
Analysis of Multivariate Data" by Matteson and James (https://doi.org/10.48550/arXiv.1306.4933).
The algorithm recursively splits the series in a way to maximize some measure of dissimilarity (denoted qhat)
between the chunks. Splitting happens as long as the dissimilarity is statistically significant.
"""
tester = PermutationsSignificanceTester(max_pvalue=max_pvalue, permutations=100, calculator=PairDistanceCalculator, seed=seed)
detector = ChangePointDetector(significance_tester=tester, calculator=PairDistanceCalculator)
change_points = detector.get_change_points(series=series)
return change_points, None
def compute_change_points(
series: Sequence[SupportsFloat], window_len: int = 50, max_pvalue: float = 0.001, min_magnitude: float = 0.0,
new_data: Optional[int] = None, old_weak_cp: Optional[GenCPList] = None
) -> Tuple[GenCPList, Optional[GenCPList]]:
"""
Change Point detection algorithm described in "Hunter: Using Change Point Detection to Hunt for Performance
Regressions" by Fleming et al. (https://doi.org/10.1145/3578244.3583719).
The algorithm consist of two steps:
1. Split step:
- splitting a series into subseries via sliding windows
- applying original change point detection algorithm to each window (https://doi.org/10.48550/arXiv.1306.4933)
with first-pass significance threshold max_pvalue (see clarification regarding this threshold below)
- make a set union operation over all detected change points, so-called weak change points. They are "weak"
change points because the algorithm uses modified (relaxed) max_pvalue for this step. In the current
implementation the first-pass significance threshold is 10x of the original max_pvalue. For example, if we want
to detect all change points at significance threshold 0.001, a threshold of 0.01 will be used at this step. Since
it's a higher threshold for p-values, the algorithm will find more points. However, these points are just POTENTIAL
change points, because some of them are significant at 0.01 but not at 0.001. In statistical terms, the evidence
that they are change points is weak (only 0.01 compared to 0.001). Hence, the term "weak" change points. They will
be filtered down to the final change points (with correct significance threshold) at the next step (Merge step)
of the algorithm. For the reasoning on why change points cannot be computed right away (and the need for
[weak change points -> change points] process) read Fleming et al. paper.
2. Merge step:
- Filters out weak change points recursively going bottom-up, keeping only high-quality change points, i.e., the
ones that meet either a p-value threshold criteria or relative magnitude change criteria.
"""
first_pass_pvalue = max_pvalue * 10 if max_pvalue < 0.05 else (max_pvalue * 2 if max_pvalue < 0.5 else max_pvalue)
weak_change_points = split(series, window_len, first_pass_pvalue, new_points=new_data, old_cp=old_weak_cp)
return merge(copy.copy(weak_change_points), series, max_pvalue, min_magnitude), weak_change_points