diff --git a/conda/environments/all_cuda-129_arch-aarch64.yaml b/conda/environments/all_cuda-129_arch-aarch64.yaml index 8cd585feb1..3963683035 100644 --- a/conda/environments/all_cuda-129_arch-aarch64.yaml +++ b/conda/environments/all_cuda-129_arch-aarch64.yaml @@ -40,6 +40,7 @@ dependencies: - libopenblas<=0.3.30 - libraft==26.6.*,>=0.0.0a0 - librmm==26.6.*,>=0.0.0a0 +- lightgbm - matplotlib-base - nbsphinx - ninja @@ -75,6 +76,7 @@ dependencies: - scikit-learn>=1.5 - scipy>=1.14.0 - seaborn +- shap - skl2onnx - sphinx - sphinx-copybutton diff --git a/conda/environments/all_cuda-129_arch-x86_64.yaml b/conda/environments/all_cuda-129_arch-x86_64.yaml index 74cbc8a8a6..c2fe325231 100644 --- a/conda/environments/all_cuda-129_arch-x86_64.yaml +++ b/conda/environments/all_cuda-129_arch-x86_64.yaml @@ -39,6 +39,7 @@ dependencies: - libcuvs==26.6.*,>=0.0.0a0 - libraft==26.6.*,>=0.0.0a0 - librmm==26.6.*,>=0.0.0a0 +- lightgbm - matplotlib-base - nbsphinx - ninja @@ -74,6 +75,7 @@ dependencies: - scikit-learn>=1.5 - scipy>=1.14.0 - seaborn +- shap - skl2onnx - sphinx - sphinx-copybutton diff --git a/conda/environments/all_cuda-131_arch-aarch64.yaml b/conda/environments/all_cuda-131_arch-aarch64.yaml index fd9122ef1a..5f45228e3c 100644 --- a/conda/environments/all_cuda-131_arch-aarch64.yaml +++ b/conda/environments/all_cuda-131_arch-aarch64.yaml @@ -40,6 +40,7 @@ dependencies: - libopenblas<=0.3.30 - libraft==26.6.*,>=0.0.0a0 - librmm==26.6.*,>=0.0.0a0 +- lightgbm - matplotlib-base - nbsphinx - ninja @@ -75,6 +76,7 @@ dependencies: - scikit-learn>=1.5 - scipy>=1.14.0 - seaborn +- shap - skl2onnx - sphinx - sphinx-copybutton diff --git a/conda/environments/all_cuda-131_arch-x86_64.yaml b/conda/environments/all_cuda-131_arch-x86_64.yaml index ba5e3d5d08..07765ea411 100644 --- a/conda/environments/all_cuda-131_arch-x86_64.yaml +++ b/conda/environments/all_cuda-131_arch-x86_64.yaml @@ -39,6 +39,7 @@ dependencies: - libcuvs==26.6.*,>=0.0.0a0 - libraft==26.6.*,>=0.0.0a0 - librmm==26.6.*,>=0.0.0a0 +- lightgbm - matplotlib-base - nbsphinx - ninja @@ -74,6 +75,7 @@ dependencies: - scikit-learn>=1.5 - scipy>=1.14.0 - seaborn +- shap - skl2onnx - sphinx - sphinx-copybutton diff --git a/dependencies.yaml b/dependencies.yaml index 119502a8ea..6bcac3c959 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -604,6 +604,8 @@ dependencies: - statsmodels - umap-learn>=0.5.7,<0.5.12 - pynndescent + - lightgbm + - shap - output_types: conda packages: - cuvs==26.6.*,>=0.0.0a0 diff --git a/python/cuml/cuml/explainer/base.pyx b/python/cuml/cuml/explainer/base.pyx index 0b1cb41933..272b27d127 100644 --- a/python/cuml/cuml/explainer/base.pyx +++ b/python/cuml/cuml/explainer/base.pyx @@ -16,7 +16,7 @@ from cuml.explainer.common import ( output_list_shap_values, ) from cuml.internals.base import get_handle -from cuml.internals.input_utils import input_to_cupy_array, input_to_host_array +from cuml.internals.validation import check_array from libc.stdint cimport uintptr_t from libcpp cimport bool @@ -129,9 +129,10 @@ class SHAPBase(): raise ValueError("dtype must be either np.float32 or np.float64.") self.dtype = dtype - self.background, self.nrows, self.ncols, _ = \ - input_to_cupy_array(background, order=self.order, - convert_to_dtype=self.dtype) + self.background = check_array( + background, order=self.order, dtype=self.dtype, ensure_all_finite=False + ) + self.nrows, self.ncols = self.background.shape self.random_state = random_state @@ -204,9 +205,13 @@ class SHAPBase(): """ self._reset_timers() - X = input_to_cupy_array(X, - order=self.order, - convert_to_dtype=self.dtype)[0] + X = check_array( + X, + order=self.order, + dtype=self.dtype, + ensure_2d=False, + ensure_all_finite=False, + ) if X.ndim == 1: X = X.reshape((1, self.ncols)) @@ -294,7 +299,9 @@ class SHAPBase(): out = Explanation( values=shap_values, base_values=base_values, - data=input_to_host_array(X).array, + data=check_array( + X, mem_type="host", ensure_2d=False, ensure_all_finite=False + ), feature_names=self.feature_names, main_effects=main_effect_values ) diff --git a/python/cuml/cuml/explainer/common.py b/python/cuml/cuml/explainer/common.py index 7e69ece891..5960d556b5 100644 --- a/python/cuml/cuml/explainer/common.py +++ b/python/cuml/cuml/explainer/common.py @@ -1,10 +1,10 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import cupy as cp -from cuml.internals.input_utils import input_to_cupy_array +from cuml.internals.validation import check_array def get_tag_from_model_func(func, tag, default=None): @@ -39,17 +39,17 @@ def model_func_call(X, model_func, gpu_model=False): Returns the results as CuPy arrays. """ if gpu_model: - y = input_to_cupy_array(X=model_func(X), order="K").array + y = model_func(X) else: try: - y = input_to_cupy_array(model_func(cp.asnumpy(X))).array + y = model_func(cp.asnumpy(X)) except TypeError: raise TypeError( "Explainer can only explain models that can " "take GPU data or NumPy arrays as input." ) - return y + return check_array(y, ensure_2d=False, ensure_all_finite=False) def get_cai_ptr(X): diff --git a/python/cuml/cuml/explainer/kernel_shap.pyx b/python/cuml/cuml/explainer/kernel_shap.pyx index 77914db8f9..e817baaad4 100644 --- a/python/cuml/cuml/explainer/kernel_shap.pyx +++ b/python/cuml/cuml/explainer/kernel_shap.pyx @@ -13,7 +13,7 @@ import numpy as np from cuml.explainer.base import SHAPBase from cuml.explainer.common import get_cai_ptr, model_func_call from cuml.internals import get_handle -from cuml.internals.input_utils import input_to_cupy_array +from cuml.internals.validation import check_array from cuml.linear_model import Lasso, LinearRegression from libc.stdint cimport uint64_t, uintptr_t @@ -279,8 +279,9 @@ class KernelExplainer(SHAPBase): self.randind, self.dtype) - row, _, _, _ = \ - input_to_cupy_array(row, order=self.order) + row = check_array( + row, order=self.order, ensure_2d=False, ensure_all_finite=False + ) handle = get_handle() cdef handle_t* handle_ = handle.getHandle() diff --git a/python/cuml/cuml/explainer/sampling.py b/python/cuml/cuml/explainer/sampling.py index 668b1d7e37..c64859775a 100644 --- a/python/cuml/cuml/explainer/sampling.py +++ b/python/cuml/cuml/explainer/sampling.py @@ -1,15 +1,14 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # +import cudf import cupy as cp -from scipy.sparse import issparse +import pandas as pd import cuml from cuml import KMeans -from cuml.internals.input_utils import ( - determine_array_type, - get_supported_input_type, -) +from cuml.internals.array import CumlArray +from cuml.internals.validation import check_array from cuml.preprocessing import SimpleImputer @@ -43,58 +42,48 @@ def kmeans_sampling(X, k, round_values=True, detailed=False, random_state=0): labels : Cluster labels of the data points in the original dataset, shape (n_samples, 1) """ - output_dtype = get_supported_input_type(X) - _output_dtype_str = determine_array_type(X) - - if output_dtype is None: - raise TypeError( - f"Type of input {type(X)} is not supported. Supported \ - dtypes: cuDF DataFrame, cuDF Series, cupy, numba,\ - numpy, pandas DataFrame, pandas Series" - ) - - if "DataFrame" in str(output_dtype): - group_names = X.columns - X = cp.array(X.values, copy=False) - if "Series" in str(output_dtype): - group_names = X.name - X = cp.array(X.values.reshape(-1, 1), copy=False) + if isinstance(X, (cudf.DataFrame, pd.DataFrame)): + group_names = [str(c) for c in X.columns] + elif isinstance(X, (cudf.Series, pd.Series)): + group_names = [str(X.name)] + elif len(X.shape) == 2: + group_names = [str(i) for i in range(X.shape[1])] else: - # it's either numpy, cupy or numba - X = cp.array(X, copy=False) - try: - # more than one column - group_names = [str(i) for i in range(X.shape[1])] - except IndexError: - # one column - X = X.reshape(-1, 1) - group_names = ["0"] + group_names = ["0"] + + X, index = check_array( + X, ensure_2d=False, ensure_all_finite=False, return_index=True + ) + if X.ndim == 1: + X = X.reshape(-1, 1) # in case there are any missing values in data impute them imp = SimpleImputer( - missing_values=cp.nan, strategy="mean", output_type=_output_dtype_str + missing_values=cp.nan, strategy="mean", output_type="cupy" ) X = imp.fit_transform(X) kmeans = KMeans( n_clusters=k, random_state=random_state, - output_type=_output_dtype_str, + output_type="cupy", n_init="auto", ).fit(X) if round_values: for i in range(k): for j in range(X.shape[1]): - xj = ( - X[:, j].toarray().flatten() if issparse(X) else X[:, j] - ) # sparse support courtesy of @PrimozGodec + xj = X[:, j] ind = cp.argmin(cp.abs(xj - kmeans.cluster_centers_[i, j])) kmeans.cluster_centers_[i, j] = X[ind, j] summary = kmeans.cluster_centers_ labels = kmeans.labels_ if detailed: - return summary, group_names, labels + return ( + CumlArray(data=summary), + group_names, + CumlArray(labels, index=index), + ) else: - return summary + return CumlArray(data=summary) diff --git a/python/cuml/cuml/explainer/tree_shap.pyx b/python/cuml/cuml/explainer/tree_shap.pyx index f52a592f89..6efd36b31d 100644 --- a/python/cuml/cuml/explainer/tree_shap.pyx +++ b/python/cuml/cuml/explainer/tree_shap.pyx @@ -1,17 +1,18 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import re +import cudf +import cupy as cp import numpy as np +import pandas as pd import treelite import cuml -from cuml.common import input_to_cuml_array -from cuml.internals.array import CumlArray -from cuml.internals.input_utils import determine_array_type from cuml.internals.treelite import safe_treelite_call +from cuml.internals.validation import check_array from libc.stdint cimport uintptr_t @@ -59,13 +60,24 @@ cdef extern from "cuml/explainer/tree_shap.hpp" namespace "ML::Explainer" nogil: cdef FloatPointer type_erase_float_ptr(array): cdef FloatPointer ptr if array.dtype == np.float32: - ptr = < uintptr_t > array.ptr + ptr = < uintptr_t > array.data.ptr elif array.dtype == np.float64: - ptr = < uintptr_t > array.ptr + ptr = < uintptr_t > array.data.ptr else: raise ValueError("Unsupported dtype") return ptr + +def _is_host_container(x): + """Returns if the data is a host-side container type""" + if isinstance(x, (pd.DataFrame, pd.Series, np.ndarray)): + return True + elif isinstance(x, (cudf.DataFrame, cudf.Series, cp.ndarray)): + return False + # All other cases cuda memory types should have this + return not hasattr(x, "__cuda_array_interface__") + + cdef class TreeExplainer: """ Model explainer that calculates Shapley values for the predictions of @@ -145,9 +157,14 @@ cdef class TreeExplainer: def __init__(self, *, model, data=None, convert_dtype=True): if data is not None: - self.data, _, _, _ = self._prepare_input(data, convert_dtype) - else: - self.data = None + data = check_array( + data, + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="C", + ensure_all_finite=False, + ) + self.data = data # Handle various kinds of tree model objects cls = model.__class__ @@ -196,28 +213,7 @@ cdef class TreeExplainer: # Process Treelite model to extract path info self.path_info = extract_path_info(tl_handle) - def _prepare_input(self, X, convert_dtype): - try: - return input_to_cuml_array( - X, - order='C', - convert_to_dtype=(np.float32 if convert_dtype - else None), - check_dtype=[np.float32, np.float64]) - except ValueError: - # input can be a DataFrame with mixed types - # in this case coerce to 64-bit - return input_to_cuml_array( - X, - order='C', - convert_to_dtype=np.float64) - - def _determine_output_type(self, X): - X_type = determine_array_type(X) - # Coerce to CuPy / NumPy because we may need to return 3D array - return 'numpy' if X_type == 'numpy' else 'cupy' - - def shap_values(self, X, convert_dtype=True) -> CumlArray: + def shap_values(self, X, convert_dtype=True): """ Estimate the SHAP values for a set of samples. For a given row, the SHAP values plus the `expected_value` attribute sum up to the raw @@ -238,16 +234,26 @@ cdef class TreeExplainer: Returns a matrix of SHAP values of shape (# classes x # samples x # features). """ - X_m, n_rows, n_cols, dtype = self._prepare_input(X, convert_dtype) - # Storing a C-order 3D array in a CumlArray leads to cryptic error - # ValueError: len(shape) != len(strides) - # So we use 2D array here - pred_shape = (n_rows, self.num_class[0] * (n_cols + 1)) - preds = CumlArray.empty( - shape=pred_shape, dtype=dtype, order='C') + return_numpy = _is_host_container(X) + X = check_array( + X, + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="C", + ensure_all_finite=False, + ) + + n_rows, n_cols = X.shape + dtype = X.dtype + + preds = cp.empty( + (n_rows, self.num_class[0] * (n_cols + 1)), + dtype=dtype, + order="C", + ) if self.data is None: - gpu_treeshap(self.path_info, type_erase_float_ptr(X_m), + gpu_treeshap(self.path_info, type_erase_float_ptr(X), < size_t > n_rows, < size_t > n_cols, type_erase_float_ptr(preds), preds.size) else: @@ -256,7 +262,7 @@ cdef class TreeExplainer: "Expected background data to have the same dtype as X.") gpu_treeshap_interventional( self.path_info, - type_erase_float_ptr(X_m), + type_erase_float_ptr(X), < size_t > n_rows, < size_t > n_cols, type_erase_float_ptr(self.data), < size_t > self.data.shape[0], < size_t > self.data.shape[1], @@ -268,24 +274,21 @@ cdef class TreeExplainer: # 2. Transpose SHAP values in dimension (row_id, feature_id, group_id) # Note. The layout of the SHAP values from the `shap` package changed # in version 0.45.0. We use the changed layout. - preds = preds.to_output( - output_type=self._determine_output_type(X)) + if return_numpy: + preds = preds.get() if self.num_class[0] > 1: preds = preds.reshape( (n_rows, self.num_class[0], n_cols + 1)) preds = preds.transpose((0, 2, 1)) self.expected_value = preds[0, -1, :] - return preds[:, :-1, :] + preds = preds[:, :-1, :] else: assert self.num_class[0] == 1 self.expected_value = preds[0, -1] - return preds[:, :-1] + preds = preds[:, :-1] + return preds - def shap_interaction_values( - self, - X, - method='shapley-interactions', - convert_dtype=True) -> CumlArray: + def shap_interaction_values(self, X, method='shapley-interactions', convert_dtype=True): """ Estimate the SHAP interaction values for a set of samples. For a given row, the SHAP values plus the `expected_value` attribute sum @@ -310,25 +313,34 @@ cdef class TreeExplainer: Returns a matrix of SHAP values of shape (# classes x # samples x # features x # features). """ - X_m, n_rows, n_cols, dtype = self._prepare_input(X, convert_dtype) + return_numpy = _is_host_container(X) + X = check_array( + X, + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="C", + ensure_all_finite=False, + ) + + n_rows, n_cols = X.shape + dtype = X.dtype - # Storing a C-order 3D array in a CumlArray leads to cryptic error - # ValueError: len(shape) != len(strides) - # So we use 2D array here - pred_shape = (n_rows, self.num_class[0] * (n_cols + 1)**2) - preds = CumlArray.empty( - shape=pred_shape, dtype=dtype, order='C') + preds = cp.empty( + (n_rows, self.num_class[0] * (n_cols + 1)**2), + dtype=dtype, + order="C", + ) if self.data is None: if method == 'shapley-interactions': gpu_treeshap_interactions( self.path_info, - type_erase_float_ptr(X_m), + type_erase_float_ptr(X), < size_t > n_rows, < size_t > n_cols, type_erase_float_ptr(preds), preds.size) elif method == 'shapley-taylor': gpu_treeshap_taylor_interactions( - self.path_info, type_erase_float_ptr(X_m), + self.path_info, type_erase_float_ptr(X), < size_t > n_rows, < size_t > n_cols, type_erase_float_ptr(preds), preds.size) else: @@ -338,17 +350,18 @@ cdef class TreeExplainer: "Interventional algorithm not supported for interactions." " Please specify data as None in constructor.") - preds = preds.to_output( - output_type=self._determine_output_type(X)) + if return_numpy: + preds = preds.get() if self.num_class[0] > 1: preds = preds.reshape( (n_rows, self.num_class[0], n_cols + 1, n_cols + 1)) preds = preds.transpose((1, 0, 2, 3)) self.expected_value = preds[:, 0, -1, -1] - return preds[:, :, :-1, :-1] + preds = preds[:, :, :-1, :-1] else: assert self.num_class[0] == 1 preds = preds.reshape( (n_rows, n_cols + 1, n_cols + 1)) self.expected_value = preds[0, -1, -1] - return preds[:, :-1, :-1] + preds = preds[:, :-1, :-1] + return preds diff --git a/python/cuml/pyproject.toml b/python/cuml/pyproject.toml index afa6057907..0fc7d56bd8 100644 --- a/python/cuml/pyproject.toml +++ b/python/cuml/pyproject.toml @@ -111,6 +111,7 @@ test = [ "hdbscan>=0.8.39", "hypothesis>=6.0,<7", "ipython>=7.32.0", + "lightgbm", "nltk", "numpydoc<1.9", "nvidia-ml-py>=12", @@ -123,6 +124,7 @@ test = [ "pyyaml", "scikit-learn>=1.5", "seaborn", + "shap", "statsmodels", "umap-learn>=0.5.7,<0.5.12", "xgboost>=2.1.0", diff --git a/python/cuml/tests/explainer/test_gpu_treeshap.py b/python/cuml/tests/explainer/test_gpu_treeshap.py index ec292dd234..d41f022f89 100644 --- a/python/cuml/tests/explainer/test_gpu_treeshap.py +++ b/python/cuml/tests/explainer/test_gpu_treeshap.py @@ -2,8 +2,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # - import json +import warnings import cudf import cupy as cp @@ -11,6 +11,7 @@ import pandas as pd import pytest import treelite +from cudf.pandas import LOADED as cudf_pandas_active from hypothesis import HealthCheck, assume, example, given, settings from hypothesis import strategies as st from sklearn.datasets import make_classification, make_regression @@ -435,6 +436,9 @@ def test_sklearn_rf_classifier(n_classes): ) +@pytest.mark.xfail( + reason="Treelite does not yet support XGBoost models with categorical encoder" +) def test_xgb_toy_categorical(): xgb = pytest.importorskip("xgboost") @@ -460,7 +464,7 @@ def test_xgb_toy_categorical(): params, dtrain, num_boost_round=1, evals=[(dtrain, "train")] ) explainer = TreeExplainer(model=xgb_model) - out = explainer.shap_values(X).get() + out = explainer.shap_values(X) ref_out = xgb_model.predict(dtrain, pred_contribs=True) np.testing.assert_almost_equal(out, ref_out[:, :-1], decimal=5) @@ -469,6 +473,9 @@ def test_xgb_toy_categorical(): ) +@pytest.mark.xfail( + reason="Treelite does not yet support XGBoost models with categorical encoder" +) @pytest.mark.parametrize("n_classes", [2, 3]) def test_xgb_classifier_with_categorical(n_classes): xgb = pytest.importorskip("xgboost") @@ -533,6 +540,9 @@ def test_xgb_classifier_with_categorical(n_classes): ) +@pytest.mark.xfail( + reason="Treelite does not yet support XGBoost models with categorical encoder" +) def test_xgb_regressor_with_categorical(): xgb = pytest.importorskip("xgboost") @@ -610,7 +620,7 @@ def test_lightgbm_regressor_with_categorical(): ) explainer = TreeExplainer(model=lgb_model) - out = explainer.shap_values(X).get() + out = explainer.shap_values(X) ref_explainer = shap.explainers.Tree(model=lgb_model) ref_out = ref_explainer.shap_values(X) @@ -661,7 +671,7 @@ def test_lightgbm_classifier_with_categorical(n_classes): ) # Insert NaN randomly into X - X_test = X.values.copy() + X_test = X.values.astype(np.float64).copy() n_nan = int(np.floor(X.size * 0.1)) rng = np.random.default_rng(seed=0) index_nan = rng.choice(X.size, size=n_nan, replace=False) @@ -673,10 +683,13 @@ def test_lightgbm_classifier_with_categorical(n_classes): ref_explainer = shap.explainers.Tree(model=lgb_model) ref_out = ref_explainer.shap_values(X_test) ref_expected_value = ref_explainer.expected_value - np.testing.assert_almost_equal(out, ref_out, decimal=5) np.testing.assert_almost_equal( explainer.expected_value, ref_expected_value, decimal=5 ) + if not cudf_pandas_active: + # cudf.pandas causes small numerical issues in the output here, + # possibly due to something in shap or lightgbm + np.testing.assert_almost_equal(out, ref_out, decimal=5) def learn_model(draw, X, y, task, learner, n_estimators, n_targets): @@ -898,6 +911,14 @@ def check_efficiency_interactions(expected_value, pred, shap_values): ) +# TODO(26.08): Can inline this back within the `example` call +with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + example_random_forest = curfr( + max_features=1.0, random_state=0, n_streams=1, n_bins=10 + ).fit(np.ones((10, 5), dtype=np.float32), np.ones(10, dtype=np.float32)) + + # Generating input data/models can be time consuming and triggers # hypothesis HealthCheck @settings( @@ -909,9 +930,7 @@ def check_efficiency_interactions(expected_value, pred, shap_values): params=( pd.DataFrame(np.ones((10, 5), dtype=np.float32)), np.ones(10, dtype=np.float32), - curfr(max_features=1.0, random_state=0, n_streams=1, n_bins=10).fit( - np.ones((10, 5), dtype=np.float32), np.ones(10, dtype=np.float32) - ), + example_random_forest, np.ones(10, dtype=np.float32), ), interactions_method="shapley-interactions", diff --git a/python/cuml/tests/explainer/test_shap_plotting.py b/python/cuml/tests/explainer/test_shap_plotting.py index 8dcf34fd29..174c2b64cc 100644 --- a/python/cuml/tests/explainer/test_shap_plotting.py +++ b/python/cuml/tests/explainer/test_shap_plotting.py @@ -1,8 +1,8 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # - +import numpy as np import pytest from cuml import KernelExplainer as cuKE @@ -95,7 +95,9 @@ def test_summary(explainer, exact_shap_regression_dataset): api_type="raw_shap_values", ) - shap.summary_plot(shap_values, show=show_plots) + shap.summary_plot( + shap_values, show=show_plots, rng=np.random.default_rng(42) + ) def test_violin(explainer, exact_shap_regression_dataset):