Skip to content
Open
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
107 changes: 107 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions darts/ad/_save_load.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
Save/Load Mixin for Anomaly Detection
--------------------------------------

Provides shared save() and load() methods for AD base classes to
avoid code duplication across AnomalyScorer, Detector, Aggregator,
and AnomalyModel.
"""

import datetime
import os
import pickle

from darts.logging import get_logger, raise_log

logger = get_logger(__name__)


class SaveableMixin:
"""Mixin that adds pickle-based save() and load() to anomaly detection classes.

Subclasses inherit these methods and can override the default path pattern
``{ClassName}_{YYYY-mm-dd_HH_MM_SS}.pkl``.

Example
-------
>>> from darts.ad.scorers import KMeansScorer
>>> scorer = KMeansScorer(window=10, k=8)
>>> scorer.fit(series)
>>> scorer.save("my_scorer.pkl")
>>> loaded = KMeansScorer.load("my_scorer.pkl")
"""

def save(
self,
path: str | os.PathLike | None = None,
**pkl_kwargs,
) -> None:
"""Saves the object under a given path or generates a default path.

Parameters
----------
path
Path under which to save the object at its current state. If no path
is specified, a default path ``"{ClassName}_{YYYY-mm-dd_HH_MM_SS}.pkl"``
is generated automatically.
pkl_kwargs
Keyword arguments passed to ``pickle.dump()``.
"""
if path is None:
path = (
f"{type(self).__name__}"
f"_{datetime.datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}.pkl"
)
if isinstance(path, str | os.PathLike):
with open(path, "wb") as handle:
pickle.dump(obj=self, file=handle, **pkl_kwargs)
else:
raise_log(
ValueError(
"Argument 'path' has to be a filepath (str or PathLike), "
f"but was '{path.__class__}'."
),
logger=logger,
)

@staticmethod
def load(path: str | os.PathLike) -> "SaveableMixin":
"""Loads an object from a given path.

Parameters
----------
path
Path from which to load the object.
"""
if isinstance(path, str | os.PathLike):
if not os.path.exists(path):
raise_log(
FileNotFoundError(f"The file {path} doesn't exist"),
logger=logger,
)
with open(path, "rb") as handle:
obj = pickle.load(file=handle)
else:
raise_log(
ValueError(
"Argument 'path' has to be a filepath (str or PathLike), "
f"but was '{path.__class__}'."
),
logger=logger,
)
return obj
4 changes: 3 additions & 1 deletion darts/ad/aggregators/aggregators.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import numpy as np

from darts import TimeSeries
from darts.ad._save_load import SaveableMixin
from darts.ad.utils import (
_assert_fit_called,
_check_input,
Expand All @@ -37,12 +38,13 @@
logger = get_logger(__name__)


class Aggregator(ABC):
class Aggregator(SaveableMixin, ABC):
"""Base class for Aggregators."""

def __init__(self):
self.width_trained_on: int | None = None


@abstractmethod
def __str__(self):
"""returns the name of the aggregator"""
Expand Down
4 changes: 3 additions & 1 deletion darts/ad/anomaly_model/anomaly_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing_extensions import Self

from darts import TimeSeries
from darts.ad._save_load import SaveableMixin
from darts.ad.scorers.scorers import AnomalyScorer
from darts.ad.utils import (
_assert_same_length,
Expand All @@ -27,7 +28,7 @@
logger = get_logger(__name__)


class AnomalyModel(ABC):
class AnomalyModel(SaveableMixin, ABC):
"""Base class for all anomaly models."""

def __init__(self, model, scorer):
Expand All @@ -41,6 +42,7 @@ def __init__(self, model, scorer):
)
self.model = model


def fit(
self,
series: TimeSeriesLike,
Expand Down
4 changes: 3 additions & 1 deletion darts/ad/detectors/detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import numpy as np

from darts import TimeSeries
from darts.ad._save_load import SaveableMixin
from darts.ad.utils import (
_assert_fit_called,
_check_input,
Expand All @@ -34,12 +35,13 @@
logger = get_logger(__name__)


class Detector(ABC):
class Detector(SaveableMixin, ABC):
"""Base class for all detectors"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
self.width_trained_on: int | None = None


def detect(
self,
series: TimeSeriesLike,
Expand Down
4 changes: 3 additions & 1 deletion darts/ad/scorers/scorers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import numpy as np

from darts import TimeSeries, metrics
from darts.ad._save_load import SaveableMixin
from darts.ad.utils import (
_assert_same_length,
_check_input,
Expand All @@ -38,7 +39,7 @@
logger = get_logger(__name__)


class AnomalyScorer(ABC):
class AnomalyScorer(SaveableMixin, ABC):
"""Base class for all anomaly scorers"""

def __init__(self, is_univariate: bool, window: int) -> None:
Expand All @@ -64,6 +65,7 @@ def __init__(self, is_univariate: bool, window: int) -> None:
self.window = window
self._is_univariate = is_univariate


def score_from_prediction(
self,
series: TimeSeriesLike,
Expand Down
22 changes: 22 additions & 0 deletions darts/tests/ad/test_aggregators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
FittableAggregator,
OrAggregator,
)
from darts.ad.scorers import KMeansScorer
from darts.models import MovingAverageFilter

# element shape : (model_cls, model_kwargs, expected metrics)
Expand Down Expand Up @@ -631,3 +632,24 @@ def test_ensemble_aggregator_multiple_series(self):
self.mts_anomalies1,
self.mts_anomalies2,
] == input_series_copy

def test_save_load_aggregator(self, tmp_path):
"""Test save/load for aggregators."""
from darts.ad.aggregators import AndAggregator, OrAggregator
for agg in [AndAggregator(), OrAggregator()]:
path = tmp_path / f"{type(agg).__name__}.pkl"
agg.save(str(path))
loaded = type(agg).load(str(path))
assert type(loaded) == type(agg)

def test_save_load_fittable_aggregator(self, tmp_path):
"""Test save/load preserves fitted FittableAggregator."""
from darts.ad.aggregators import EnsembleSklearnAggregator, FittableAggregator
from sklearn.ensemble import GradientBoostingClassifier
agg = EnsembleSklearnAggregator(
model=GradientBoostingClassifier(),
)
path = tmp_path / "ensemble.pkl"
agg.save(str(path))
loaded = EnsembleSklearnAggregator.load(str(path))
assert type(loaded) == type(agg)
30 changes: 30 additions & 0 deletions darts/tests/ad/test_anomaly_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1491,3 +1491,33 @@ def test_immutabilty(self):

# Check that the original series is not modified
assert series == input_series_copy

def test_save_load_filtering_anomaly_model(self, tmp_path):
"""Test save/load for FilteringAnomalyModel."""
model = FilteringAnomalyModel(
model=MovingAverageFilter(window=10),
scorer=Norm(),
)
model.fit(self.train, allow_model_training=True)
path = tmp_path / "filtering_am.pkl"
model.save(str(path))
loaded = FilteringAnomalyModel.load(str(path))
scores_orig = model.score(self.test)
scores_loaded = loaded.score(self.test)
for s_orig, s_loaded in zip(scores_orig, scores_loaded):
assert s_orig == s_loaded

def test_save_load_forecasting_anomaly_model(self, tmp_path):
"""Test save/load for ForecastingAnomalyModel."""
model = ForecastingAnomalyModel(
model=SKLearnModel(lags=5),
scorer=Norm(),
)
model.fit(self.train, allow_model_training=True)
path = tmp_path / "forecasting_am.pkl"
model.save(str(path))
loaded = ForecastingAnomalyModel.load(str(path))
scores_orig = model.score(self.test)
scores_loaded = loaded.score(self.test)
for s_orig, s_loaded in zip(scores_orig, scores_loaded):
assert s_orig == s_loaded
22 changes: 22 additions & 0 deletions darts/tests/ad/test_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -703,3 +703,25 @@ def test_iqr_detector_detect_logic(self):
detection = detector.detect(anomalous_ts)

assert detection.sum(axis=0).all_values().flatten()[0] == expected_anomalies

def test_save_load_detector(self, tmp_path):
"""Test save/load for detectors."""
for config in list_detectors:
detector_cls, kwargs = config
detector = detector_cls(**kwargs)
path = tmp_path / f"{detector_cls.__name__}.pkl"
detector.save(str(path))
loaded = detector_cls.load(str(path))
assert type(loaded) == type(detector)

def test_save_load_fitted_detector(self, tmp_path):
"""Test save/load preserves fitted QuantileDetector."""
from darts.ad.detectors.quantile_detector import QuantileDetector
detector = QuantileDetector(low_quantile=0.1, high_quantile=0.9)
detector.fit(self.train)
path = tmp_path / "quantile.pkl"
detector.save(str(path))
loaded = QuantileDetector.load(str(path))
detection_orig = detector.detect(self.test)
detection_loaded = loaded.detect(self.test)
assert detection_orig == detection_loaded
45 changes: 45 additions & 0 deletions darts/tests/ad/test_scorers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1741,3 +1741,48 @@ def test_immutability(self):

# Check that the original series is not modified
assert series == input_series_copy

def test_save_load_non_fittable(self, tmp_path):
"""Test save/load for non-fittable scorers."""
import tempfile
for scorer in list_NonFittableAnomalyScorer:
path = tmp_path / f"{type(scorer).__name__}.pkl"
scorer.save(str(path))
loaded = type(scorer).load(str(path))
assert type(loaded) == type(scorer)
assert loaded.window == scorer.window

def test_save_load_kmeans(self, tmp_path):
"""Test save/load preserves fitted KMeansScorer."""
scorer = KMeansScorer(window=5, k=4)
scorer.fit(self.train)
path = tmp_path / "kmeans.pkl"
scorer.save(str(path))
loaded = KMeansScorer.load(str(path))
assert loaded.window == scorer.window
scores_orig = scorer.score(self.test)
scores_loaded = loaded.score(self.test)
assert scores_orig == scores_loaded

def test_save_load_wasserstein(self):
"""Test save/load with default path."""
scorer = KMeansScorer(window=3, k=3)
scorer.fit(self.train)
scorer.save()
import glob, os
files = glob.glob("KMeansScorer_*.pkl")
assert len(files) == 1
loaded = KMeansScorer.load(files[0])
assert loaded.window == scorer.window
os.remove(files[0])

def test_save_load_pyod(self, tmp_path):
"""Test save/load for PyODScorer."""
from pyod.models.knn import KNN
from darts.ad.scorers import PyODScorer
scorer = PyODScorer(window=5, model=KNN())
scorer.fit(self.train)
path = tmp_path / "pyod.pkl"
scorer.save(str(path))
loaded = PyODScorer.load(str(path))
assert loaded.window == scorer.window