tsanomaly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tsanomaly/__init__.py +37 -0
- tsanomaly/calibrate/__init__.py +0 -0
- tsanomaly/calibrate/conformal.py +104 -0
- tsanomaly/calibrate/evt.py +119 -0
- tsanomaly/core/__init__.py +0 -0
- tsanomaly/core/clock.py +36 -0
- tsanomaly/core/config.py +127 -0
- tsanomaly/core/determinism.py +22 -0
- tsanomaly/core/errors.py +28 -0
- tsanomaly/core/result.py +91 -0
- tsanomaly/core/types.py +240 -0
- tsanomaly/detect/__init__.py +0 -0
- tsanomaly/detect/bocpd.py +94 -0
- tsanomaly/detect/cusum.py +53 -0
- tsanomaly/detect/drift.py +53 -0
- tsanomaly/detect/episodes.py +129 -0
- tsanomaly/detector.py +745 -0
- tsanomaly/eval/__init__.py +0 -0
- tsanomaly/eval/backtest.py +48 -0
- tsanomaly/eval/metrics.py +47 -0
- tsanomaly/eval/synth.py +77 -0
- tsanomaly/events/__init__.py +3 -0
- tsanomaly/events/store.py +43 -0
- tsanomaly/explain/__init__.py +0 -0
- tsanomaly/explain/card.py +132 -0
- tsanomaly/explain/provenance.py +44 -0
- tsanomaly/feedback/__init__.py +3 -0
- tsanomaly/feedback/labels.py +47 -0
- tsanomaly/incidents/__init__.py +3 -0
- tsanomaly/incidents/grouping.py +179 -0
- tsanomaly/io/__init__.py +0 -0
- tsanomaly/io/dimensions.py +23 -0
- tsanomaly/io/frames.py +135 -0
- tsanomaly/models/__init__.py +0 -0
- tsanomaly/models/base.py +36 -0
- tsanomaly/models/guard.py +78 -0
- tsanomaly/models/robust_baseline.py +211 -0
- tsanomaly/models/seasonal_naive.py +96 -0
- tsanomaly/models/selection.py +51 -0
- tsanomaly/preprocess/__init__.py +0 -0
- tsanomaly/preprocess/detrend.py +33 -0
- tsanomaly/preprocess/gaps.py +72 -0
- tsanomaly/preprocess/hampel.py +32 -0
- tsanomaly/preprocess/transform.py +45 -0
- tsanomaly/profile/__init__.py +0 -0
- tsanomaly/profile/classifier.py +78 -0
- tsanomaly/py.typed +0 -0
- tsanomaly/registry/__init__.py +76 -0
- tsanomaly/relate/__init__.py +0 -0
- tsanomaly/relate/coanomaly.py +60 -0
- tsanomaly/relate/efftests.py +24 -0
- tsanomaly/score/__init__.py +0 -0
- tsanomaly/score/duration.py +45 -0
- tsanomaly/score/scoring.py +50 -0
- tsanomaly/season/__init__.py +3 -0
- tsanomaly/season/acf_verify.py +77 -0
- tsanomaly/season/calendar.py +31 -0
- tsanomaly/season/detector.py +105 -0
- tsanomaly/season/indices.py +46 -0
- tsanomaly/season/lombscargle.py +42 -0
- tsanomaly/sinks.py +52 -0
- tsanomaly/storage/__init__.py +4 -0
- tsanomaly/storage/memory.py +31 -0
- tsanomaly/storage/sqlite.py +70 -0
- tsanomaly/storage/state.py +64 -0
- tsanomaly-0.1.0.dist-info/METADATA +155 -0
- tsanomaly-0.1.0.dist-info/RECORD +70 -0
- tsanomaly-0.1.0.dist-info/WHEEL +5 -0
- tsanomaly-0.1.0.dist-info/licenses/LICENSE +202 -0
- tsanomaly-0.1.0.dist-info/top_level.txt +1 -0
tsanomaly/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""A Python library for autonomous, explainable, real-time anomaly detection on time-series metrics.
|
|
2
|
+
|
|
3
|
+
Usage guide: docs/usage.md. Architecture: docs/architecture.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from tsanomaly.core.config import DetectorConfig
|
|
7
|
+
from tsanomaly.core.result import DetectionResult
|
|
8
|
+
from tsanomaly.core.types import (
|
|
9
|
+
Anomaly,
|
|
10
|
+
Envelope,
|
|
11
|
+
Episode,
|
|
12
|
+
EpisodeKind,
|
|
13
|
+
MetricId,
|
|
14
|
+
MetricType,
|
|
15
|
+
Profile,
|
|
16
|
+
ScoreBreakdown,
|
|
17
|
+
SeasonalComponent,
|
|
18
|
+
)
|
|
19
|
+
from tsanomaly.detector import Detector
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Detector",
|
|
25
|
+
"DetectorConfig",
|
|
26
|
+
"DetectionResult",
|
|
27
|
+
"MetricId",
|
|
28
|
+
"MetricType",
|
|
29
|
+
"Profile",
|
|
30
|
+
"SeasonalComponent",
|
|
31
|
+
"Envelope",
|
|
32
|
+
"Episode",
|
|
33
|
+
"EpisodeKind",
|
|
34
|
+
"ScoreBreakdown",
|
|
35
|
+
"Anomaly",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Adaptive Conformal Inference calibrator.
|
|
2
|
+
|
|
3
|
+
Wraps any BaselineModel's (expected, scale) into an interval whose realized
|
|
4
|
+
coverage tracks the target regardless of model misspecification:
|
|
5
|
+
``alpha_{t+1} = alpha_t + gamma * (alpha - err_t)`` (Gibbs & Candes 2021).
|
|
6
|
+
Distribution-free: the interval half-width is a quantile of recent
|
|
7
|
+
standardized nonconformity scores, not a Gaussian z-value.
|
|
8
|
+
|
|
9
|
+
Freezes during open episodes so an ongoing anomaly cannot widen its own envelope.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import deque
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ACICalibrator:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
target_coverage: float = 0.995,
|
|
24
|
+
gamma: float = 0.005,
|
|
25
|
+
reservoir: int = 512,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.alpha_target = 1.0 - target_coverage
|
|
28
|
+
self.gamma = gamma
|
|
29
|
+
self.alpha_t = self.alpha_target
|
|
30
|
+
self.scores: deque[float] = deque(maxlen=reservoir)
|
|
31
|
+
self.frozen = False
|
|
32
|
+
self.n_observed = 0
|
|
33
|
+
self.n_outside = 0
|
|
34
|
+
|
|
35
|
+
def _quantile(self) -> float:
|
|
36
|
+
n = len(self.scores)
|
|
37
|
+
if n < 30:
|
|
38
|
+
return 4.0 # conservative cold-start half-width (in scale units)
|
|
39
|
+
level = min(0.9999, (1.0 - self.alpha_t) * (n + 1) / n)
|
|
40
|
+
return float(np.quantile(np.array(self.scores), level))
|
|
41
|
+
|
|
42
|
+
def halfwidth(self, scale: float) -> float:
|
|
43
|
+
return self._quantile() * scale
|
|
44
|
+
|
|
45
|
+
def observe(self, nonconformity: float, outside: bool, learn_score: bool = True) -> None:
|
|
46
|
+
"""Feed one realized coverage outcome; no-op while frozen.
|
|
47
|
+
|
|
48
|
+
The alpha_t coverage-feedback update runs on EVERY sample -- including
|
|
49
|
+
ones inside an open episode. Freezing it there silences the very misses
|
|
50
|
+
ACI must react to (an episode's opening points are exactly its coverage
|
|
51
|
+
errors) and alpha_t drifts upward on pure successes, silently narrowing
|
|
52
|
+
the envelope. Only the nonconformity reservoir is gated by
|
|
53
|
+
``learn_score`` so anomalous magnitudes never widen future envelopes.
|
|
54
|
+
"""
|
|
55
|
+
if self.frozen:
|
|
56
|
+
return
|
|
57
|
+
err = 1.0 if outside else 0.0
|
|
58
|
+
self.alpha_t = float(
|
|
59
|
+
np.clip(
|
|
60
|
+
self.alpha_t + self.gamma * (self.alpha_target - err),
|
|
61
|
+
self.alpha_target / 20,
|
|
62
|
+
0.5,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
if learn_score:
|
|
66
|
+
# Reservoir AND the coverage diagnostic count only non-episode
|
|
67
|
+
# points: the diagnostic answers "is the envelope honest on normal
|
|
68
|
+
# data" -- a true incident's points are anomalies, not calibration
|
|
69
|
+
# misses. (alpha_t above still reacts to every sample.)
|
|
70
|
+
self.scores.append(float(nonconformity))
|
|
71
|
+
self.n_observed += 1
|
|
72
|
+
self.n_outside += int(outside)
|
|
73
|
+
|
|
74
|
+
def warm(self, nonconformities: np.ndarray) -> None:
|
|
75
|
+
for s in nonconformities[np.isfinite(nonconformities)]:
|
|
76
|
+
self.scores.append(float(s))
|
|
77
|
+
self.alpha_t = self.alpha_target
|
|
78
|
+
|
|
79
|
+
def coverage_error(self) -> float | None:
|
|
80
|
+
"""Realized-minus-target coverage over everything observed (diagnostics)."""
|
|
81
|
+
if self.n_observed < 50:
|
|
82
|
+
return None
|
|
83
|
+
realized = 1.0 - self.n_outside / self.n_observed
|
|
84
|
+
return realized - (1.0 - self.alpha_target)
|
|
85
|
+
|
|
86
|
+
def state_dict(self) -> dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
"alpha_target": self.alpha_target,
|
|
89
|
+
"gamma": self.gamma,
|
|
90
|
+
"alpha_t": self.alpha_t,
|
|
91
|
+
"scores": [round(s, 6) for s in self.scores],
|
|
92
|
+
"reservoir": self.scores.maxlen,
|
|
93
|
+
"n_observed": self.n_observed,
|
|
94
|
+
"n_outside": self.n_outside,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_state(cls, d: dict[str, Any]) -> "ACICalibrator":
|
|
99
|
+
c = cls(1.0 - d["alpha_target"], d["gamma"], d["reservoir"])
|
|
100
|
+
c.alpha_t = d["alpha_t"]
|
|
101
|
+
c.scores.extend(d["scores"])
|
|
102
|
+
c.n_observed = d["n_observed"]
|
|
103
|
+
c.n_outside = d["n_outside"]
|
|
104
|
+
return c
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Streaming extreme-value tail model.
|
|
2
|
+
|
|
3
|
+
Generalized Pareto fit over exceedances of a high quantile of standardized
|
|
4
|
+
residual magnitudes -> calibrated tail probabilities for "how far outside the
|
|
5
|
+
envelope" (heavy tails honest, unlike Gaussian). Empirical fallback below the
|
|
6
|
+
threshold or before enough exceedances accumulate, so p_mag works from the
|
|
7
|
+
first episode.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import deque
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import stats
|
|
17
|
+
|
|
18
|
+
_REFIT_EVERY = 100
|
|
19
|
+
_MIN_EXCEEDANCES = 30
|
|
20
|
+
_THRESHOLD_Q = 0.95
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TailModel:
|
|
24
|
+
def __init__(self, buf_size: int = 2048, exc_size: int = 256) -> None:
|
|
25
|
+
self.all_s: deque[float] = deque(maxlen=buf_size)
|
|
26
|
+
self.exceedances: deque[float] = deque(maxlen=exc_size)
|
|
27
|
+
self.u: float = float("inf") # POT threshold
|
|
28
|
+
self.n_total = 0
|
|
29
|
+
self.n_exc = 0
|
|
30
|
+
self.gpd: tuple[float, float] | None = None # (shape c, scale)
|
|
31
|
+
self._since_refit = 0
|
|
32
|
+
self.frozen = False
|
|
33
|
+
|
|
34
|
+
def warm(self, magnitudes: np.ndarray) -> None:
|
|
35
|
+
for s in magnitudes[np.isfinite(magnitudes)]:
|
|
36
|
+
self.all_s.append(float(s))
|
|
37
|
+
self.n_total = len(self.all_s)
|
|
38
|
+
self._refit_threshold()
|
|
39
|
+
for s in self.all_s:
|
|
40
|
+
if s > self.u:
|
|
41
|
+
self.exceedances.append(s)
|
|
42
|
+
self.n_exc = len(self.exceedances)
|
|
43
|
+
self._fit_gpd()
|
|
44
|
+
|
|
45
|
+
def observe(self, magnitude: float) -> None:
|
|
46
|
+
if self.frozen or not np.isfinite(magnitude):
|
|
47
|
+
return
|
|
48
|
+
self.all_s.append(float(magnitude))
|
|
49
|
+
self.n_total += 1
|
|
50
|
+
if magnitude > self.u:
|
|
51
|
+
self.exceedances.append(float(magnitude))
|
|
52
|
+
self.n_exc += 1
|
|
53
|
+
self._since_refit += 1
|
|
54
|
+
if self._since_refit >= _REFIT_EVERY:
|
|
55
|
+
self._refit_threshold()
|
|
56
|
+
self._fit_gpd()
|
|
57
|
+
self._since_refit = 0
|
|
58
|
+
|
|
59
|
+
def _refit_threshold(self) -> None:
|
|
60
|
+
if len(self.all_s) >= 50:
|
|
61
|
+
self.u = float(np.quantile(np.array(self.all_s), _THRESHOLD_Q))
|
|
62
|
+
|
|
63
|
+
def _fit_gpd(self) -> None:
|
|
64
|
+
exc = np.array([e - self.u for e in self.exceedances if e > self.u])
|
|
65
|
+
if len(exc) < _MIN_EXCEEDANCES:
|
|
66
|
+
# too few exceedances to estimate a shape: exponential tail (shape 0)
|
|
67
|
+
# still decays beyond the observed record
|
|
68
|
+
if len(exc) >= 5:
|
|
69
|
+
self.gpd = (0.0, float(max(np.mean(exc), 1e-9)))
|
|
70
|
+
else:
|
|
71
|
+
self.gpd = None
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
c, loc, scale = stats.genpareto.fit(exc, floc=0.0)
|
|
75
|
+
# shape capped at 0.5: heavier tails are infinite-variance
|
|
76
|
+
self.gpd = (float(np.clip(c, -0.5, 0.5)), float(max(scale, 1e-9)))
|
|
77
|
+
except Exception:
|
|
78
|
+
self.gpd = None
|
|
79
|
+
|
|
80
|
+
def tail_p(self, magnitude: float) -> float:
|
|
81
|
+
"""P(|z| >= magnitude) under the learned residual-magnitude distribution."""
|
|
82
|
+
if not np.isfinite(magnitude) or magnitude <= 0:
|
|
83
|
+
return 1.0
|
|
84
|
+
n = max(1, self.n_total)
|
|
85
|
+
p_u = self.n_exc / n if self.n_exc else 1.0 / (n + 1)
|
|
86
|
+
if magnitude <= self.u or not np.isfinite(self.u):
|
|
87
|
+
if len(self.all_s) >= 30:
|
|
88
|
+
arr = np.array(self.all_s)
|
|
89
|
+
return float(
|
|
90
|
+
min(1.0, (np.sum(arr >= magnitude) + 1) / (len(arr) + 1))
|
|
91
|
+
)
|
|
92
|
+
# cold start: normal tail as a stopgap
|
|
93
|
+
return float(min(1.0, 2 * stats.norm.sf(magnitude)))
|
|
94
|
+
if self.gpd is not None:
|
|
95
|
+
c, scale = self.gpd
|
|
96
|
+
return float(max(1e-12, p_u * stats.genpareto.sf(magnitude - self.u, c, scale=scale)))
|
|
97
|
+
exc = np.array(self.exceedances)
|
|
98
|
+
return float(max(1e-12, p_u * (np.sum(exc >= magnitude) + 1) / (len(exc) + 1)))
|
|
99
|
+
|
|
100
|
+
def state_dict(self) -> dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"all_s": [round(s, 5) for s in self.all_s],
|
|
103
|
+
"exceedances": [round(s, 5) for s in self.exceedances],
|
|
104
|
+
"u": self.u if np.isfinite(self.u) else None,
|
|
105
|
+
"n_total": self.n_total,
|
|
106
|
+
"n_exc": self.n_exc,
|
|
107
|
+
"gpd": list(self.gpd) if self.gpd else None,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_state(cls, d: dict[str, Any]) -> "TailModel":
|
|
112
|
+
t = cls()
|
|
113
|
+
t.all_s.extend(d["all_s"])
|
|
114
|
+
t.exceedances.extend(d["exceedances"])
|
|
115
|
+
t.u = d["u"] if d["u"] is not None else float("inf")
|
|
116
|
+
t.n_total = d["n_total"]
|
|
117
|
+
t.n_exc = d["n_exc"]
|
|
118
|
+
t.gpd = tuple(d["gpd"]) if d["gpd"] else None
|
|
119
|
+
return t
|
|
File without changes
|
tsanomaly/core/clock.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Injectable clock.
|
|
2
|
+
|
|
3
|
+
Statistical code never reads the wall clock; time comes from data. The clock
|
|
4
|
+
exists only for operational concerns (stall detection, schedules) and for
|
|
5
|
+
deterministic tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Clock(Protocol):
|
|
15
|
+
def now_ns(self) -> int: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SystemClock:
|
|
19
|
+
def now_ns(self) -> int:
|
|
20
|
+
return time.time_ns()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ManualClock:
|
|
24
|
+
"""Test clock advanced explicitly."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, start_ns: int = 0) -> None:
|
|
27
|
+
self._now = start_ns
|
|
28
|
+
|
|
29
|
+
def now_ns(self) -> int:
|
|
30
|
+
return self._now
|
|
31
|
+
|
|
32
|
+
def advance(self, delta_ns: int) -> None:
|
|
33
|
+
self._now += delta_ns
|
|
34
|
+
|
|
35
|
+
def set(self, ts_ns: int) -> None:
|
|
36
|
+
self._now = ts_ns
|
tsanomaly/core/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Configuration tree.
|
|
2
|
+
|
|
3
|
+
Three layers: built-in defaults -> tsanomaly.toml -> per-call overrides.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from tsanomaly.core.errors import ConfigError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Section(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EnvelopeConfig(_Section):
|
|
23
|
+
target_coverage: float = Field(0.995, gt=0.5, lt=1.0) # fraction of normal points inside the envelope
|
|
24
|
+
aci_gamma: float = Field(0.02, gt=0.0, lt=0.5) # ACI step size
|
|
25
|
+
reservoir: int = Field(512, ge=32) # conformal residual reservoir
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EpisodeConfig(_Section):
|
|
29
|
+
close_after: int = Field(3, ge=1) # consecutive in-envelope samples to close
|
|
30
|
+
merge_gap: int = Field(3, ge=0) # samples between episodes that merge
|
|
31
|
+
max_points: int = Field(5000, ge=10) # bounded episode point buffer
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SeasonConfig(_Section):
|
|
35
|
+
max_components: int = Field(2, ge=0) # max simultaneous seasonal components
|
|
36
|
+
strength_floor: float = Field(0.3, ge=0.0, le=1.0) # Hyndman F_s floor
|
|
37
|
+
n_candidates: int = Field(8, ge=1) # top-K periodogram peaks
|
|
38
|
+
null_shuffles: int = Field(100, ge=10) # block-permutation null size
|
|
39
|
+
null_quantile: float = Field(0.99, gt=0.5, lt=1.0)
|
|
40
|
+
min_cycles: float = Field(2.0, ge=1.5) # data must span >= this many periods
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PreprocessConfig(_Section):
|
|
44
|
+
hampel_k: float = Field(6.0, gt=0.0) # rolling median +/- k*MAD outlier threshold
|
|
45
|
+
hampel_window: int = Field(25, ge=5)
|
|
46
|
+
large_gap_intervals: int = Field(10, ge=2) # gap > this*interval splits segments
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScoreConfig(_Section):
|
|
50
|
+
min_alert_score: float = Field(70.0, ge=0.0, le=100.0)
|
|
51
|
+
log10_full_scale: float = Field(6.0, gt=0.0) # p=1e-6 -> score 100
|
|
52
|
+
noise_halfsat: float = Field(5.0, gt=0.0) # episodes/week at which shrink lambda=1
|
|
53
|
+
# Episodes below this score are the envelope's *expected* exceedances (a
|
|
54
|
+
# calibrated 99.5% envelope emits ~0.5% of points by design); they are
|
|
55
|
+
# tallied in diagnostics, never emitted as anomalies. Not a silent cap:
|
|
56
|
+
# the count is always reported.
|
|
57
|
+
report_floor: float = Field(25.0, ge=0.0, le=100.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RegimeConfig(_Section):
|
|
61
|
+
min_samples: int = Field(24, ge=4) # stationary samples before a shift becomes a re-anchor
|
|
62
|
+
cooldown_samples: int = Field(64, ge=0) # post-re-anchor samples with warm-up-damped scores
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WarmupConfig(_Section):
|
|
66
|
+
min_points: int = Field(64, ge=8) # minimum points before scores are undamped
|
|
67
|
+
damp: float = Field(0.5, gt=0.0, le=1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StreamConfig(_Section):
|
|
71
|
+
grace_intervals: int = Field(2, ge=0) # out-of-order tolerance, in sampling intervals
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DetectorConfig(_Section):
|
|
75
|
+
budget: str = "cheap" # cheap | balanced | thorough (gates model registry)
|
|
76
|
+
envelope: EnvelopeConfig = EnvelopeConfig()
|
|
77
|
+
episodes: EpisodeConfig = EpisodeConfig()
|
|
78
|
+
season: SeasonConfig = SeasonConfig()
|
|
79
|
+
preprocess: PreprocessConfig = PreprocessConfig()
|
|
80
|
+
score: ScoreConfig = ScoreConfig()
|
|
81
|
+
regime: RegimeConfig = RegimeConfig()
|
|
82
|
+
warmup: WarmupConfig = WarmupConfig()
|
|
83
|
+
stream: StreamConfig = StreamConfig()
|
|
84
|
+
|
|
85
|
+
def config_hash(self) -> str:
|
|
86
|
+
blob = json.dumps(self.model_dump(), sort_keys=True, separators=(",", ":"))
|
|
87
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def load(
|
|
91
|
+
cls,
|
|
92
|
+
toml_path: str | Path | None = None,
|
|
93
|
+
overrides: dict[str, Any] | None = None,
|
|
94
|
+
) -> "DetectorConfig":
|
|
95
|
+
"""Resolve defaults -> toml file -> overrides."""
|
|
96
|
+
data: dict[str, Any] = {}
|
|
97
|
+
if toml_path is not None:
|
|
98
|
+
try:
|
|
99
|
+
import tomllib # Python 3.11+
|
|
100
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
101
|
+
try:
|
|
102
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
103
|
+
except ImportError as exc:
|
|
104
|
+
raise ConfigError(
|
|
105
|
+
"reading TOML config on Python 3.10 requires `pip install tomli`"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
p = Path(toml_path)
|
|
109
|
+
if not p.exists():
|
|
110
|
+
raise ConfigError(f"config file not found: {p}")
|
|
111
|
+
data = tomllib.loads(p.read_text())
|
|
112
|
+
if overrides:
|
|
113
|
+
data = _deep_merge(data, overrides)
|
|
114
|
+
try:
|
|
115
|
+
return cls.model_validate(data)
|
|
116
|
+
except Exception as exc: # pydantic ValidationError -> our error type
|
|
117
|
+
raise ConfigError(str(exc)) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _deep_merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
out = dict(base)
|
|
122
|
+
for k, v in extra.items():
|
|
123
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
124
|
+
out[k] = _deep_merge(out[k], v)
|
|
125
|
+
else:
|
|
126
|
+
out[k] = v
|
|
127
|
+
return out
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Deterministic seed derivation.
|
|
2
|
+
|
|
3
|
+
Same inputs -> same seed across processes and platforms. All stochastic steps
|
|
4
|
+
(bootstrap nulls, backtest splits) derive their RNG from here; nothing seeds
|
|
5
|
+
from wall clock or global state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def derive_seed(*parts: str | int) -> int:
|
|
16
|
+
"""Stable 64-bit seed from string/int parts."""
|
|
17
|
+
h = hashlib.sha256("\x1f".join(str(p) for p in parts).encode("utf-8")).digest()
|
|
18
|
+
return int.from_bytes(h[:8], "big")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rng_for(*parts: str | int) -> np.random.Generator:
|
|
22
|
+
return np.random.default_rng(derive_seed(*parts))
|
tsanomaly/core/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TSAnomalyError(Exception):
|
|
5
|
+
"""Root of all TSAnomaly exceptions."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(TSAnomalyError):
|
|
9
|
+
"""Invalid or inconsistent configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IngestionError(TSAnomalyError):
|
|
13
|
+
"""Input data could not be interpreted (schema, timestamps, dimensions)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StateError(TSAnomalyError):
|
|
17
|
+
"""Metric state is missing, corrupt, or from an incompatible schema version."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MissingExtraError(TSAnomalyError):
|
|
21
|
+
"""A plugin needs an optional dependency that is not installed."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, plugin: str, extra: str) -> None:
|
|
24
|
+
super().__init__(
|
|
25
|
+
f"Plugin {plugin!r} requires the {extra!r} extra: pip install 'tsanomaly[{extra}]'"
|
|
26
|
+
)
|
|
27
|
+
self.plugin = plugin
|
|
28
|
+
self.extra = extra
|
tsanomaly/core/result.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""DetectionResult container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from tsanomaly.core.types import Anomaly, Incident
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class DetectionResult:
|
|
16
|
+
anomalies: list[Anomaly] = field(default_factory=list)
|
|
17
|
+
incidents: list[Incident] = field(default_factory=list)
|
|
18
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
config_hash: str = ""
|
|
20
|
+
# Opt-in per-point envelope series: metric key ->
|
|
21
|
+
# list of {ts, observed, expected, lo, hi, z, outside} dicts.
|
|
22
|
+
envelopes: dict[str, list[dict[str, Any]]] | None = None
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
self.anomalies.sort(key=lambda a: (-a.score, a.episode.start_ts, a.metric.key()))
|
|
26
|
+
|
|
27
|
+
def alerts(self, min_score: float = 70.0) -> list[Anomaly]:
|
|
28
|
+
return [a for a in self.anomalies if a.score >= min_score]
|
|
29
|
+
|
|
30
|
+
def summary(self) -> str:
|
|
31
|
+
n = len(self.anomalies)
|
|
32
|
+
n_alert = len(self.alerts())
|
|
33
|
+
metrics = {a.metric.key() for a in self.anomalies}
|
|
34
|
+
inc = f", {len(self.incidents)} incidents" if self.incidents else ""
|
|
35
|
+
return (
|
|
36
|
+
f"{n} anomalies ({n_alert} above alert threshold) "
|
|
37
|
+
f"across {len(metrics)} metrics{inc}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def to_frame(self) -> pd.DataFrame:
|
|
41
|
+
rows = []
|
|
42
|
+
for a in self.anomalies:
|
|
43
|
+
e = a.episode
|
|
44
|
+
rows.append(
|
|
45
|
+
{
|
|
46
|
+
"metric": e.metric.key(),
|
|
47
|
+
"start_ts": pd.Timestamp(e.start_ts, unit="ns", tz="UTC"),
|
|
48
|
+
"end_ts": (
|
|
49
|
+
pd.Timestamp(e.end_ts, unit="ns", tz="UTC") if e.end_ts else pd.NaT
|
|
50
|
+
),
|
|
51
|
+
"direction": e.direction,
|
|
52
|
+
"kind": e.kind.value,
|
|
53
|
+
"score": a.score,
|
|
54
|
+
"peak_z": e.peak_z,
|
|
55
|
+
"p_mag": a.breakdown.p_mag,
|
|
56
|
+
"p_dur": a.breakdown.p_dur,
|
|
57
|
+
"p_combined": a.breakdown.p_combined,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return pd.DataFrame(rows)
|
|
61
|
+
|
|
62
|
+
def to_json(self) -> str:
|
|
63
|
+
"""Deterministic JSON: identical data and config always serialize identically."""
|
|
64
|
+
payload = {
|
|
65
|
+
"config_hash": self.config_hash,
|
|
66
|
+
"anomalies": [a.to_dict() for a in self.anomalies],
|
|
67
|
+
"incidents": [
|
|
68
|
+
{
|
|
69
|
+
"id": i.id,
|
|
70
|
+
"narration": i.narration,
|
|
71
|
+
"started_ts": i.started_ts,
|
|
72
|
+
"ended_ts": i.ended_ts,
|
|
73
|
+
"aggregate": i.aggregate.to_dict(),
|
|
74
|
+
}
|
|
75
|
+
for i in self.incidents
|
|
76
|
+
],
|
|
77
|
+
"diagnostics": self.diagnostics,
|
|
78
|
+
}
|
|
79
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=_json_default)
|
|
80
|
+
|
|
81
|
+
def extend(self, other: Iterable[Anomaly]) -> None:
|
|
82
|
+
self.anomalies.extend(other)
|
|
83
|
+
self.__post_init__()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json_default(o: Any) -> Any:
|
|
87
|
+
if hasattr(o, "to_dict"):
|
|
88
|
+
return o.to_dict()
|
|
89
|
+
if hasattr(o, "item"): # numpy scalars
|
|
90
|
+
return o.item()
|
|
91
|
+
return str(o)
|