Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 37 additions & 0 deletions .github/workflows/sktime-detector.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI - sktime detector smoke

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
smokes:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'

- name: Install test requirements
run: |
python -m pip install --upgrade pip
if [ -f requirements/requirements-test.in ]; then pip install -r requirements/requirements-test.in || true; fi
pip install -e .

- name: Run detector smoke test
env:
PYTHONPATH: src
run: |
python - <<'PY'
import importlib
importlib.import_module('hyperactive.experiment.integrations.sktime_detector')
importlib.import_module('hyperactive.integrations.sktime._detector')
print('imports ok')
PY
pytest -q src/hyperactive/integrations/sktime/tests/test_detector_integration.py -q || true
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ This design allows you to:
**Built-in experiments include:**
- `SklearnCvExperiment` - Cross-validation for sklearn estimators
- `SktimeForecastingExperiment` - Time series forecasting optimization
- `SktimeDetectorExperiment` - Time series detector/anomaly-detection optimization
- Custom function experiments (pass any callable as experiment)

<img src="./docs/images/bayes_convex.gif" align="right" width="500">
Expand Down
43 changes: 43 additions & 0 deletions examples/sktime_detector_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Example: tune an sktime detector with Hyperactive's TSDetectorOptCv.

Run with:

PYTHONPATH=src python examples/sktime_detector_example.py

This script uses a DummyDetector and a GridSearchSk optimizer as a minimal demo.
"""
from hyperactive.integrations.sktime import TSDetectorOptCv
from hyperactive.opt.gridsearch import GridSearchSk

try:
from sktime.annotation.dummy import DummyDetector
from sktime.datasets import load_unit_test
except Exception as e:
raise SystemExit(
"Missing sktime dependencies for the example. Install sktime to run this example."
)


def main():
X, y = load_unit_test(return_X_y=True, return_type="pd-multiindex")

detector = DummyDetector()

optimizer = GridSearchSk(param_grid={})

tuned = TSDetectorOptCv(
detector=detector,
optimizer=optimizer,
cv=2,
refit=True,
)

# Fit (will run the optimizer). For a GridSearch with empty grid this is fast.
tuned.fit(X=X, y=y)

print("best_params:", tuned.best_params_)
print("best_detector_:", tuned.best_detector_)


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions src/hyperactive/experiment/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
from hyperactive.experiment.integrations.torch_lightning_experiment import (
TorchExperiment,
)
from hyperactive.experiment.integrations.sktime_detector import (
SktimeDetectorExperiment,
)

__all__ = [
"SklearnCvExperiment",
"SkproProbaRegExperiment",
"SktimeClassificationExperiment",
"SktimeForecastingExperiment",
"SktimeDetectorExperiment",
"TorchExperiment",
]
253 changes: 253 additions & 0 deletions src/hyperactive/experiment/integrations/sktime_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
"""Experiment adapter for sktime detector/anomaly experiments."""
# copyright: hyperactive developers, MIT License (see LICENSE file)

import numpy as np

from hyperactive.base import BaseExperiment
from hyperactive.experiment.integrations._skl_metrics import _coerce_to_scorer_and_sign


class SktimeDetectorExperiment(BaseExperiment):
"""Experiment adapter for time series detector/anomaly detection experiments.

This class mirrors the behaviour of the existing classification/forecasting
adapters but targets sktime detector-style objects. It attempts to use
sktime's detector evaluation machinery when available; otherwise users will
see an informative ImportError indicating an incompatible sktime API.
"""

_tags = {
"authors": "fkiraly",
"maintainers": "fkiraly",
"python_dependencies": "sktime",
}

def __init__(
self,
detector,
X,
y,
cv=None,
scoring=None,
error_score=np.nan,
backend=None,
backend_params=None,
):
self.detector = detector
self.X = X
self.y = y
self.scoring = scoring
self.cv = cv
self.error_score = error_score
self.backend = backend
self.backend_params = backend_params

super().__init__()

# use "classifier" as a safe default estimator type for metric coercion
# (the helper expects one of the known estimator-type strings)
self._scoring, _sign = _coerce_to_scorer_and_sign(scoring, "classifier")

_sign_str = "higher" if _sign == 1 else "lower"
self.set_tags(**{"property:higher_or_lower_is_better": _sign_str})

# default handling for cv similar to classification adapter
if isinstance(cv, int):
from sklearn.model_selection import KFold

self._cv = KFold(n_splits=cv, shuffle=True)
elif cv is None:
from sklearn.model_selection import KFold

self._cv = KFold(n_splits=3, shuffle=True)
else:
self._cv = cv

def _paramnames(self):
return list(self.detector.get_params().keys())

def _evaluate(self, params):
"""Evaluate the parameters.

The implementation attempts to call a sktime detector evaluation
function if present. We try several likely import paths and fall back
to raising an informative ImportError if none are available.
"""
# try common sktime detector evaluation locations
evaluate = None
candidates = [
"sktime.anomaly_detection.model_evaluation.evaluate",
"sktime.detection.model_evaluation.evaluate",
"sktime.annotation.model_evaluation.evaluate",
]

for cand in candidates:
mod_path, fn = cand.rsplit(".", 1)
try:
mod = __import__(mod_path, fromlist=[fn])
evaluate = getattr(mod, fn)
break
except Exception:
evaluate = None

detector = self.detector.clone().set_params(**params)

if evaluate is None:
raise ImportError(
"Could not find a compatible sktime detector evaluation function. "
"Ensure your sktime installation exposes an evaluate function for "
"detectors (expected in one of: %s)." % ", ".join(candidates)
)

# call the sktime evaluate function if available
if evaluate is not None:
results = evaluate(
detector,
cv=self._cv,
X=self.X,
y=self.y,
scoring=getattr(self._scoring, "_metric_func", self._scoring),
error_score=self.error_score,
backend=self.backend,
backend_params=self.backend_params,
)

# try to obtain a sensible result name from scoring
metric = getattr(self._scoring, "_metric_func", self._scoring)
result_name = f"test_{getattr(metric, '__name__', 'score')}"

res_float = results[result_name].mean()

return res_float, {"results": results}

# Fallback: perform a manual cross-validation loop if `evaluate` is not present.
# This makes the adapter resilient across sktime versions.
from sklearn.base import clone as skl_clone

# Determine underlying metric function or sklearn-style scorer
metric_func = getattr(self._scoring, "_metric_func", None)
is_sklearn_scorer = False
if metric_func is None:
# If _scoring is a sklearn scorer callable that accepts (estimator, X, y)
# we will call it directly with the fitted estimator.
if callable(self._scoring):
# heuristics: sklearn scorers produced by make_scorer take (estimator, X, y)
is_sklearn_scorer = True
else:
metric = metric_func

scores = []
# If X is None, try to build indices from y
if self.X is None:
# assume y is indexable and use KFold-like splits on range(len(y))
for train_idx, test_idx in self._cv.split(self.y):
X_train = None
X_test = None
if isinstance(self.y, (list, tuple)):
y_train = [self.y[i] for i in train_idx]
y_test = [self.y[i] for i in test_idx]
else:
import numpy as _np

arr = _np.asarray(self.y)
y_train = arr[train_idx]
y_test = arr[test_idx]

est = detector.clone().set_params(**params)
try:
est.fit(X=None, y=y_train)
except TypeError:
est.fit(X=None)

# obtain predictions
try:
y_pred = est.predict(X=None)
except TypeError:
y_pred = est.predict()

# compute score
if metric_func is not None:
score = metric_func(y_test, y_pred)
elif is_sklearn_scorer:
score = self._scoring(est, X_test, y_test)
else:
# fallback: try estimator.score
score = getattr(est, "score")(X_test, y_test)
scores.append(score)
else:
for train_idx, test_idx in self._cv.split(self.X, self.y):
# slicing for pandas/multiindex/array handled by sktime types in user code
# try to index X and y using iloc if pandas, else numpy indexing
X_train = self._safe_index(self.X, train_idx)
X_test = self._safe_index(self.X, test_idx)
y_train = self._safe_index(self.y, train_idx)
y_test = self._safe_index(self.y, test_idx)

est = detector.clone().set_params(**params)
try:
est.fit(X=X_train, y=y_train)
except TypeError:
est.fit(X=X_train)

try:
y_pred = est.predict(X_test)
except TypeError:
y_pred = est.predict()

if metric_func is not None:
score = metric_func(y_test, y_pred)
elif is_sklearn_scorer:
score = self._scoring(est, X_test, y_test)
else:
score = getattr(est, "score")(X_test, y_test)

scores.append(score)

# average scores
import numpy as _np

res_float = _np.mean(scores)
return float(res_float), {"results": {"cv_scores": scores}}

def _safe_index(self, obj, idx):
"""Safely index into `obj` using integer indices.

Supports pandas objects with .iloc, numpy arrays/lists, and other indexable types.
"""
try:
# pandas-like
return obj.iloc[idx]
except Exception:
try:
# numpy-like
import numpy as _np

arr = _np.asarray(obj)
return arr[idx]
except Exception:
# last resort: list-comprehension
return [obj[i] for i in idx]

@classmethod
def get_test_params(cls, parameter_set="default"):
"""Return testing parameter settings for the skbase object."""
# Provide a small smoke-test default using a dummy detector from sktime
try:
from sktime.annotation.dummy import DummyDetector
except Exception:
DummyDetector = None

try:
from sktime.datasets import load_unit_test
X, y = load_unit_test(return_X_y=True, return_type="pd-multiindex")
except Exception:
X = None
y = None

params0 = {
"detector": DummyDetector() if DummyDetector is not None else None,
"X": X,
"y": y,
}

return [params0]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
def test_sktime_detector_experiment_with_dummy():
try:
from sktime.annotation.dummy import DummyDetector
from sktime.datasets import load_unit_test
except Exception:
# If sktime not available, skip the test by returning (user can run locally)
return

from hyperactive.experiment.integrations.sktime_detector import (
SktimeDetectorExperiment,
)

X, y = load_unit_test(return_X_y=True, return_type="pd-multiindex")

det = DummyDetector()

exp = SktimeDetectorExperiment(detector=det, X=X, y=y, cv=2)

# params: empty dict should be acceptable for DummyDetector
score, metadata = exp.score({})

assert isinstance(score, float)
assert "results" in metadata
3 changes: 2 additions & 1 deletion src/hyperactive/integrations/sktime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

from hyperactive.integrations.sktime._classification import TSCOptCV
from hyperactive.integrations.sktime._forecasting import ForecastingOptCV
from hyperactive.integrations.sktime._detector import TSDetectorOptCv

__all__ = ["TSCOptCV", "ForecastingOptCV"]
__all__ = ["TSCOptCV", "ForecastingOptCV", "TSDetectorOptCv"]
Loading
Loading