Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions community/promanomaly-iforest/README.md
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions community/promanomaly-iforest/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Stateless Isolation-Forest detector for promanomaly."""

from .detector import IsolationForest

__all__ = ["IsolationForest"]
156 changes: 156 additions & 0 deletions community/promanomaly-iforest/src/promanomaly_iforest/detector.py
Original file line number Diff line number Diff line change
@@ -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,
}
]
)
62 changes: 62 additions & 0 deletions community/promanomaly-iforest/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading