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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ but cannot always guarantee backwards compatibility. Changes that may **break co

**Improved**

- Build sklearn per-estimator SHAP explainers lazily for requested horizons and likelihood components, and cache them for reuse. Native multi-output explainers remain eager. Addresses the lazy-construction part of [#3293](https://github.com/unit8co/darts/issues/3293).
- Added a warning when calling `TimeSeries.values()` without specifying a sample for a stochastic time series.
- Improvements to `TimeSeries` :
- `__pow__` now supports raising to negative powers (as series, arrays or scalars). E.g. `series ** (-2)`. [#3298](https://github.com/unit8co/darts/pull/3298) by [Gabriel Demetrios Lafis](https://github.com/galafis).
Expand Down
9 changes: 7 additions & 2 deletions darts/explainability/shap_adapters/shap_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from abc import ABC, abstractmethod
from collections.abc import Sequence
from enum import Enum
from functools import partial

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -229,7 +230,11 @@ def shap_explanations(
for t_idx, t in enumerate(self.target_components_likelihood):
if t not in target_components:
continue
explanation = self.explainer[h - 1][t_idx](foreground_arr, **kwargs)
explainer = self.explainer[h - 1][t_idx]
if isinstance(explainer, partial):
explainer = explainer()
self.explainer[h - 1][t_idx] = explainer
explanation = explainer(foreground_arr, **kwargs)
explanation = DartsShapExplanation(
values=explanation.values,
data=explanation.data,
Expand Down Expand Up @@ -366,7 +371,7 @@ def _build_explainer(
background_arr: np.ndarray,
shap_method: SHAPMethod,
**kwargs,
) -> shap.Explainer | dict[int, dict[int, shap.Explainer]]:
) -> shap.Explainer | dict[int, dict[int, shap.Explainer | partial]]:
"""
Builds the SHAP explainer based on the specified SHAP method.

Expand Down
7 changes: 5 additions & 2 deletions darts/explainability/shap_adapters/sklearn_shap_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
---------------------------------------
"""

from functools import partial

import numpy as np
import pandas as pd
import shap
Expand Down Expand Up @@ -72,7 +74,7 @@ def _build_explainer(
background_arr: np.ndarray,
shap_method: SHAPMethod,
**kwargs,
) -> shap.Explainer | dict[int, dict[int, shap.Explainer]]:
) -> shap.Explainer | dict[int, dict[int, shap.Explainer | partial]]:
likelihood = model.likelihood
has_quantile_container = model._model_container is not None

Expand Down Expand Up @@ -103,7 +105,8 @@ def _build_explainer(
else:
target_dim = j
quantile = None
explainers[i][j] = self._build_explainer_sklearn(
explainers[i][j] = partial(
self._build_explainer_sklearn,
model_sklearn=model.get_estimator(
horizon=i, target_dim=target_dim, quantile=quantile
),
Expand Down
32 changes: 32 additions & 0 deletions darts/explainability/shap_explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ def __init__(
**kwargs
Optionally, additional keyword arguments passed to ``shap_method``.

Notes
-----
For sklearn models with separate estimators per horizon or likelihood parameter,
SHAP explainers are created only when their output is first requested and are
cached for subsequent calls. Constructor options in ``kwargs`` are applied at
that time, so output-specific SHAP initialization errors are reported on the
first explanation request. Native multi-output models still use a single
eagerly constructed explainer.

Examples
--------
For ``SKLearnModel``:
Expand All @@ -143,6 +152,29 @@ def __init__(
>>> explainer.summary_plot()
>>> explainer.force_plot()

For a univariate quantile model, request a horizon and its median component
explicitly to avoid constructing explainers for unused outputs. The component
name can be obtained from the fitted likelihood rather than formatted manually:

>>> import numpy as np
>>> from darts import TimeSeries
>>> from darts.explainability import ShapExplainer
>>> from darts.models import LightGBMModel
>>> series = TimeSeries.from_values(np.sin(np.arange(96) / 4), columns=["target"])
>>> quantiles = [0.1, 0.5, 0.9]
>>> model = LightGBMModel(
... lags=3, output_chunk_length=3, likelihood="quantile",
... quantiles=quantiles, n_estimators=5, num_leaves=4,
... min_child_samples=2, n_jobs=1, verbosity=-1,
... ).fit(series)
>>> components = model.likelihood.component_names(series=series)
>>> median_component = components[quantiles.index(0.5)]
>>> explainer = ShapExplainer(model)
>>> result = explainer.explain(
... foreground_series=series[-24:], horizons=[1],
... target_components=[median_component],
... )

For ``TorchForecastingModel``:

>>> from darts.datasets import AusBeerDataset
Expand Down
175 changes: 175 additions & 0 deletions darts/tests/explainability/test_lazy_sklearn_shap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Selective SHAP construction with real tree models and additive references."""

from functools import wraps

import numpy as np
import pytest
import shap
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.tree import DecisionTreeRegressor

from darts import TimeSeries
from darts.explainability import ShapExplainer
from darts.models import SKLearnModel


@pytest.fixture
def tree_constructions(monkeypatch):
constructions = []
original = shap.TreeExplainer.__init__

@wraps(original)
def counted(self, *args, **kwargs):
constructions.append((args, kwargs))
original(self, *args, **kwargs)

monkeypatch.setattr(shap.TreeExplainer, "__init__", counted)
return constructions


def series(multivariate=False):
x = np.arange(64, dtype=float)
values = (np.sin(x / 3) + x / 15)[:, None]
columns = ["y"]
if multivariate:
values = np.column_stack([values[:, 0], np.cos(x / 5) - x / 30])
columns.append("other")
return TimeSeries.from_values(values, columns=columns)


def quantile_model(multivariate=False):
pytest.importorskip("lightgbm")
from darts.models import LightGBMModel

target = series(multivariate)
model = LightGBMModel(
lags=4,
output_chunk_length=3,
likelihood="quantile",
quantiles=[0.1, 0.5, 0.9],
n_estimators=5,
num_leaves=4,
min_child_samples=3,
n_jobs=1,
verbosity=-1,
random_state=13,
)
model.fit(target)
return model, target


def test_requested_quantile_horizons_are_lazy_and_cached(tree_constructions):
model, target = quantile_model()
explainer = ShapExplainer(model, test_stationarity=False)
assert len(tree_constructions) == 0
first = explainer.explain(horizons=[1], target_components=["y_q0.500"])
assert len(tree_constructions) == 1
again = explainer.explain(horizons=[1], target_components=["y_q0.500"])
assert len(tree_constructions) == 1
np.testing.assert_array_equal(
first.get_explanation(1, "y_q0.500").values(),
again.get_explanation(1, "y_q0.500").values(),
)
explainer.explain(horizons=[3], target_components=["y_q0.100"])
assert len(tree_constructions) == 2
explainer.explain()
assert len(tree_constructions) == 9


@pytest.mark.parametrize(
"multivariate,component,target_dim,quantile",
[(False, "y_q0.500", 0, 0.5), (True, "other_q0.900", 1, 0.9)],
)
def test_lazy_values_reconstruct_selected_estimator(
tree_constructions, multivariate, component, target_dim, quantile
):
model, target = quantile_model(multivariate)
explainer = ShapExplainer(model, test_stationarity=False)
assert len(tree_constructions) == 0
result = explainer.explain(
foreground_series=target[-20:], horizons=[2], target_components=[component]
)
assert len(tree_constructions) == 1
obj = result.get_shap_explanation_object(2, component)
estimate = model.get_estimator(horizon=1, target_dim=target_dim, quantile=quantile)
expected = estimate.predict(obj.data)
np.testing.assert_allclose(
obj.base_values + obj.values.sum(axis=1), expected, rtol=1e-6, atol=1e-6
)


def test_explain_single_uses_the_same_cache(tree_constructions):
model, target = quantile_model()
explainer = ShapExplainer(model, test_stationarity=False)
assert len(tree_constructions) == 0
explainer.explain(horizons=[1], target_components=["y_q0.500"])
assert len(tree_constructions) == 1
first = explainer.explain_single(target, target_components=["y_q0.500"])
assert len(tree_constructions) == 3
assert np.isfinite(first.get_explanation("y_q0.500").values()).all()
assert first.get_explanation("y_q0.500").n_timesteps == 3
explainer.explain_single(target, target_components=["y_q0.500"])
assert len(tree_constructions) == 3


def test_deterministic_wrapped_estimators_are_lazy(tree_constructions):
target = series()
model = SKLearnModel(
lags=4,
output_chunk_length=3,
model=GradientBoostingRegressor(n_estimators=5, random_state=13),
)
model.fit(target)
explainer = ShapExplainer(model, test_stationarity=False)
assert len(tree_constructions) == 0
result = explainer.explain(horizons=[2], target_components=["y"])
assert len(tree_constructions) == 1
obj = result.get_shap_explanation_object(2, "y")
expected = model.get_estimator(horizon=1, target_dim=0).predict(obj.data)
np.testing.assert_allclose(obj.base_values + obj.values.sum(axis=1), expected)


def test_native_multioutput_remains_eager(tree_constructions):
target = series()
model = SKLearnModel(
lags=4,
output_chunk_length=3,
model=DecisionTreeRegressor(max_depth=2, random_state=13),
)
model.fit(target)
explainer = ShapExplainer(model, test_stationarity=False)
assert len(tree_constructions) == 1
result = explainer.explain(horizons=[2], target_components=["y"])
assert len(tree_constructions) == 1
assert np.isfinite(result.get_explanation(2, "y").values()).all()


def test_constructor_options_reach_lazy_tree(tree_constructions):
model, target = quantile_model()
explainer = ShapExplainer(
model,
test_stationarity=False,
feature_perturbation="interventional",
model_output="raw",
)
assert len(tree_constructions) == 0
result = explainer.explain(horizons=[1], target_components=["y_q0.500"])
assert len(tree_constructions) == 1
assert tree_constructions[0][1]["feature_perturbation"] == "interventional"
np.testing.assert_array_equal(
tree_constructions[0][0][1], explainer.explainer.background_arr
)
obj = result.get_shap_explanation_object(1, "y_q0.500")
expected = model.get_estimator(horizon=0, target_dim=0, quantile=0.5).predict(
obj.data
)
np.testing.assert_allclose(
obj.base_values + obj.values.sum(axis=1), expected, rtol=1e-5, atol=1e-6
)


def test_invalid_method_still_fails_before_construction(tree_constructions):
model, target = quantile_model()
with pytest.raises(ValueError, match="Invalid `shap_method"):
ShapExplainer(model, shap_method="invalid", test_stationarity=False)
assert len(tree_constructions) == 0
4 changes: 4 additions & 0 deletions darts/tests/explainability/test_shap_explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ def test_gbm_creation(self, model):
# CatBoostModel supports multi-output for certain loss functions
assert isinstance(shap_explain.explainer.explainer, shap.explainers.Tree)
else:
# Separate estimators create their SHAP explainer on first use.
shap_explain.explain(
horizons=[1], target_components=[self.target_ts.components[0]]
)
assert isinstance(
shap_explain.explainer.explainer[0][0], shap.explainers.Tree
)
Expand Down