From 6e71788307b78b291ef22a793e52241bc8a2e1a9 Mon Sep 17 00:00:00 2001 From: AHMETHAKANBEZIR1 Date: Sun, 25 Oct 2026 10:03:20 +0300 Subject: [PATCH 1/2] Build per-estimator SHAP explainers lazily and cache requested outputs --- CHANGELOG.md | 2 + .../shap_adapters/shap_adapter.py | 9 +- .../shap_adapters/sklearn_shap_adapter.py | 7 +- darts/explainability/shap_explainer.py | 9 + .../explainability/test_lazy_sklearn_shap.py | 175 ++++++++++++++++++ .../explainability/test_shap_explainer.py | 4 + 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 darts/tests/explainability/test_lazy_sklearn_shap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2e1d2932..6ebb5c47d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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). + **Fixed** - Fixed compatibility with pandas 3.1: fitting and predicting models on series with a daily frequency failed with a `TypeError`, and `MIDAS` accepted a series whose frequency was already the target low frequency. [#3289](https://github.com/unit8co/darts/pull/3289) by [Nicolò Angileri](https://github.com/nicoloangileri). diff --git a/darts/explainability/shap_adapters/shap_adapter.py b/darts/explainability/shap_adapters/shap_adapter.py index c9be01029c..3d14c337f7 100644 --- a/darts/explainability/shap_adapters/shap_adapter.py +++ b/darts/explainability/shap_adapters/shap_adapter.py @@ -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 @@ -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, @@ -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. diff --git a/darts/explainability/shap_adapters/sklearn_shap_adapter.py b/darts/explainability/shap_adapters/sklearn_shap_adapter.py index 3c853efd6e..cf88db77de 100644 --- a/darts/explainability/shap_adapters/sklearn_shap_adapter.py +++ b/darts/explainability/shap_adapters/sklearn_shap_adapter.py @@ -3,6 +3,8 @@ --------------------------------------- """ +from functools import partial + import numpy as np import pandas as pd import shap @@ -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 @@ -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 ), diff --git a/darts/explainability/shap_explainer.py b/darts/explainability/shap_explainer.py index 29149c3287..6a2a33ef62 100644 --- a/darts/explainability/shap_explainer.py +++ b/darts/explainability/shap_explainer.py @@ -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``: diff --git a/darts/tests/explainability/test_lazy_sklearn_shap.py b/darts/tests/explainability/test_lazy_sklearn_shap.py new file mode 100644 index 0000000000..d676a86dd2 --- /dev/null +++ b/darts/tests/explainability/test_lazy_sklearn_shap.py @@ -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 diff --git a/darts/tests/explainability/test_shap_explainer.py b/darts/tests/explainability/test_shap_explainer.py index 869a1fe68c..e2dd8ee833 100644 --- a/darts/tests/explainability/test_shap_explainer.py +++ b/darts/tests/explainability/test_shap_explainer.py @@ -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 ) From a847845c029be880be1534d7f88899df736144ab Mon Sep 17 00:00:00 2001 From: AHMETHAKANBEZIR1 Date: Sun, 25 Oct 2026 11:40:59 +0300 Subject: [PATCH 2/2] Show selecting a median quantile with lazy SHAP explainers --- darts/explainability/shap_explainer.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/darts/explainability/shap_explainer.py b/darts/explainability/shap_explainer.py index 6a2a33ef62..6ff01c6601 100644 --- a/darts/explainability/shap_explainer.py +++ b/darts/explainability/shap_explainer.py @@ -152,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