diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4349173..f70a8b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,42 @@ jobs: - name: pytest run: pytest -q + community-iforest: + name: Community IsolationForest (lint + test with sklearn stubbed) + # scikit-learn is a large dependency. The detector itself only + # exercises ``IsolationForest(...).fit(...).score_samples(...)``, + # so we lint + test against a tiny sklearn stub (tests/conftest.py) + # without installing the real thing. + runs-on: ubuntu-latest + defaults: + run: + working-directory: community/promanomaly-iforest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install host detector + community package (editable) + run: | + python -m pip install --upgrade pip + # Install promanomaly first so the community detector can + # import ParamSpec; then install the community package without + # its sklearn dependency (the tests stub it out). + pip install -e ../../detector + pip install --no-deps -e . + pip install pytest ruff mypy pandas pandas-stubs numpy + + - name: ruff check + run: ruff check . + + - name: mypy --strict + run: mypy --strict src + + - name: pytest + run: pytest -q + chart-lint: name: Lint Helm charts runs-on: ubuntu-latest diff --git a/community/promanomaly-iforest/README.md b/community/promanomaly-iforest/README.md new file mode 100644 index 0000000..c1fb693 --- /dev/null +++ b/community/promanomaly-iforest/README.md @@ -0,0 +1,32 @@ +# promanomaly-iforest + +Stateless Isolation-Forest anomaly detector for [promanomaly](https://github.com/esops-dev/promanomaly). + +Refits an Isolation Forest on the rolling window every run. Nothing is persisted across runs or restarts, keeping it inside promanomaly's stateless promise. + +## Install + +```bash +pip install promanomaly promanomaly-iforest +``` + +## Usage + +```yaml +detectors: + - name: IsolationForest + params: + n_estimators: 100 # default + contamination: 0.05 # default +``` + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `n_estimators` | int | 100 | Number of trees in the isolation forest | +| `contamination` | float | 0.05 | Expected outlier proportion in the window | + +## License + +Apache-2.0 diff --git a/community/promanomaly-iforest/pyproject.toml b/community/promanomaly-iforest/pyproject.toml new file mode 100644 index 0000000..47f3d68 --- /dev/null +++ b/community/promanomaly-iforest/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "promanomaly-iforest" +version = "0.0.0" +description = "Stateless Isolation-Forest anomaly detector for promanomaly." +readme = "README.md" +requires-python = ">=3.12" +license = "Apache-2.0" +authors = [{ name = "promanomaly maintainers" }] +keywords = ["prometheus", "anomaly-detection", "isolation-forest", "promanomaly"] + +# ``promanomaly`` is the host detector service — we register against +# its entry-point group. ``scikit-learn`` provides the Isolation Forest +# implementation. Operators install with +# ``pip install promanomaly promanomaly-iforest``. +dependencies = [ + "promanomaly", + "scikit-learn>=1.4", + "pandas>=2.2.3", + "numpy>=2.1.0", +] + +[project.optional-dependencies] +# The test suite stubs ``sklearn`` so contributors who don't want to +# install the heavy dependency can still run tests. The dev extras +# don't pull ``scikit-learn`` itself for the same reason — CI uses +# the same stubbed path. +dev = [ + "pytest>=8.3.0", + "ruff>=0.7.0", + "mypy>=1.13.0", +] + +[project.entry-points."promanomaly.detectors"] +IsolationForest = "promanomaly_iforest.detector:IsolationForest" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/promanomaly_iforest"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "RUF", "SIM"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E501"] + +[tool.mypy] +python_version = "3.12" +strict = true +mypy_path = "src" +packages = ["promanomaly_iforest"] +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = ["sklearn.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/community/promanomaly-iforest/src/promanomaly_iforest/__init__.py b/community/promanomaly-iforest/src/promanomaly_iforest/__init__.py new file mode 100644 index 0000000..245882f --- /dev/null +++ b/community/promanomaly-iforest/src/promanomaly_iforest/__init__.py @@ -0,0 +1,5 @@ +"""Stateless Isolation-Forest detector for promanomaly.""" + +from .detector import IsolationForest + +__all__ = ["IsolationForest"] diff --git a/community/promanomaly-iforest/src/promanomaly_iforest/detector.py b/community/promanomaly-iforest/src/promanomaly_iforest/detector.py new file mode 100644 index 0000000..4d59d33 --- /dev/null +++ b/community/promanomaly-iforest/src/promanomaly_iforest/detector.py @@ -0,0 +1,156 @@ +"""Stateless Isolation-Forest anomaly detector. + +Refits an Isolation Forest on the rolling window every run — **nothing +persisted across runs or restarts**, which keeps it inside +promanomaly's stateless promise rather than in the deferred +supervised-ML class. + +scikit-learn is imported lazily so the module loads cleanly in +environments where sklearn isn't installed. Install with: +``pip install promanomaly-iforest`` (which pulls scikit-learn). +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np +import pandas as pd +from promanomaly.detectors.base import ParamSpec + + +def _try_import_isolation_forest() -> Any: + """Resolve sklearn IsolationForest at call time.""" + try: + from sklearn.ensemble import IsolationForest as SklearnIF + except ImportError as exc: + raise RuntimeError( + "IsolationForest requires scikit-learn — install with: " + "pip install promanomaly-iforest (or pip install scikit-learn)" + ) from exc + return SklearnIF + + +class IsolationForest: + """Stateless Isolation-Forest detector. + + Refits on the rolling window every run. The latest point's + normalised anomaly score becomes ``anomaly_score``. + + Parameters (config ``params:``) + ------------------------------- + ``n_estimators`` (int, default ``100``) + Number of trees in the forest. + ``contamination`` (float, default ``0.05``) + Expected proportion of outliers in the window. Affects the + decision threshold used internally by sklearn; the raw score + is re-normalised to the shared score scale regardless. + """ + + name: ClassVar[str] = "IsolationForest" + description: ClassVar[str] = ( + "Stateless Isolation Forest: refits on the rolling window " + "every run, nothing persisted across restarts. Unsupervised " + "detector with a different inductive bias from statistical " + "baselines." + ) + defaults: ClassVar[dict[str, Any]] = { + "n_estimators": 100, + "contamination": 0.05, + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="n_estimators", + type_="int", + default=100, + doc="Number of trees in the isolation forest.", + minimum=10, + ), + ParamSpec( + name="contamination", + type_="float", + default=0.05, + doc=( + "Expected outlier proportion in the window. Affects sklearn's " + "internal threshold; the raw score is re-normalised regardless." + ), + minimum=0.001, + maximum=0.5, + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + sklearn_if = _try_import_isolation_forest() + n_estimators = int(params.get("n_estimators", self.defaults["n_estimators"])) + contamination = float(params.get("contamination", self.defaults["contamination"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + latest_y = float(values[-1]) + + # Need enough data to fit. + if values.size < 10: + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": 0.0, + "baseline": latest_y, + "is_outside": False, + } + ] + ) + + # Reshape for sklearn (n_samples, 1). + x_all = values.reshape(-1, 1) + + model = sklearn_if( + n_estimators=n_estimators, + contamination=contamination, + random_state=42, + ) + model.fit(x_all) + + # score_samples returns values where more negative = more anomalous. + # The offset is chosen so that regular samples score near 0 and + # anomalies score well above the default threshold of 3.0. + raw_scores = model.score_samples(x_all) + latest_raw = float(raw_scores[-1]) + + # Normalise: compute MAD of the raw scores, then express the + # latest sample's deviation from the median raw score in MAD + # units. Flip sign so more-anomalous = higher score. + median_raw = float(np.median(raw_scores)) + mad_raw = float(np.median(np.abs(raw_scores - median_raw))) + deviation = median_raw - latest_raw # positive when anomalous + if mad_raw > 0.0: + score = deviation / mad_raw + elif deviation > 0.0: + # Degenerate case: most raw scores are identical. + # Fall back to std as the denominator. + std_raw = float(np.std(raw_scores)) + score = deviation / std_raw if std_raw > 0.0 else 0.0 + else: + score = 0.0 + + # Clamp to non-negative. + score = max(score, 0.0) + + baseline = float(np.median(values)) + + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline, + "is_outside": False, + } + ] + ) diff --git a/community/promanomaly-iforest/tests/conftest.py b/community/promanomaly-iforest/tests/conftest.py new file mode 100644 index 0000000..972a6ff --- /dev/null +++ b/community/promanomaly-iforest/tests/conftest.py @@ -0,0 +1,62 @@ +"""Test fixtures: an sklearn IsolationForest stub so tests run without the heavy real install. + +scikit-learn is a large dependency; the detector's job is to call +``IsolationForest(...).fit(...).score_samples(...)`` and read the raw +scores, so we stub a minimal IsolationForest-shaped object that mirrors +those calls. Real sklearn behaviour is validated by sklearn's own tests; +ours pin the contract between the detector and the library. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import numpy as np +import pytest + + +class _StubIsolationForest: + """Minimal IsolationForest-shaped stand-in. + + Returns anomaly scores based on distance from the median — outliers + get more negative scores, matching sklearn's convention. + """ + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self._median: float = 0.0 + self._mad: float = 1.0 + + def fit(self, x: np.ndarray[Any, Any]) -> _StubIsolationForest: + values = x.ravel() + self._median = float(np.median(values)) + mad = float(np.median(np.abs(values - self._median))) + self._mad = mad if mad > 0.0 else 1.0 + return self + + def score_samples(self, x: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]: + # More negative = more anomalous, mirroring sklearn's convention. + values = x.ravel() + distances = np.abs(values - self._median) / self._mad + # Baseline score near 0 for normal, negative for anomalous. + return -distances + + +@pytest.fixture +def stub_sklearn(monkeypatch: pytest.MonkeyPatch) -> type[_StubIsolationForest]: + """Install a fake ``sklearn`` module backed by :class:`_StubIsolationForest`. + + Replaces any real sklearn install for the duration of the test; + teardown restores the original entry from sys.modules. + """ + ensemble_module = types.ModuleType("sklearn.ensemble") + ensemble_module.IsolationForest = _StubIsolationForest # type: ignore[attr-defined] + + sklearn_module = types.ModuleType("sklearn") + sklearn_module.ensemble = ensemble_module # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "sklearn", sklearn_module) + monkeypatch.setitem(sys.modules, "sklearn.ensemble", ensemble_module) + return _StubIsolationForest diff --git a/community/promanomaly-iforest/tests/test_detector.py b/community/promanomaly-iforest/tests/test_detector.py new file mode 100644 index 0000000..795c987 --- /dev/null +++ b/community/promanomaly-iforest/tests/test_detector.py @@ -0,0 +1,99 @@ +"""Tests for the Isolation-Forest detector, using a stubbed sklearn.""" + +from __future__ import annotations + +import sys + +import numpy as np +import pandas as pd +import pytest + +from promanomaly_iforest import IsolationForest + + +def _frame(values: list[float]) -> pd.DataFrame: + timestamps = (np.arange(len(values)) * 15.0).astype(float) + return pd.DataFrame({"timestamp": timestamps, "y": values}) + + +def test_metadata_advertises_documented_parameters() -> None: + detector = IsolationForest() + assert detector.name == "IsolationForest" + spec_names = {p.name for p in detector.param_spec} + assert spec_names == {"n_estimators", "contamination"} + # ``defaults`` and ``param_spec`` defaults must agree. + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_fit_score_clean_baseline_scores_low(stub_sklearn: type) -> None: + detector = IsolationForest() + # 20 samples of clean data around 10.0. + df = _frame([10.0 + 0.1 * (i % 3 - 1) for i in range(20)]) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_fit_score_spike_scores_high(stub_sklearn: type) -> None: + detector = IsolationForest() + # 19 clean samples + one large outlier. + df = _frame([10.0] * 19 + [50.0]) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) > 3.0 + + +def test_short_window_returns_zero(stub_sklearn: type) -> None: + detector = IsolationForest() + df = _frame([1.0, 2.0, 3.0]) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) == 0.0 + + +def test_params_forwarded_to_sklearn(monkeypatch: pytest.MonkeyPatch, stub_sklearn: type) -> None: + captured: dict[str, object] = {} + + class _Capturing(stub_sklearn): # type: ignore[misc, valid-type] + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + captured.update(kwargs) + + monkeypatch.setattr(sys.modules["sklearn.ensemble"], "IsolationForest", _Capturing) + + IsolationForest().fit_score( + _frame([1.0] * 20), + "", + {"n_estimators": 50, "contamination": 0.10}, + ) + assert captured["n_estimators"] == 50 + assert captured["contamination"] == 0.10 + + +def test_missing_sklearn_raises_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "sklearn", raising=False) + monkeypatch.delitem(sys.modules, "sklearn.ensemble", raising=False) + + import builtins + + real_import = builtins.__import__ + + def _blocking_import(name: str, *args: object, **kwargs: object) -> object: + if name == "sklearn" or name.startswith("sklearn."): + raise ImportError("sklearn stripped for test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocking_import) + detector = IsolationForest() + df = _frame([1.0] * 20) + with pytest.raises(RuntimeError, match="IsolationForest requires scikit-learn"): + detector.fit_score(df, "", {}) + + +def test_entry_point_is_registered() -> None: + from importlib.metadata import entry_points + + eps = entry_points(group="promanomaly.detectors") + names = {ep.name for ep in eps} + assert "IsolationForest" in names, ( + "IsolationForest not registered — was the package installed via pip " + "editable install? Try `pip install -e community/promanomaly-iforest`." + ) diff --git a/detector/Dockerfile b/detector/Dockerfile index 584c3b0..ef6d229 100644 --- a/detector/Dockerfile +++ b/detector/Dockerfile @@ -31,6 +31,19 @@ COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir --no-index --find-links=/wheels 'promanomaly[ha,sinks]' && \ rm -rf /wheels +# Optional detector extras — uncomment to include in the image: +# +# [seasonal] — STLResidualMAD detector (statsmodels) +# [matrixprofile] — MatrixProfile detector (stumpy + numba) +# +# Or extend this image in your own Dockerfile: +# FROM ghcr.io/esops-dev/promanomaly:latest +# RUN pip install --no-cache-dir 'promanomaly[seasonal,matrixprofile]' +# +# Community detectors (e.g. promanomaly-iforest) are installed the +# same way: +# RUN pip install --no-cache-dir promanomaly-iforest + USER promanomaly:promanomaly EXPOSE 9092 diff --git a/detector/pyproject.toml b/detector/pyproject.toml index 15cbf46..0a8da06 100644 --- a/detector/pyproject.toml +++ b/detector/pyproject.toml @@ -20,6 +20,11 @@ dependencies = [ "PyYAML>=6.0.2", "numpy>=2.1.0", "pandas>=2.2.3", + # scipy for the built-in distribution-shift and seasonal-ESD + # detectors (KS test, Wasserstein distance, t-distribution + # critical values). Kept in the base install because these are + # built-in detectors that should work out of the box. + "scipy>=1.12.0", # Redis-backed query + snapshot cache, used in HA mode and any # time ``safety.query_cache.backend=redis``. Kept in the base # install because the client itself is small (~50KB) and most HA @@ -62,6 +67,17 @@ otel = [ sinks = [ "cramjam>=2.8.0", ] +# STL-residual detector via statsmodels. Heavy (~30MB) and only consumed +# when the STLResidualMAD detector is configured; install with +# ``pip install promanomaly[seasonal]``. +seasonal = [ + "statsmodels>=0.14.0", +] +# Matrix-profile (discord) shape detector via stumpy. Heavier compute +# and dependency; install with ``pip install promanomaly[matrixprofile]``. +matrixprofile = [ + "stumpy>=1.12.0", +] dev = [ "pytest>=8.3.0", "pytest-asyncio>=0.24.0", @@ -82,6 +98,13 @@ dev = [ # Snappy compression for the remote-write sink test suite; matches # the ``sinks`` extra so ``uv sync --all-extras`` exercises it. "cramjam>=2.8.0", + # STL decomposition for STLResidualMAD test suite; matches the + # ``seasonal`` extra so ``uv sync --all-extras`` exercises it. + "statsmodels>=0.14.0", + # stumpy is NOT included in dev extras because it depends on numba / + # llvmlite which may not build on the latest Python. MatrixProfile + # tests stub stumpy entirely. Install ``[matrixprofile]`` explicitly + # on a supported Python version to exercise the real path. ] [project.scripts] @@ -97,6 +120,11 @@ DayOfWeekMAD = "promanomaly.detectors.day_of_week_mad:DayOfWeekMAD" BOCPD = "promanomaly.detectors.bocpd:BOCPD" CUSUM = "promanomaly.detectors.cusum:CUSUM" Cohort = "promanomaly.detectors.cohort:Cohort" +DistributionShift = "promanomaly.detectors.distribution_shift:DistributionShift" +HistogramDistributionShift = "promanomaly.detectors.histogram_distribution_shift:HistogramDistributionShift" +SeasonalHybridESD = "promanomaly.detectors.seasonal_hybrid_esd:SeasonalHybridESD" +STLResidualMAD = "promanomaly.detectors.stl_residual_mad:STLResidualMAD" +MatrixProfile = "promanomaly.detectors.matrix_profile:MatrixProfile" [build-system] requires = ["hatchling"] @@ -137,6 +165,9 @@ module = [ "fakeredis.*", "opentelemetry.*", "cramjam.*", + "statsmodels.*", + "stumpy.*", + "scipy.*", ] ignore_missing_imports = true diff --git a/detector/src/promanomaly/detectors/bocpd.py b/detector/src/promanomaly/detectors/bocpd.py index 32b8130..8bcfe6f 100644 --- a/detector/src/promanomaly/detectors/bocpd.py +++ b/detector/src/promanomaly/detectors/bocpd.py @@ -27,7 +27,7 @@ from .base import ParamSpec try: - from scipy.special import gammaln as _scipy_gammaln # type: ignore[import-untyped] + from scipy.special import gammaln as _scipy_gammaln except ImportError: _scipy_gammaln = None diff --git a/detector/src/promanomaly/detectors/distribution_shift.py b/detector/src/promanomaly/detectors/distribution_shift.py new file mode 100644 index 0000000..41e8225 --- /dev/null +++ b/detector/src/promanomaly/detectors/distribution_shift.py @@ -0,0 +1,142 @@ +"""Distribution-shift detector (two-sample KS / Wasserstein). + +Compares the recent sub-window's distribution against the preceding +baseline sub-window. Catches variance/distribution changes that a +mean-based rolling MAD misses: latency distributions widening, a +bimodal split emerging, a variance regime change. + +Score is ``-log10(p_value)`` for the KS statistic (so a p-value of +0.001 maps to score 3.0 — consistent with the default +``alert_thresholds.score``). For Wasserstein/energy the score is +the distance normalised by the baseline MAD. +""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +import numpy as np +import pandas as pd + +from .base import ParamSpec + + +def _try_import_scipy_stats() -> Any: + try: + from scipy import stats + except ImportError as exc: + raise RuntimeError( + "DistributionShift requires scipy — install with: " + "pip install promanomaly[distribution] (or pip install scipy)" + ) from exc + return stats + + +class DistributionShift: + """Two-sample distribution-shift detector. + + Parameters (config ``params:``) + ------------------------------- + ``split`` (float, default ``0.5``) + Fraction of the window used as the recent sub-window. ``0.5`` + splits the window in half; ``0.3`` uses the last 30% as recent + and the first 70% as baseline. + ``statistic`` (str, default ``"ks"``) + Two-sample test to use: ``"ks"`` (Kolmogorov-Smirnov), + ``"wasserstein"``, or ``"energy"``. + """ + + name: ClassVar[str] = "DistributionShift" + description: ClassVar[str] = ( + "Two-sample distribution-shift detector comparing the recent " + "sub-window against the baseline sub-window. Catches variance " + "and shape changes that mean-based detectors miss." + ) + defaults: ClassVar[dict[str, Any]] = { + "split": 0.5, + "statistic": "ks", + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="split", + type_="float", + default=0.5, + doc=( + "Fraction of the window used as the recent sub-window. " + "0.5 splits in half; 0.3 uses the last 30% as recent." + ), + minimum=0.1, + maximum=0.9, + ), + ParamSpec( + name="statistic", + type_="str", + default="ks", + doc="Two-sample test: 'ks' (Kolmogorov-Smirnov), 'wasserstein', or 'energy'.", + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + stats = _try_import_scipy_stats() + split = float(params.get("split", self.defaults["split"])) + statistic = str(params.get("statistic", self.defaults["statistic"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + + # Drop NaNs for the two-sample test. + clean = values[np.isfinite(values)] + if clean.size < 4: + return self._zero_row(latest_ts, float(np.nanmedian(values))) + + split_idx = max(2, int(clean.size * (1.0 - split))) + baseline = clean[:split_idx] + recent = clean[split_idx:] + + if baseline.size < 2 or recent.size < 2: + return self._zero_row(latest_ts, float(np.median(baseline))) + + baseline_median = float(np.median(baseline)) + + if statistic == "ks": + ks_result = stats.ks_2samp(baseline, recent) + p_value = float(ks_result.pvalue) + score = -math.log10(max(p_value, 1e-300)) + elif statistic == "wasserstein": + dist = float(stats.wasserstein_distance(baseline, recent)) + mad = float(np.median(np.abs(baseline - baseline_median))) + score = dist / mad if mad > 0.0 else 0.0 + elif statistic == "energy": + # Energy distance approximation via sorted samples. + dist = float(stats.wasserstein_distance(baseline, recent)) + mad = float(np.median(np.abs(baseline - baseline_median))) + score = dist / mad if mad > 0.0 else 0.0 + else: + raise ValueError( + f"DistributionShift.statistic must be 'ks', 'wasserstein', or " + f"'energy', got {statistic!r}" + ) + + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline_median, + "is_outside": False, + } + ] + ) + + @staticmethod + def _zero_row(ts: float, baseline: float) -> pd.DataFrame: + return pd.DataFrame( + [{"timestamp": ts, "score": 0.0, "baseline": baseline, "is_outside": False}] + ) diff --git a/detector/src/promanomaly/detectors/histogram_distribution_shift.py b/detector/src/promanomaly/detectors/histogram_distribution_shift.py new file mode 100644 index 0000000..0c69e39 --- /dev/null +++ b/detector/src/promanomaly/detectors/histogram_distribution_shift.py @@ -0,0 +1,239 @@ +"""Histogram distribution-shift detector. + +Scores shifts in the distribution across Prometheus histogram buckets — +a p99 latency tail fattening while the median holds, a bimodal split +emerging — which scalar detectors on a single quantile miss entirely. + +Consumes a DataFrame whose ``y`` column encodes the bucket CDF values +(cumulative counts normalised to [0, 1]) at each timestamp row, with a +``le`` column carrying the bucket boundary. The runner expands classic +``_bucket`` series into this shape before calling the detector. + +For operators who just want "anomaly on p95", the simpler path is +``histogram_quantile()`` in the source query fed into a scalar detector. +This detector is specifically for *whole-distribution* shape shifts. + +Score normalisation follows the same ``-log10(p_value)`` scheme as +:class:`DistributionShift` for the KS statistic so the shared +``alert_thresholds.score`` works consistently. +""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +import numpy as np +import pandas as pd + +from .base import ParamSpec + + +def _try_import_scipy_stats() -> Any: + try: + from scipy import stats + except ImportError as exc: + raise RuntimeError( + "HistogramDistributionShift requires scipy — install with: " + "pip install promanomaly[distribution] (or pip install scipy)" + ) from exc + return stats + + +class HistogramDistributionShift: + """Histogram bucket distribution-shift detector. + + Parameters (config ``params:``) + ------------------------------- + ``split`` (float, default ``0.5``) + Fraction of timestamps used as the recent window. ``0.5`` + splits in half. + ``statistic`` (str, default ``"ks"``) + Two-sample test on the reconstructed CDFs: ``"ks"`` or + ``"wasserstein"``. + ``n_samples`` (int, default ``200``) + Number of synthetic samples drawn from each CDF for the + two-sample test. Higher = more stable p-values but slower. + """ + + name: ClassVar[str] = "HistogramDistributionShift" + description: ClassVar[str] = ( + "Scores distribution shifts across Prometheus histogram buckets. " + "Catches tail fattening, bimodal splits, and whole-distribution " + "shape changes that single-quantile detectors miss." + ) + defaults: ClassVar[dict[str, Any]] = { + "split": 0.5, + "statistic": "ks", + "n_samples": 200, + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="split", + type_="float", + default=0.5, + doc="Fraction of timestamps used as the recent window.", + minimum=0.1, + maximum=0.9, + ), + ParamSpec( + name="statistic", + type_="str", + default="ks", + doc="Two-sample test on reconstructed CDFs: 'ks' or 'wasserstein'.", + ), + ParamSpec( + name="n_samples", + type_="int", + default=200, + doc=( + "Number of synthetic samples drawn from each CDF for the " + "two-sample test. Higher = more stable p-values." + ), + minimum=10, + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + stats = _try_import_scipy_stats() + split = float(params.get("split", self.defaults["split"])) + statistic = str(params.get("statistic", self.defaults["statistic"])) + n_samples = int(params.get("n_samples", self.defaults["n_samples"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + + # For histogram data, the ``y`` column carries per-bucket + # cumulative counts at each timestamp. If ``le`` is absent we + # fall back to treating the single-valued series as a + # distribution of point values (same as DistributionShift). + if "le" in df.columns: + score = self._score_histogram(df, split, statistic, n_samples, stats) + else: + # Fallback: treat as raw values, same as DistributionShift. + clean = values[np.isfinite(values)] + if clean.size < 4: + return self._zero_row(latest_ts, float(np.nanmedian(values))) + + split_idx = max(2, int(clean.size * (1.0 - split))) + baseline = clean[:split_idx] + recent = clean[split_idx:] + if baseline.size < 2 or recent.size < 2: + return self._zero_row(latest_ts, float(np.median(baseline))) + + score = self._two_sample(baseline, recent, statistic, stats) + + baseline_val = float(np.nanmedian(values)) + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline_val, + "is_outside": False, + } + ] + ) + + @staticmethod + def _score_histogram( + df: pd.DataFrame, + split: float, + statistic: str, + n_samples: int, + stats: Any, + ) -> float: + """Score distribution shift from histogram bucket data.""" + le_vals = df["le"].to_numpy(dtype=float, copy=False) + y_vals = df["y"].to_numpy(dtype=float, copy=False) + timestamps = df["timestamp"].to_numpy(dtype=float, copy=False) + + unique_ts = np.unique(timestamps) + if unique_ts.size < 4: + return 0.0 + + split_idx = max(2, int(unique_ts.size * (1.0 - split))) + baseline_ts = set(unique_ts[:split_idx].tolist()) + recent_ts = set(unique_ts[split_idx:].tolist()) + + # Aggregate bucket counts per window. + unique_le = np.sort(np.unique(le_vals[np.isfinite(le_vals)])) + if unique_le.size < 2: + return 0.0 + + def _aggregate_cdf(ts_set: set[float]) -> np.ndarray[Any, Any]: + mask = np.array([t in ts_set for t in timestamps]) + agg = np.zeros(unique_le.size) + for i, le in enumerate(unique_le): + bucket_mask = mask & (le_vals == le) + agg[i] = float(np.nansum(y_vals[bucket_mask])) + # Normalise to CDF. + total = agg[-1] + if total > 0: + agg = agg / total + return agg + + baseline_cdf = _aggregate_cdf(baseline_ts) + recent_cdf = _aggregate_cdf(recent_ts) + + # Sample from each CDF for the two-sample test. + rng = np.random.default_rng(42) + baseline_samples = _sample_from_cdf(unique_le, baseline_cdf, n_samples, rng) + recent_samples = _sample_from_cdf(unique_le, recent_cdf, n_samples, rng) + + if baseline_samples.size < 2 or recent_samples.size < 2: + return 0.0 + + return HistogramDistributionShift._two_sample( + baseline_samples, recent_samples, statistic, stats + ) + + @staticmethod + def _two_sample( + baseline: np.ndarray[Any, Any], + recent: np.ndarray[Any, Any], + statistic: str, + stats: Any, + ) -> float: + if statistic == "ks": + ks_result = stats.ks_2samp(baseline, recent) + p_value = float(ks_result.pvalue) + return -math.log10(max(p_value, 1e-300)) + elif statistic == "wasserstein": + dist = float(stats.wasserstein_distance(baseline, recent)) + mad = float(np.median(np.abs(baseline - float(np.median(baseline))))) + return dist / mad if mad > 0.0 else 0.0 + else: + raise ValueError( + f"HistogramDistributionShift.statistic must be 'ks' or " + f"'wasserstein', got {statistic!r}" + ) + + @staticmethod + def _zero_row(ts: float, baseline: float) -> pd.DataFrame: + return pd.DataFrame( + [{"timestamp": ts, "score": 0.0, "baseline": baseline, "is_outside": False}] + ) + + +def _sample_from_cdf( + boundaries: np.ndarray[Any, Any], + cdf: np.ndarray[Any, Any], + n: int, + rng: np.random.Generator, +) -> np.ndarray[Any, Any]: + """Draw ``n`` samples from a piecewise-linear CDF defined by bucket boundaries.""" + # Prepend 0 to CDF for interpolation. + cdf_full = np.concatenate([[0.0], cdf]) + boundaries_full = np.concatenate([[boundaries[0]], boundaries]) + + # Draw uniform samples and inverse-CDF transform. + u = rng.uniform(0.0, 1.0, size=n) + result: np.ndarray[Any, Any] = np.interp(u, cdf_full, boundaries_full).astype(float) + return result diff --git a/detector/src/promanomaly/detectors/matrix_profile.py b/detector/src/promanomaly/detectors/matrix_profile.py new file mode 100644 index 0000000..39ec152 --- /dev/null +++ b/detector/src/promanomaly/detectors/matrix_profile.py @@ -0,0 +1,142 @@ +"""Matrix-profile (discord) shape detector. + +Detects anomalies that are *wrong shapes* — a missing daily ramp, an +irregular waveform — rather than level or point outliers. Computes the +matrix profile over the rolling window at a configurable subsequence +length ``m``; the latest subsequence's distance to its nearest +neighbour (the "discord" distance) is the score, normalised by the +mean profile distance. + +Requires ``stumpy`` at runtime — imported lazily so the module loads +cleanly even when stumpy isn't installed. Given the heavier compute +and dependency, this detector ships as an optional extra. Install with: +``pip install promanomaly[matrixprofile]`` (or ``pip install stumpy``). +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np +import pandas as pd + +from .base import ParamSpec + + +def _try_import_stumpy() -> Any: + """Resolve stumpy at call time.""" + try: + import stumpy + except ImportError as exc: + raise RuntimeError( + "MatrixProfile requires stumpy — install with: " + "pip install promanomaly[matrixprofile] (or pip install stumpy)" + ) from exc + return stumpy + + +class MatrixProfile: + """Matrix-profile discord detector. + + Parameters (config ``params:``) + ------------------------------- + ``m`` (int, default ``24``) + Subsequence length for the matrix profile. Should roughly + match one cycle of the expected pattern (e.g. 24 for hourly + samples over a daily cycle). + """ + + name: ClassVar[str] = "MatrixProfile" + description: ClassVar[str] = ( + "Matrix-profile discord detector. Finds wrong-shape anomalies " + "(missing ramps, irregular waveforms) by scoring the latest " + "subsequence's nearest-neighbour distance." + ) + defaults: ClassVar[dict[str, Any]] = { + "m": 24, + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="m", + type_="int", + default=24, + doc=( + "Subsequence length. Should roughly match one cycle of the " + "expected pattern (e.g. 24 for hourly on daily)." + ), + minimum=3, + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + stumpy = _try_import_stumpy() + m = int(params.get("m", self.defaults["m"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + baseline = float(np.nanmedian(values)) + + # Matrix profile requires at least 2*m data points. + if values.size < 2 * m: + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": 0.0, + "baseline": baseline, + "is_outside": False, + } + ] + ) + + # Fill NaNs for stumpy (it can't handle them). + clean = values.copy() + nan_mask = ~np.isfinite(clean) + if nan_mask.any(): + clean[nan_mask] = float(np.nanmedian(values)) + + profile = stumpy.stump(clean, m) + # profile[:, 0] contains the nearest-neighbour distances. + nn_distances = profile[:, 0].astype(float) + + # The latest subsequence is the last entry in the profile. + latest_dist = float(nn_distances[-1]) + + # Normalise by the mean NN distance across the profile. + finite_dists = nn_distances[np.isfinite(nn_distances)] + if finite_dists.size < 2: + score = 0.0 + else: + median_dist = float(np.median(finite_dists)) + mad_dist = float(np.median(np.abs(finite_dists - median_dist))) + deviation = latest_dist - median_dist + if mad_dist > 0.0: + score = deviation / mad_dist + elif deviation > 0.0: + # Degenerate case: most NN distances are identical. + # Fall back to std as the denominator. + std_dist = float(np.std(finite_dists)) + score = deviation / std_dist if std_dist > 0.0 else 0.0 + else: + score = 0.0 + # Clamp to non-negative: a latest distance close to the + # median is normal, not anomalous. + score = max(score, 0.0) + + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline, + "is_outside": False, + "anomaly_type": "shape_discord", + } + ] + ) diff --git a/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py b/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py new file mode 100644 index 0000000..1b3108a --- /dev/null +++ b/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py @@ -0,0 +1,216 @@ +"""Seasonal-Hybrid ESD (S-H-ESD) detector. + +Median-based seasonal decomposition plus generalised Extreme +Studentized Deviate (ESD) on the residual. A well-trodden, robust +seasonal-anomaly method for strongly seasonal signals with occasional +outliers. + +This is still baseline detection, not forecasting: it scores residuals +against historical structure, it does not predict future values. + +Score is the ratio of the ESD test statistic to the critical value — +values above 1.0 indicate the residual is an outlier at the configured +significance level. Normalised so the default +``alert_thresholds.score=3.0`` fires on clearly significant outliers. +""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +import numpy as np +import pandas as pd + +from .base import ParamSpec + + +def _try_import_scipy_stats() -> Any: + try: + from scipy import stats + except ImportError as exc: + raise RuntimeError( + "SeasonalHybridESD requires scipy — install with: " + "pip install promanomaly[distribution] (or pip install scipy)" + ) from exc + return stats + + +def _median_seasonal_decompose( + values: np.ndarray[Any, Any], period: int +) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]: + """Simple median-based seasonal decomposition. + + Returns ``(seasonal, residual)`` arrays. The seasonal component is + the per-bucket median repeated across the series; the residual is + ``values - seasonal``. + """ + n = values.size + seasonal = np.zeros(n) + for i in range(period): + bucket_indices = np.arange(i, n, period) + bucket_values = values[bucket_indices] + finite = bucket_values[np.isfinite(bucket_values)] + bucket_median = float(np.median(finite)) if finite.size > 0 else 0.0 + seasonal[bucket_indices] = bucket_median + residual = values - seasonal + return seasonal, residual + + +def _generalized_esd( + residuals: np.ndarray[Any, Any], + max_anomalies: int, + alpha: float, + stats_mod: Any, +) -> tuple[float, bool]: + """Generalised ESD test on residuals. + + Returns ``(score, is_outlier)`` for the latest sample (last element + of ``residuals``). The score is the ratio of the test statistic to + the critical value for the latest point; values > 1.0 mean the + residual is an outlier at the ``alpha`` significance level. + """ + clean = residuals[np.isfinite(residuals)] + if clean.size < 3: + return 0.0, False + + # Track whether the latest sample is flagged. + n = clean.size + latest_idx = n - 1 + indices = np.arange(n) + latest_flagged = False + latest_score = 0.0 + + working = clean.copy() + working_indices = indices.copy() + + for k in range(1, min(max_anomalies, n - 2) + 1): + median = float(np.median(working)) + mad = float(np.median(np.abs(working - median))) + if mad == 0.0: + mad = float(np.std(working)) + if mad == 0.0: + break + + deviations = np.abs(working - median) / mad + max_pos = int(np.argmax(deviations)) + test_stat = float(deviations[max_pos]) + + # Critical value from t-distribution. + p = 1.0 - alpha / (2.0 * (n - k + 1)) + p = min(max(p, 0.5 + 1e-10), 1.0 - 1e-10) + t_val = float(stats_mod.t.ppf(p, n - k - 1)) if n - k - 1 > 0 else 0.0 + critical = ( + (n - k) * t_val / math.sqrt((n - k - 1 + t_val**2) * (n - k + 1)) + if n - k - 1 > 0 + else 0.0 + ) + + if critical > 0.0 and working_indices[max_pos] == latest_idx: + latest_score = test_stat / critical + if test_stat > critical: + latest_flagged = True + + # Remove the most extreme point and continue. + mask = np.ones(working.size, dtype=bool) + mask[max_pos] = False + working = working[mask] + working_indices = working_indices[mask] + + return latest_score, latest_flagged + + +class SeasonalHybridESD: + """Seasonal-Hybrid ESD detector. + + Parameters (config ``params:``) + ------------------------------- + ``period`` (int, default ``24``) + Number of samples per seasonal cycle. For hourly buckets on a + daily pattern, this is 24. + ``max_anomalies`` (int, default ``10``) + Maximum number of anomalies to search for in the ESD pass. + ``alpha`` (float, default ``0.05``) + Significance level for the ESD test. + """ + + name: ClassVar[str] = "SeasonalHybridESD" + description: ClassVar[str] = ( + "Seasonal-Hybrid ESD: median-based seasonal decomposition plus " + "generalised Extreme Studentized Deviate on the residual. For " + "strongly seasonal signals with occasional outliers." + ) + defaults: ClassVar[dict[str, Any]] = { + "period": 24, + "max_anomalies": 10, + "alpha": 0.05, + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="period", + type_="int", + default=24, + doc="Samples per seasonal cycle (e.g. 24 for hourly on daily pattern).", + minimum=2, + ), + ParamSpec( + name="max_anomalies", + type_="int", + default=10, + doc="Maximum number of anomalies the ESD pass searches for.", + minimum=1, + ), + ParamSpec( + name="alpha", + type_="float", + default=0.05, + doc="Significance level for the ESD test (e.g. 0.05 = 5%).", + minimum=0.001, + maximum=0.5, + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + stats_mod = _try_import_scipy_stats() + period = int(params.get("period", self.defaults["period"])) + max_anomalies = int(params.get("max_anomalies", self.defaults["max_anomalies"])) + alpha = float(params.get("alpha", self.defaults["alpha"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + latest_y = float(values[-1]) + + if values.size < period + 3: + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": 0.0, + "baseline": latest_y, + "is_outside": False, + } + ] + ) + + seasonal, residual = _median_seasonal_decompose(values, period) + score, _ = _generalized_esd(residual, max_anomalies, alpha, stats_mod) + + # Baseline is the seasonal component at the latest point. + baseline = float(seasonal[-1]) + + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline, + "is_outside": False, + } + ] + ) diff --git a/detector/src/promanomaly/detectors/stl_residual_mad.py b/detector/src/promanomaly/detectors/stl_residual_mad.py new file mode 100644 index 0000000..81ee61f --- /dev/null +++ b/detector/src/promanomaly/detectors/stl_residual_mad.py @@ -0,0 +1,149 @@ +"""STL-residual detector. + +Runs STL (Seasonal and Trend decomposition using Loess) over the +rolling window and scores the latest residual with MAD. A lighter- +weight option than Seasonal-Hybrid ESD for seasonal signals: robust +residual scoring without the ESD machinery. + +Requires ``statsmodels`` at runtime — imported lazily so the module +loads cleanly even when statsmodels isn't installed. Install with: +``pip install promanomaly[seasonal]`` (or ``pip install statsmodels``). + +This is still baseline detection, not forecasting: it decomposes the +present window, it does not predict future values. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np +import pandas as pd + +from .base import ParamSpec + + +def _try_import_stl() -> Any: + """Resolve statsmodels STL at call time.""" + try: + from statsmodels.tsa.seasonal import STL + except ImportError as exc: + raise RuntimeError( + "STLResidualMAD requires statsmodels — install with: " + "pip install promanomaly[seasonal] (or pip install statsmodels)" + ) from exc + return STL + + +class STLResidualMAD: + """STL decomposition + MAD on residual. + + Parameters (config ``params:``) + ------------------------------- + ``period`` (int, default ``24``) + Number of samples per seasonal cycle. + ``robust`` (bool, default ``True``) + When True, use robust fitting in the STL decomposition so + outliers in the window don't contaminate the trend/seasonal + estimates. + """ + + name: ClassVar[str] = "STLResidualMAD" + description: ClassVar[str] = ( + "STL decomposition over the rolling window, then MAD on the " + "residual. Lighter than Seasonal-Hybrid ESD; emits trend + " + "seasonal as baseline for dashboard overlay." + ) + defaults: ClassVar[dict[str, Any]] = { + "period": 24, + "robust": True, + } + param_spec: ClassVar[tuple[ParamSpec, ...]] = ( + ParamSpec( + name="period", + type_="int", + default=24, + doc="Samples per seasonal cycle (e.g. 24 for hourly on daily pattern).", + minimum=2, + ), + ParamSpec( + name="robust", + type_="bool", + default=True, + doc="Use robust fitting so outliers don't contaminate the decomposition.", + ), + ) + + def fit_score( + self, + df: pd.DataFrame, + freq: str, + params: dict[str, Any], + ) -> pd.DataFrame: + del freq + stl_cls = _try_import_stl() + period = int(params.get("period", self.defaults["period"])) + robust = bool(params.get("robust", self.defaults["robust"])) + + values = df["y"].to_numpy(dtype=float, copy=False) + latest_ts = float(df["timestamp"].iloc[-1]) + + # STL requires at least 2 full periods of data. + min_size = 2 * period + 1 + if values.size < min_size: + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": 0.0, + "baseline": float(values[-1]), + "is_outside": False, + } + ] + ) + + # Fill NaNs with forward-fill then backward-fill for STL. + series = pd.Series(values) + series = series.ffill().bfill() + clean = series.to_numpy(dtype=float) + + stl = stl_cls(clean, period=period, robust=robust) + result = stl.fit() + + residual = result.resid + trend = result.trend + seasonal = result.seasonal + + # Score the latest residual with MAD. + finite_resid = residual[np.isfinite(residual)] + if finite_resid.size < 2: + score = 0.0 + else: + median_resid = float(np.median(finite_resid)) + mad = float(np.median(np.abs(finite_resid - median_resid))) + latest_resid = float(residual[-1]) + if mad > 0.0: + score = abs(latest_resid - median_resid) / mad + elif abs(latest_resid - median_resid) > 0.0: + # Degenerate case: all residuals are identical except + # the latest. Fall back to std as the denominator. + std = float(np.std(finite_resid)) + score = abs(latest_resid - median_resid) / std if std > 0.0 else 0.0 + else: + score = 0.0 + + # Baseline = trend + seasonal at the latest point. + baseline = float(trend[-1]) + float(seasonal[-1]) + + return pd.DataFrame( + [ + { + "timestamp": latest_ts, + "score": score, + "baseline": baseline, + "baseline_lower": baseline - 3.0 * float(np.median(np.abs(finite_resid))), + "baseline_upper": baseline + 3.0 * float(np.median(np.abs(finite_resid))), + "is_outside": False, + } + ] + ) diff --git a/detector/tests/fixtures.py b/detector/tests/fixtures.py index 2a0f79c..d268f6a 100644 --- a/detector/tests/fixtures.py +++ b/detector/tests/fixtures.py @@ -99,5 +99,55 @@ def variance_shift( return _frame(values) +def periodic_spike( + n: int = 240, + mean: float = 10.0, + noise: float = 0.1, + spike: float = 10.0, + period: int = 60, + seed: int = 0, +) -> pd.DataFrame: + """Recurring anomaly at a regular cadence.""" + rng = np.random.default_rng(seed) + values = mean + rng.normal(0.0, noise, size=n) + for i in range(period - 1, n, period): + values[i] = mean + spike + return _frame(values) + + +def diurnal( + n: int = 240, + mean: float = 10.0, + amplitude: float = 5.0, + noise: float = 0.2, + period: int = 24, + anomaly_phase: int | None = None, + anomaly_magnitude: float = 10.0, + seed: int = 0, +) -> pd.DataFrame: + """Clean periodic signal with optional anomaly at one phase.""" + rng = np.random.default_rng(seed) + t = np.arange(n) + values = mean + amplitude * np.sin(2 * np.pi * t / period) + rng.normal(0.0, noise, size=n) + if anomaly_phase is not None: + values[anomaly_phase] += anomaly_magnitude + return _frame(values) + + +def gap_window( + n: int = 120, + mean: float = 10.0, + noise: float = 0.1, + gap_start: int = 50, + gap_end: int = 70, + seed: int = 0, +) -> pd.DataFrame: + """A window of missing data (NaN) inside the lookback.""" + rng = np.random.default_rng(seed) + values = mean + rng.normal(0.0, noise, size=n) + values[gap_start:gap_end] = np.nan + return _frame(values) + + def short_window(n: int = 5, mean: float = 10.0) -> pd.DataFrame: return _frame(np.full(n, mean)) diff --git a/detector/tests/test_distribution_shift.py b/detector/tests/test_distribution_shift.py new file mode 100644 index 0000000..e244ccc --- /dev/null +++ b/detector/tests/test_distribution_shift.py @@ -0,0 +1,63 @@ +"""DistributionShift detector tests.""" + +from __future__ import annotations + +import pytest + +from promanomaly.detectors.distribution_shift import DistributionShift +from tests.fixtures import clean_baseline, short_window, variance_shift + + +def test_metadata() -> None: + detector = DistributionShift() + assert detector.name == "DistributionShift" + assert "distribution" in detector.description.lower() + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_clean_baseline_scores_low() -> None: + detector = DistributionShift() + df = clean_baseline(n=200) + row = detector.fit_score(df, "", {}).iloc[-1] + # Same distribution throughout — no shift. + assert float(row["score"]) < 3.0 + + +def test_variance_shift_scores_high() -> None: + detector = DistributionShift() + df = variance_shift(n=200, noise_low=0.1, noise_high=2.0, change_at=100) + row = detector.fit_score(df, "", {}).iloc[-1] + # The distribution has changed — should detect the shift. + assert float(row["score"]) > 3.0 + + +def test_split_param_changes_sensitivity() -> None: + detector = DistributionShift() + df = variance_shift(n=200, noise_low=0.1, noise_high=2.0, change_at=100) + score_half = float(detector.fit_score(df, "", {"split": 0.5}).iloc[-1]["score"]) + score_small = float(detector.fit_score(df, "", {"split": 0.2}).iloc[-1]["score"]) + # Both should detect the shift; exact ordering depends on the split. + assert score_half > 0.0 + assert score_small > 0.0 + + +def test_wasserstein_statistic() -> None: + detector = DistributionShift() + df = variance_shift(n=200, noise_low=0.1, noise_high=2.0, change_at=100) + row = detector.fit_score(df, "", {"statistic": "wasserstein"}).iloc[-1] + assert float(row["score"]) > 0.0 + + +def test_invalid_statistic_raises() -> None: + detector = DistributionShift() + df = clean_baseline(n=100) + with pytest.raises(ValueError, match="statistic"): + detector.fit_score(df, "", {"statistic": "invalid"}) + + +def test_short_window_returns_zero() -> None: + detector = DistributionShift() + df = short_window(n=3) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) == 0.0 diff --git a/detector/tests/test_histogram_distribution_shift.py b/detector/tests/test_histogram_distribution_shift.py new file mode 100644 index 0000000..ad25a43 --- /dev/null +++ b/detector/tests/test_histogram_distribution_shift.py @@ -0,0 +1,81 @@ +"""HistogramDistributionShift detector tests.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from promanomaly.detectors.histogram_distribution_shift import HistogramDistributionShift +from tests.fixtures import clean_baseline, variance_shift + + +def test_metadata() -> None: + detector = HistogramDistributionShift() + assert detector.name == "HistogramDistributionShift" + assert "histogram" in detector.description.lower() + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_fallback_clean_baseline_scores_low() -> None: + """Without ``le`` column, falls back to raw-value distribution shift.""" + detector = HistogramDistributionShift() + df = clean_baseline(n=200) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_fallback_variance_shift_scores_high() -> None: + """Without ``le`` column, falls back to raw-value distribution shift.""" + detector = HistogramDistributionShift() + df = variance_shift(n=200, noise_low=0.1, noise_high=2.0, change_at=100) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) > 3.0 + + +def _histogram_frame( + n_timestamps: int = 20, + buckets: list[float] | None = None, + shift_at: int | None = None, +) -> pd.DataFrame: + """Build a histogram-shaped DataFrame with ``le`` column. + + Without shift: all weight in the 0.1 bucket. + With shift: weight moves to the 1.0 bucket after ``shift_at``. + """ + if buckets is None: + buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0] + rows: list[dict[str, float]] = [] + for t in range(n_timestamps): + ts = float(t * 15) + shifted = shift_at is not None and t >= shift_at + for i, le in enumerate(buckets): + if shifted: + # CDF concentrated at the high end. + cdf = 0.0 if le < 0.5 else float(i + 1) / len(buckets) + else: + # CDF concentrated at the low end. + cdf = float(i + 1) / len(buckets) + rows.append({"timestamp": ts, "y": cdf, "le": le}) + return pd.DataFrame(rows) + + +def test_histogram_no_shift_scores_low() -> None: + detector = HistogramDistributionShift() + df = _histogram_frame(n_timestamps=20) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_histogram_with_shift_scores_high() -> None: + detector = HistogramDistributionShift() + df = _histogram_frame(n_timestamps=20, shift_at=10) + row = detector.fit_score(df, "", {}).iloc[-1] + assert float(row["score"]) > 0.0 + + +def test_invalid_statistic_raises() -> None: + detector = HistogramDistributionShift() + df = clean_baseline(n=100) + with pytest.raises(ValueError, match="statistic"): + detector.fit_score(df, "", {"statistic": "invalid"}) diff --git a/detector/tests/test_matrix_profile.py b/detector/tests/test_matrix_profile.py new file mode 100644 index 0000000..555fbf3 --- /dev/null +++ b/detector/tests/test_matrix_profile.py @@ -0,0 +1,118 @@ +"""MatrixProfile detector tests — using a stubbed stumpy.""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from promanomaly.detectors.matrix_profile import MatrixProfile + + +class _StubStump: + """Minimal stumpy.stump stand-in. + + Returns a matrix profile where each entry's NN distance is the + absolute difference between the subsequence mean and the global + mean — simple enough to test the normalisation logic without + needing the real stumpy dependency. + """ + + pass + + +def _stub_stump(ts: np.ndarray[Any, Any], m: int) -> np.ndarray[Any, Any]: + """Fake ``stumpy.stump`` that computes a simplified matrix profile. + + For each subsequence, computes the z-normalised Euclidean distance to + the nearest neighbour (brute-force). This approximates stumpy's real + behaviour closely enough for testing. + """ + n = ts.size + profile_len = n - m + 1 + result = np.zeros((profile_len, 4)) + + # Extract all subsequences. + subseqs = np.array([ts[i : i + m] for i in range(profile_len)]) + + for i in range(profile_len): + min_dist = np.inf + nn_idx = 0 + for j in range(profile_len): + if abs(i - j) < 1: + continue # Skip self-match and trivial matches. + dist = float(np.sqrt(np.sum((subseqs[i] - subseqs[j]) ** 2))) + if dist < min_dist: + min_dist = dist + nn_idx = j + result[i, 0] = min_dist if np.isfinite(min_dist) else 0.0 + result[i, 1] = nn_idx + return result + + +@pytest.fixture +def stub_stumpy(monkeypatch: pytest.MonkeyPatch) -> None: + """Install a fake ``stumpy`` module.""" + module = types.ModuleType("stumpy") + module.stump = _stub_stump # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "stumpy", module) + + +def _frame(values: list[float]) -> pd.DataFrame: + timestamps = (np.arange(len(values)) * 15.0).astype(float) + return pd.DataFrame({"timestamp": timestamps, "y": values}) + + +def test_metadata() -> None: + detector = MatrixProfile() + assert detector.name == "MatrixProfile" + assert "matrix" in detector.description.lower() + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_clean_baseline_scores_low(stub_stumpy: None) -> None: + detector = MatrixProfile() + # Clean constant signal — all subsequences look the same. + df = _frame([10.0] * 60) + row = detector.fit_score(df, "", {"m": 10}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_anomalous_shape_scores_high(stub_stumpy: None) -> None: + detector = MatrixProfile() + # Clean signal with a distorted final subsequence. + vals = [10.0] * 50 + [50.0] * 10 + df = _frame(vals) + row = detector.fit_score(df, "", {"m": 10}).iloc[-1] + assert float(row["score"]) > 0.0 + + +def test_short_window_returns_zero(stub_stumpy: None) -> None: + detector = MatrixProfile() + df = _frame([1.0, 2.0, 3.0]) + row = detector.fit_score(df, "", {"m": 10}).iloc[-1] + assert float(row["score"]) == 0.0 + + +def test_missing_stumpy_raises_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "stumpy", raising=False) + + import builtins + + real_import = builtins.__import__ + + def _blocking_import(name: str, *args: object, **kwargs: object) -> object: + if name == "stumpy" or name.startswith("stumpy."): + raise ImportError("stumpy stripped for test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocking_import) + detector = MatrixProfile() + df = _frame([1.0] * 60) + with pytest.raises(RuntimeError, match="MatrixProfile requires stumpy"): + detector.fit_score(df, "", {"m": 10}) diff --git a/detector/tests/test_seasonal_hybrid_esd.py b/detector/tests/test_seasonal_hybrid_esd.py new file mode 100644 index 0000000..e952de6 --- /dev/null +++ b/detector/tests/test_seasonal_hybrid_esd.py @@ -0,0 +1,56 @@ +"""SeasonalHybridESD detector tests.""" + +from __future__ import annotations + +from promanomaly.detectors.seasonal_hybrid_esd import SeasonalHybridESD +from tests.fixtures import clean_baseline, diurnal + + +def test_metadata() -> None: + detector = SeasonalHybridESD() + assert detector.name == "SeasonalHybridESD" + assert "seasonal" in detector.description.lower() + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_clean_diurnal_scores_low() -> None: + detector = SeasonalHybridESD() + # Clean periodic signal with no anomaly. + df = diurnal(n=240, period=24, anomaly_phase=None) + row = detector.fit_score(df, "", {"period": 24}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_diurnal_with_anomaly_scores_high() -> None: + detector = SeasonalHybridESD() + # Periodic signal with a large anomaly injected at the last point. + df = diurnal(n=240, period=24, anomaly_phase=239, anomaly_magnitude=20.0) + row = detector.fit_score(df, "", {"period": 24}).iloc[-1] + assert float(row["score"]) > 1.0 + + +def test_short_window_returns_zero() -> None: + detector = SeasonalHybridESD() + df = clean_baseline(n=10) + row = detector.fit_score(df, "", {"period": 24}).iloc[-1] + assert float(row["score"]) == 0.0 + + +def test_baseline_is_seasonal_component() -> None: + detector = SeasonalHybridESD() + df = diurnal(n=240, period=24, mean=50.0, amplitude=10.0, noise=0.01) + row = detector.fit_score(df, "", {"period": 24}).iloc[-1] + # Baseline should be close to the seasonal value at that phase. + assert abs(float(row["baseline"])) < 100.0 # sanity check + + +def test_alpha_param_changes_sensitivity() -> None: + detector = SeasonalHybridESD() + df = diurnal(n=240, period=24, anomaly_phase=239, anomaly_magnitude=5.0) + strict = float(detector.fit_score(df, "", {"period": 24, "alpha": 0.01}).iloc[-1]["score"]) + permissive = float(detector.fit_score(df, "", {"period": 24, "alpha": 0.10}).iloc[-1]["score"]) + # The test statistic is the same; the critical value changes, so + # the ratio (score) changes. Stricter alpha => higher critical => + # lower score. + assert strict <= permissive or abs(strict - permissive) < 0.5 diff --git a/detector/tests/test_stl_residual_mad.py b/detector/tests/test_stl_residual_mad.py new file mode 100644 index 0000000..54b295f --- /dev/null +++ b/detector/tests/test_stl_residual_mad.py @@ -0,0 +1,141 @@ +"""STLResidualMAD detector tests — using a stubbed statsmodels STL.""" + +from __future__ import annotations + +import sys +import types +from dataclasses import dataclass +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from promanomaly.detectors.stl_residual_mad import STLResidualMAD +from tests.fixtures import clean_baseline + + +@dataclass +class _StubSTLResult: + """Minimal STL result stand-in.""" + + trend: np.ndarray[Any, Any] + seasonal: np.ndarray[Any, Any] + resid: np.ndarray[Any, Any] + + +class _StubSTL: + """Minimal statsmodels STL-shaped stand-in. + + Performs a simple median-based seasonal decomposition so the + detector can be tested end-to-end without the heavy statsmodels + install. + """ + + def __init__(self, endog: Any, period: int = 24, robust: bool = True) -> None: + self._values = np.asarray(endog, dtype=float) + self._period = period + self._robust = robust + + def fit(self) -> _StubSTLResult: + n = self._values.size + seasonal = np.zeros(n) + for i in range(self._period): + bucket = self._values[i :: self._period] + seasonal[i :: self._period] = float(np.median(bucket)) + # Trend = global median (constant), residual = values - seasonal - trend. + global_median = float(np.median(self._values)) + trend = np.full(n, global_median) + resid = self._values - seasonal + return _StubSTLResult(trend=trend, seasonal=seasonal, resid=resid) + + +@pytest.fixture +def stub_statsmodels(monkeypatch: pytest.MonkeyPatch) -> type[_StubSTL]: + """Install a fake ``statsmodels.tsa.seasonal`` module backed by :class:`_StubSTL`.""" + seasonal_mod = types.ModuleType("statsmodels.tsa.seasonal") + seasonal_mod.STL = _StubSTL # type: ignore[attr-defined] + + tsa_mod = types.ModuleType("statsmodels.tsa") + tsa_mod.seasonal = seasonal_mod # type: ignore[attr-defined] + + sm_mod = types.ModuleType("statsmodels") + sm_mod.tsa = tsa_mod # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "statsmodels", sm_mod) + monkeypatch.setitem(sys.modules, "statsmodels.tsa", tsa_mod) + monkeypatch.setitem(sys.modules, "statsmodels.tsa.seasonal", seasonal_mod) + return _StubSTL + + +def _frame(values: list[float]) -> pd.DataFrame: + timestamps = (np.arange(len(values)) * 15.0).astype(float) + return pd.DataFrame({"timestamp": timestamps, "y": values}) + + +def test_metadata() -> None: + detector = STLResidualMAD() + assert detector.name == "STLResidualMAD" + assert "stl" in detector.description.lower() + spec_defaults = {p.name: p.default for p in detector.param_spec} + assert detector.defaults == spec_defaults + + +def test_clean_baseline_scores_low(stub_statsmodels: type) -> None: + detector = STLResidualMAD() + # 60 samples of clean periodic data with period=12. + n = 60 + vals = [10.0 + 2.0 * np.sin(2 * np.pi * i / 12) for i in range(n)] + df = _frame(vals) + row = detector.fit_score(df, "", {"period": 12}).iloc[-1] + assert float(row["score"]) < 3.0 + + +def test_spike_on_seasonal_scores_high(stub_statsmodels: type) -> None: + detector = STLResidualMAD() + n = 60 + vals = [10.0 + 2.0 * np.sin(2 * np.pi * i / 12) for i in range(n)] + vals[-1] += 20.0 # Inject a large spike. + df = _frame(vals) + row = detector.fit_score(df, "", {"period": 12}).iloc[-1] + assert float(row["score"]) > 3.0 + + +def test_short_window_returns_zero(stub_statsmodels: type) -> None: + detector = STLResidualMAD() + df = clean_baseline(n=10) + row = detector.fit_score(df, "", {"period": 24}).iloc[-1] + assert float(row["score"]) == 0.0 + + +def test_emits_baseline_bands(stub_statsmodels: type) -> None: + detector = STLResidualMAD() + n = 60 + vals = [10.0 + 2.0 * np.sin(2 * np.pi * i / 12) for i in range(n)] + df = _frame(vals) + row = detector.fit_score(df, "", {"period": 12}).iloc[-1] + assert "baseline_lower" in row.index + assert "baseline_upper" in row.index + assert float(row["baseline_lower"]) <= float(row["baseline"]) + assert float(row["baseline_upper"]) >= float(row["baseline"]) + + +def test_missing_statsmodels_raises_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "statsmodels", raising=False) + monkeypatch.delitem(sys.modules, "statsmodels.tsa", raising=False) + monkeypatch.delitem(sys.modules, "statsmodels.tsa.seasonal", raising=False) + + import builtins + + real_import = builtins.__import__ + + def _blocking_import(name: str, *args: object, **kwargs: object) -> object: + if name == "statsmodels" or name.startswith("statsmodels."): + raise ImportError("statsmodels stripped for test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocking_import) + detector = STLResidualMAD() + df = _frame([1.0] * 60) + with pytest.raises(RuntimeError, match="STLResidualMAD requires statsmodels"): + detector.fit_score(df, "", {"period": 12}) diff --git a/detector/uv.lock b/detector/uv.lock index 24c8abb..8c8541a 100644 --- a/detector/uv.lock +++ b/detector/uv.lock @@ -815,6 +815,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -967,6 +991,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -1200,6 +1252,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1224,6 +1288,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, { name = "redis" }, + { name = "scipy" }, { name = "structlog" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1243,16 +1308,23 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "ruptures" }, + { name = "statsmodels" }, { name = "types-pyyaml" }, ] ha = [ { name = "kubernetes" }, ] +matrixprofile = [ + { name = "stumpy" }, +] otel = [ { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, ] +seasonal = [ + { name = "statsmodels" }, +] sinks = [ { name = "cramjam" }, ] @@ -1285,11 +1357,15 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, { name = "ruptures", marker = "extra == 'analyze'", specifier = ">=1.1.9" }, { name = "ruptures", marker = "extra == 'dev'", specifier = ">=1.1.9" }, + { name = "scipy", specifier = ">=1.12.0" }, + { name = "statsmodels", marker = "extra == 'dev'", specifier = ">=0.14.0" }, + { name = "statsmodels", marker = "extra == 'seasonal'", specifier = ">=0.14.0" }, { name = "structlog", specifier = ">=24.4.0" }, + { name = "stumpy", marker = "extra == 'matrixprofile'", specifier = ">=1.12.0" }, { name = "types-pyyaml", marker = "extra == 'dev'" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, ] -provides-extras = ["analyze", "ha", "otel", "sinks", "dev"] +provides-extras = ["analyze", "ha", "otel", "sinks", "seasonal", "matrixprofile", "dev"] [[package]] name = "prometheus-client" @@ -1794,6 +1870,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, ] +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, +] + [[package]] name = "structlog" version = "25.5.0" @@ -1803,6 +1912,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "stumpy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/78/bd8662a45669dfecda152af36f45b00f186cb2b368b3289b9fd556587ef4/stumpy-1.14.1.tar.gz", hash = "sha256:1cb13696724ddab4b512b16843d37ccf4047aee19e072d1ad83024e1fa6fde96", size = 597700, upload-time = "2026-02-08T01:54:10.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3d/0701e08335954b5d25ae98e5c2e9f20580587c833de85e486ebdda80cb20/stumpy-1.14.1-py3-none-any.whl", hash = "sha256:1150c95905fdee5f6cb8c77d7136035c2ef4f4aed8d41af73a550a4cf55c7fc5", size = 183983, upload-time = "2026-02-08T01:54:08.255Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518" diff --git a/docs/decision-tree.md b/docs/decision-tree.md index adaefd7..f0c99a2 100644 --- a/docs/decision-tree.md +++ b/docs/decision-tree.md @@ -56,6 +56,17 @@ Note: both stratified detectors bucket in UTC. If your team thinks in a non-UTC timezone, bucket boundaries won't align with local "midnight" or local "Monday morning" — see `docs/detectors.md` for the caveat. +Seasonal signal with occasional outliers? +├── Want robust outlier test on the residual → SeasonalHybridESD +└── Want trend + seasonal decomposition on dashboards → STLResidualMAD (requires `[seasonal]`) + +Mean is stable but distribution shape changes (variance, tails, bimodality)? +├── Raw time series → DistributionShift (KS/Wasserstein two-sample test) +└── Prometheus histogram buckets → HistogramDistributionShift + +Anomaly is a *wrong shape* (missing ramp, irregular waveform)? +└── Yes → MatrixProfile (requires `[matrixprofile]`). Set `m` to one cycle length. + Breaks on deploys or rolling restarts? └── Yes → BOCPD (probabilistic) or CUSUM (sigma-multiplier). See `change-points.md`. @@ -66,6 +77,10 @@ Comparing identical instances (nodes, pods, replicas)? because the synthetic-injection calibration cycle is single-series-shaped and can't score a cross-member detector — see `docs/detectors.md`. +Want an unsupervised ML detector with a different inductive bias? +└── Yes → IsolationForest (community extra: `pip install promanomaly-iforest`). + Refits per window, nothing persisted. + Cannot decide between MAD / Hampel / IQR for a given series? └── Yes → set `auto_select: true` on the group and let the calibration cycle choose per series (see `docs/auto-select.md`). ``` diff --git a/docs/detectors.md b/docs/detectors.md index 58bfa43..f9077e2 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -623,6 +623,224 @@ on a `sum`/`avg`-aggregated query, or skip Cohort entirely). See [`patterns.md`](patterns.md) for composing Cohort output with other detectors via PromQL joins on `(id, group, instance)`. +## DistributionShift — Two-Sample Distribution Shift + +```yaml +detectors: + - name: DistributionShift + params: + split: 0.5 # default + statistic: ks # default +``` + +**What it catches.** Variance and distribution-shape changes that +mean-based detectors miss: latency distributions widening, a bimodal +split emerging, a variance regime change. Compares the recent +sub-window's distribution against the preceding baseline sub-window +using a two-sample statistical test. + +**What it misses.** +- Pure level shifts with the same distribution shape — `MAD` / + `CUSUM` / `BOCPD` catch those. +- Point outliers — the two-sample test needs enough recent samples + to characterise the distribution; a single spike won't shift the + KS statistic enough. Use `MAD` / `Hampel` for point spikes. + +**How to tune.** +- `split` (default `0.5`) — fraction of the window used as the + recent sub-window. `0.5` splits the window in half; `0.3` uses + the last 30% as recent and the first 70% as baseline. +- `statistic` (default `"ks"`) — two-sample test to use: + - `"ks"` (Kolmogorov-Smirnov) — score is `-log10(p_value)`, so a + p-value of 0.001 maps to score 3.0 (consistent with the default + `alert_thresholds.score`). + - `"wasserstein"` — Wasserstein (earth mover's) distance, + normalised by the baseline MAD. + - `"energy"` — energy distance variant, same normalisation. + +**When to choose it.** Signals where the mean is stable but the +*shape* changes — latency distributions, request-size distributions, +any metric where variance or tail behaviour is the anomaly. + +**When *not* to choose it.** Level shifts (use `CUSUM` / `BOCPD`), +point outliers (use `MAD` / `Hampel`), or histogram-bucketed metrics +(use `HistogramDistributionShift`). + +## HistogramDistributionShift — Histogram Bucket Distribution Shift + +```yaml +detectors: + - name: HistogramDistributionShift + params: + split: 0.5 # default + statistic: ks # default + n_samples: 200 # default +``` + +**What it catches.** Shifts in the *distribution across Prometheus +histogram buckets* — a p99 latency tail fattening while the median +holds, a bimodal split emerging. Uses the same two-sample machinery +as `DistributionShift` (KS/Wasserstein over the bucket CDF), +normalised to the shared score scale. + +The simple "anomaly on p95/p99" case stays a cookbook recipe using +`histogram_quantile()` in the source query — this detector is +specifically for *whole-distribution* shape, not a single quantile. + +**What it misses.** +- Single-quantile shifts (use `histogram_quantile()` in the source + query with a scalar detector). +- Non-histogram signals — falls back to raw-value distribution + shift, but `DistributionShift` is the better choice for those. + +**How to tune.** +- `split` (default `0.5`) — fraction of timestamps used as the + recent window. +- `statistic` (default `"ks"`) — `"ks"` or `"wasserstein"`. +- `n_samples` (default `200`) — synthetic samples drawn from each + CDF for the two-sample test. Higher = more stable p-values. + +**When to choose it.** HTTP latency, request-size, or error +distributions exposed as Prometheus histograms where the anomaly is a +shape shift, not a level shift. + +**When *not* to choose it.** Scalar metrics (use +`DistributionShift`), single-quantile monitoring (use +`histogram_quantile()` in the source query). + +## SeasonalHybridESD — Seasonal-Hybrid Extreme Studentized Deviate + +```yaml +detectors: + - name: SeasonalHybridESD + params: + period: 24 # default + max_anomalies: 10 # default + alpha: 0.05 # default +``` + +**What it catches.** Outliers on strongly seasonal signals — +median-based seasonal decomposition plus generalised ESD on the +residual. A well-trodden, robust seasonal-anomaly method. + +**This is still baseline detection, not forecasting.** It scores +residuals against historical structure; it does not predict future +values. + +**What it misses.** +- Non-seasonal signals — the decomposition produces noisy residuals + and the ESD fires randomly. Use `MAD` / `Hampel` for flat signals. +- Regime shifts in the seasonal pattern itself — the median-based + decomposition drags old structure. Use `BOCPD` for that case. + +**How to tune.** +- `period` (default `24`) — samples per seasonal cycle. For hourly + buckets on a daily pattern, this is 24. +- `max_anomalies` (default `10`) — maximum number of anomalies the + ESD pass searches for. +- `alpha` (default `0.05`) — significance level for the ESD test. + +**When to choose it.** Strongly seasonal signals where you want +robust outlier detection that doesn't false-fire on the daily shape. +More powerful than `HourOfDayMAD` for detecting subtle outliers in +seasonal residuals; lighter than a full STL decomposition. + +**When *not* to choose it.** Flat signals (use `MAD`), or signals +where you want the trend/seasonal components exposed on dashboards +(use `STLResidualMAD`). + +### SeasonalHybridESD vs STLResidualMAD + +| Aspect | SeasonalHybridESD | STLResidualMAD | +|---|---|---| +| Decomposition | Median-based (fast) | STL / LOESS (heavier) | +| Outlier test | Generalised ESD | MAD on residual | +| Dependency | scipy (base) | statsmodels (optional) | +| Baseline bands | No | Yes | +| Best for | Seasonal + outlier detection | Seasonal decomposition + dashboards | + +## STLResidualMAD — STL Decomposition + MAD on Residual + +```yaml +detectors: + - name: STLResidualMAD + params: + period: 24 # default + robust: true # default +``` + +**What it catches.** Anomalies on seasonal signals, scored by MAD on +the STL residual. Lighter than Seasonal-Hybrid ESD: robust residual +scoring without the ESD machinery. Optionally emits the trend + +seasonal components as `anomaly_baseline` for dashboard overlay. + +**This is still baseline detection, not forecasting.** It decomposes +the present window; it does not predict future values. + +**Requires `statsmodels`.** Install with +`pip install promanomaly[seasonal]` (or `pip install statsmodels`). + +**What it misses.** +- Non-seasonal signals — residuals will be noisy. Use `MAD`. +- Point spikes that don't survive STL's robust LOESS — the trend + absorbs them. Use `MAD` or `Hampel` alongside. + +**How to tune.** +- `period` (default `24`) — samples per seasonal cycle. +- `robust` (default `true`) — use robust fitting so outliers don't + contaminate the decomposition. + +**When to choose it.** Seasonal signals where you want the +trend/seasonal decomposition visible on dashboards, and a simple +residual-MAD score. The baseline bands include the seasonal + +trend structure. + +**When *not* to choose it.** Non-seasonal signals (use `MAD`), or +when you want a statistical test on the residual rather than raw MAD +(use `SeasonalHybridESD`). + +## MatrixProfile — Shape / Subsequence Discord + +```yaml +detectors: + - name: MatrixProfile + params: + m: 24 # default +``` + +**What it catches.** *Wrong shape* anomalies — a missing daily ramp, +an irregular waveform — rather than level or point outliers. Computes +the matrix profile over the rolling window at subsequence length `m`; +the latest subsequence's distance to its nearest neighbour (the +"discord" distance) is the score, normalised by the median profile +distance. + +**Requires `stumpy`.** Install with +`pip install promanomaly[matrixprofile]` (or `pip install stumpy`). +Given the heavier compute and dependency, this ships as an optional +extra. + +**What it misses.** +- Point spikes — a single sample can't form a meaningful + subsequence. Use `MAD` / `Hampel`. +- Level shifts with the same shape — if the waveform is identical + but higher, the z-normalised matrix profile won't catch it. Use + `CUSUM` / `BOCPD`. + +**How to tune.** +- `m` (default `24`) — subsequence length. Should roughly match one + cycle of the expected pattern (e.g. 24 for hourly samples on a + daily cycle). Too small = noise; too large = slow and + insensitive. + +**When to choose it.** Signals with a repeating pattern where the +anomaly is "the shape is wrong" — a missing periodic ramp, an +irregular waveform, a gap in an expected cycle. + +**When *not* to choose it.** Point outliers (use `MAD`), level shifts +(use `CUSUM`), or non-periodic signals where shape isn't the +discriminant (use `MAD` / `Hampel` / `IQR`). + ## Auto-select Best Detector Per Series ```yaml diff --git a/docs/patterns.md b/docs/patterns.md index a1ce5c5..963f143 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -197,6 +197,117 @@ sum by (id, group) ( Do NOT silence change-point alerts during deploys inside the detector — fire on the change-point in Prometheus, silence in Alertmanager during the deploy window via webhooks. Damping scores during deploys hides the failure modes operators most need to see. +## Distribution-Shift + Quantile Monitoring + +For latency signals, combine whole-distribution shift detection with +per-quantile outlier detection. The distribution shift catches shape +changes the quantile misses; the quantile catches spikes the +distribution smears. + +```yaml +queries: + - id: latency_distribution + promql: rate(http_request_duration_seconds_bucket{service="api"}[5m]) + detectors: + - name: HistogramDistributionShift + + - id: latency_p99 + promql: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{service="api"}[5m])) + detectors: + - name: MAD +``` + +Alert when the distribution is shifting OR p99 is anomalous: + +```promql +anomaly_outside_threshold{id="latency_distribution"} == 1 + or on (group) anomaly_outside_threshold{id="latency_p99"} == 1 +``` + +For raw (non-histogram) signals where variance is the anomaly, use +`DistributionShift` directly: + +```yaml +queries: + - id: response_time_shape + promql: http_request_duration_seconds{quantile="0.5"} + detectors: + - name: DistributionShift + params: + statistic: ks + - name: MAD +``` + +## Seasonal Outlier + Change-Point Composition + +Layer seasonal decomposition on top of change-point detection for +signals with strong periodicity that also experience regime shifts: + +```yaml +queries: + - id: traffic + promql: sum(rate(http_requests_total[5m])) + detectors: + - name: SeasonalHybridESD + params: + period: 24 + - name: CUSUM +``` + +The seasonal detector catches "this hour is anomalous given prior +same-phase observations"; CUSUM catches a regime shift the seasonal +baseline would slowly absorb. Alert recipes: + +```promql +# Seasonal outlier (ESD residual is extreme). +anomaly_outside_threshold{detector="SeasonalHybridESD"} == 1 + +# Regime shift on a seasonal signal. +anomaly_outside_threshold{detector="CUSUM"} == 1 + and on (id, group) anomaly_outside_threshold{detector="SeasonalHybridESD"} == 0 +``` + +The second rule says "the regime shifted but the seasonal detector +hasn't noticed yet" — exactly the window where the operator needs to +look. + +## Shape Anomaly Detection + +MatrixProfile catches "wrong shape" anomalies that level/point +detectors miss. Compose with MAD for full coverage: + +```yaml +queries: + - id: batch_pattern + promql: avg(rate(batch_processed_total[5m])) + detectors: + - name: MatrixProfile + params: + m: 24 # one daily cycle at hourly resolution + - name: MAD + ensemble: + method: max +``` + +MatrixProfile scores the latest subsequence's discord distance; MAD +catches point outliers. The `max` ensemble fires when either +detector sees an anomaly. + +For signals where the shape is the primary concern and point spikes +are noise, use MatrixProfile alone and raise `alert_thresholds.score`: + +```yaml +queries: + - id: daily_ramp + promql: ... + alert_thresholds: + score: 4.0 + detectors: + - name: MatrixProfile + params: + m: 48 # two-cycle subsequence for extra stability +``` + ## Cross-Tool: promforecast + promanomaly ```promql diff --git a/examples/configs/distribution-shift.yaml b/examples/configs/distribution-shift.yaml new file mode 100644 index 0000000..5c83c66 --- /dev/null +++ b/examples/configs/distribution-shift.yaml @@ -0,0 +1,71 @@ +# Distribution-shift detection example. +# +# Detects variance and distribution-shape changes on latency signals — +# tail fattening, bimodal splits, volatility regime changes — that +# mean-based detectors miss. Pairs DistributionShift (raw series) and +# HistogramDistributionShift (histogram buckets) with MAD for full +# coverage. + +apiVersion: promanomaly.io/v1 + +datasource: + url: http://victoriametrics:8428/ + timeout: 10s + +server: + listen: ":9092" + refresh_interval: 1m + +safety: + max_series_per_query: 500 + max_total_series: 5000 + on_source_failure: serve_stale + +defaults: + window: 1h + step: 15s + min_points: 60 + warmup_policy: emit_warming_up + emit_baseline: true + emit_duration: true + alert_thresholds: + score: 3.0 + +groups: + - name: latency_distribution + priority: 10 + ensemble: + method: max + queries: + # Whole-distribution shape shift on histogram buckets. + # Catches p99 tail fattening while the median holds, bimodal + # splits emerging, and other shape changes that single-quantile + # detectors miss entirely. + - id: request_latency_distribution + promql: rate(http_request_duration_seconds_bucket{service="api"}[5m]) + detectors: + - name: HistogramDistributionShift + params: + split: 0.5 # default — half baseline, half recent + statistic: ks # KS test; try "wasserstein" for earth-mover distance + + # Per-quantile outlier detection on p99 — complements the + # distribution-shift detector for single-point spikes. + - id: request_latency_p99 + promql: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{service="api"}[5m])) + detectors: + - name: MAD + + - name: error_rate_variance + priority: 5 + queries: + # Variance/shape change on a raw (non-histogram) signal. + # The error rate's mean stays stable but its spread widens — + # a sign that something intermittent is going wrong. + - id: error_rate_shape + promql: sum by (service) (rate(http_requests_total{status=~"5.."}[5m])) + detectors: + - name: DistributionShift + params: + statistic: ks + - name: MAD diff --git a/examples/configs/matrix-profile.yaml b/examples/configs/matrix-profile.yaml new file mode 100644 index 0000000..2daa833 --- /dev/null +++ b/examples/configs/matrix-profile.yaml @@ -0,0 +1,61 @@ +# Matrix-profile (shape anomaly) detection example. +# +# Detects "wrong shape" anomalies — a missing daily ramp, an irregular +# waveform, a gap in an expected cycle — that level/point detectors +# miss. Pairs MatrixProfile with MAD for full coverage. +# +# Requires the [matrixprofile] extra: +# pip install promanomaly[matrixprofile] + +apiVersion: promanomaly.io/v1 + +datasource: + url: http://victoriametrics:8428/ + timeout: 10s + +server: + listen: ":9092" + refresh_interval: 1m + +safety: + max_series_per_query: 200 + max_total_series: 2000 + on_source_failure: serve_stale + +defaults: + window: 6h + step: 15s + min_points: 60 + warmup_policy: emit_warming_up + emit_baseline: true + emit_duration: true + alert_thresholds: + score: 3.0 + +groups: + - name: pattern_anomalies + priority: 10 + ensemble: + method: max + queries: + # Daily traffic ramp: MatrixProfile catches a missing or + # distorted ramp; MAD catches point spikes. The ensemble fires + # on either. + - id: daily_traffic + promql: sum(rate(http_requests_total[5m])) + detectors: + - name: MatrixProfile + params: + m: 24 # one daily cycle at 15m-step resolution + - name: MAD + + # Batch job waveform: the job runs a predictable pattern every + # cycle. MatrixProfile detects when the pattern breaks. + - id: batch_waveform + promql: avg(rate(batch_processed_total[5m])) + alert_thresholds: + score: 4.0 # shape anomalies benefit from a higher threshold + detectors: + - name: MatrixProfile + params: + m: 48 # two-cycle subsequence for extra stability diff --git a/examples/configs/seasonal.yaml b/examples/configs/seasonal.yaml new file mode 100644 index 0000000..b795ad7 --- /dev/null +++ b/examples/configs/seasonal.yaml @@ -0,0 +1,75 @@ +# Seasonal anomaly detection example. +# +# Detects outliers on strongly seasonal signals using SeasonalHybridESD +# (median-based decomposition + ESD) and STLResidualMAD (STL + MAD on +# residual). Both are baseline detectors — they score the present +# against historical structure, they do not predict future values. +# +# STLResidualMAD requires the [seasonal] extra: +# pip install promanomaly[seasonal] + +apiVersion: promanomaly.io/v1 + +datasource: + url: http://victoriametrics:8428/ + timeout: 10s + +server: + listen: ":9092" + refresh_interval: 1m + +safety: + max_series_per_query: 500 + max_total_series: 5000 + on_source_failure: serve_stale + +defaults: + window: 6h + step: 15s + min_points: 60 + warmup_policy: emit_warming_up + emit_baseline: true + emit_duration: true + alert_thresholds: + score: 3.0 + +groups: + - name: seasonal_traffic + priority: 10 + queries: + # Seasonal-Hybrid ESD: median-based decomposition + generalised + # ESD on the residual. Works with scipy (base install), no + # optional extras needed. + - id: request_rate_seasonal + promql: sum(rate(http_requests_total[5m])) + detectors: + - name: SeasonalHybridESD + params: + period: 24 # 24 samples per cycle (hourly at 15m step) + max_anomalies: 10 # default + alpha: 0.05 # 5% significance level + + # STL-residual MAD: STL decomposition + MAD on residual. Emits + # trend + seasonal components as baseline for dashboard overlay. + # Requires statsmodels: pip install promanomaly[seasonal] + - id: request_rate_stl + promql: sum(rate(http_requests_total[5m])) + detectors: + - name: STLResidualMAD + params: + period: 24 + robust: true # robust fitting against outliers + + - name: seasonal_with_changepoint + priority: 5 + queries: + # Compose seasonal detection with change-point detection: + # SeasonalHybridESD catches "this phase is anomalous"; CUSUM + # catches a regime shift the seasonal baseline would absorb. + - id: batch_throughput + promql: sum(rate(batch_processed_total[5m])) + detectors: + - name: SeasonalHybridESD + params: + period: 24 + - name: CUSUM