3018 lines
115 KiB
Python
3018 lines
115 KiB
Python
"""Test tree functions."""
|
|
|
|
import itertools
|
|
import math
|
|
import pickle
|
|
import sys
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
import sklearn
|
|
import sklearn.pipeline
|
|
from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor, RandomForestClassifier
|
|
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
|
|
from sklearn.utils import check_array
|
|
|
|
import shap
|
|
from shap.explainers._explainer import Explanation
|
|
from shap.explainers._tree import SingleTree
|
|
from shap.utils._exceptions import InvalidModelError
|
|
|
|
|
|
def test_unsupported_model_raises_error():
|
|
"""Unsupported model inputs to TreeExplainer should raise an Exception."""
|
|
|
|
class CustomEstimator: ...
|
|
|
|
emsg = "Model type not yet supported by TreeExplainer:"
|
|
with pytest.raises(InvalidModelError, match=emsg):
|
|
_ = shap.TreeExplainer(CustomEstimator())
|
|
|
|
|
|
def test_large_background_dataset_warning():
|
|
"""A warning should be emitted when >1000 background samples are passed
|
|
with feature_perturbation='interventional'. Regression test for GH#4385."""
|
|
X, y = shap.datasets.california(n_points=1200)
|
|
model = DecisionTreeRegressor(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Use maskers.Independent with a high max_samples to bypass the default
|
|
# subsampling (max_samples=100), so the >1000 check is actually triggered.
|
|
background = shap.maskers.Independent(X, max_samples=1200)
|
|
with pytest.warns(UserWarning, match="may lead to slow runtimes"):
|
|
shap.TreeExplainer(model, background, feature_perturbation="interventional")
|
|
|
|
|
|
def test_front_page_xgboost():
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
# load JS visualization code to notebook
|
|
shap.initjs()
|
|
|
|
# train XGBoost model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
model = xgboost.train({"learning_rate": 0.01, "verbosity": 0}, xgboost.DMatrix(X, label=y), 100)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
# visualize the first prediction's explanation
|
|
shap.force_plot(explainer.expected_value, shap_values[0, :], X.iloc[0, :])
|
|
|
|
# visualize the training set predictions
|
|
shap.force_plot(explainer.expected_value, shap_values, X)
|
|
|
|
# create a SHAP dependence plot to show the effect of a single feature across the whole dataset
|
|
shap.dependence_plot(5, shap_values, X, show=False)
|
|
shap.dependence_plot("Longitude", shap_values, X, show=False)
|
|
|
|
# summarize the effects of all the features
|
|
shap.summary_plot(shap_values, X, show=False)
|
|
|
|
|
|
def test_xgboost_predictions():
|
|
from shap.explainers._tree import TreeEnsemble
|
|
|
|
xgboost = pytest.importorskip("xgboost")
|
|
X, y = shap.datasets.california(n_points=10)
|
|
model = xgboost.train({"learning_rate": 0.01}, xgboost.DMatrix(X, label=y), 10)
|
|
tree_ensemble = TreeEnsemble(
|
|
model=model,
|
|
data=X,
|
|
data_missing=None,
|
|
model_output="raw",
|
|
)
|
|
y_pred = model.predict(xgboost.DMatrix(X))
|
|
y_pred_tree_ensemble = tree_ensemble.predict(X)
|
|
# this is pretty close but not exactly the same
|
|
assert np.allclose(y_pred, y_pred_tree_ensemble, atol=1e-7)
|
|
|
|
|
|
def test_front_page_sklearn():
|
|
# load JS visualization code to notebook
|
|
shap.initjs()
|
|
|
|
# train model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
models = [
|
|
sklearn.ensemble.RandomForestRegressor(n_estimators=10),
|
|
sklearn.ensemble.ExtraTreesRegressor(n_estimators=10),
|
|
]
|
|
for model in models:
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
# visualize the first prediction's explanation
|
|
shap.force_plot(explainer.expected_value, shap_values[0, :], X.iloc[0, :])
|
|
|
|
# visualize the training set predictions
|
|
shap.force_plot(explainer.expected_value, shap_values, X)
|
|
|
|
# create a SHAP dependence plot to show the effect of a single feature across the whole
|
|
# dataset
|
|
shap.dependence_plot(5, shap_values, X, show=False)
|
|
shap.dependence_plot("Longitude", shap_values, X, show=False)
|
|
|
|
# summarize the effects of all the features
|
|
shap.summary_plot(shap_values, X, show=False)
|
|
|
|
|
|
def _conditional_expectation(tree, S, x):
|
|
tree_ind = 0
|
|
|
|
def R(node_ind):
|
|
f = tree.features[tree_ind, node_ind]
|
|
lc = tree.children_left[tree_ind, node_ind]
|
|
rc = tree.children_right[tree_ind, node_ind]
|
|
if lc < 0:
|
|
result = tree.values[tree_ind, node_ind]
|
|
# Previously the result was an array of one element, which was then implicity converted to a float
|
|
# Make this conversion explicit:
|
|
assert len(result) == 1
|
|
return result[0]
|
|
if f in S:
|
|
if x[f] <= tree.thresholds[tree_ind, node_ind]:
|
|
return R(lc)
|
|
return R(rc)
|
|
lw = tree.node_sample_weight[tree_ind, lc]
|
|
rw = tree.node_sample_weight[tree_ind, rc]
|
|
return (R(lc) * lw + R(rc) * rw) / (lw + rw)
|
|
|
|
out = 0.0
|
|
j = tree.values.shape[0] if tree.tree_limit is None else tree.tree_limit
|
|
for i in range(j):
|
|
tree_ind = i
|
|
out += R(0)
|
|
return out
|
|
|
|
|
|
def _brute_force_tree_shap(tree, x):
|
|
m = len(x)
|
|
phi = np.zeros(m)
|
|
for p in itertools.permutations(range(m)):
|
|
for i in range(m):
|
|
phi[p[i]] += _conditional_expectation(tree, p[: i + 1], x) - _conditional_expectation(tree, p[:i], x)
|
|
return phi / math.factorial(m)
|
|
|
|
|
|
def _validate_shap_values(model, x_test):
|
|
# explain the model's predictions using SHAP values
|
|
tree_explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = tree_explainer(x_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*x_test.shape,)
|
|
assert explanation.base_values.shape == (x_test.shape[0],)
|
|
|
|
# validate values sum to the margin prediction of the model plus expected_value
|
|
assert np.allclose(
|
|
explanation.values.sum(1) + explanation.base_values,
|
|
model.predict(x_test),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("col_sample", [1.0, 0.9])
|
|
def test_ngboost_models_prediction_equal(col_sample):
|
|
from shap.explainers._tree import TreeEnsemble
|
|
|
|
ngboost = pytest.importorskip("ngboost")
|
|
X, y = shap.datasets.california(n_points=500)
|
|
|
|
model = ngboost.NGBRegressor(n_estimators=2, col_sample=col_sample).fit(X, y)
|
|
|
|
tree_ensemble = TreeEnsemble(
|
|
model=model,
|
|
data=X,
|
|
data_missing=None,
|
|
model_output=0, # type: ignore[arg-type]
|
|
)
|
|
y_pred = model.predict(X)
|
|
y_pred_tree_ensemble = tree_ensemble.predict(X)
|
|
assert (y_pred == y_pred_tree_ensemble).all()
|
|
|
|
|
|
@pytest.mark.parametrize("col_sample", [1.0, 0.9])
|
|
def test_ngboost_sum_of_shap_values(col_sample):
|
|
ngboost = pytest.importorskip("ngboost")
|
|
|
|
X, y = shap.datasets.california(n_points=500)
|
|
model = ngboost.NGBRegressor(n_estimators=20, col_sample=col_sample).fit(X, y)
|
|
predicted = model.predict(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model, model_output=0) # type: ignore[arg-type]
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-5
|
|
|
|
|
|
@pytest.fixture
|
|
def configure_pyspark_python(monkeypatch):
|
|
monkeypatch.setenv("PYSPARK_PYTHON", sys.executable)
|
|
monkeypatch.setenv("PYSPARK_DRIVER_PYTHON", sys.executable)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="fails due to OOM errors, see #4021")
|
|
def test_pyspark_classifier_decision_tree(configure_pyspark_python):
|
|
pyspark = pytest.importorskip("pyspark")
|
|
pytest.importorskip("pyspark.ml")
|
|
try:
|
|
spark = pyspark.sql.SparkSession.builder.config(
|
|
conf=pyspark.SparkConf().set("spark.master", "local[*]")
|
|
).getOrCreate()
|
|
except Exception:
|
|
pytest.skip("Could not create pyspark context")
|
|
|
|
iris_sk = sklearn.datasets.load_iris()
|
|
iris = pd.DataFrame(data=np.c_[iris_sk["data"], iris_sk["target"]], columns=iris_sk["feature_names"] + ["target"])[
|
|
:100
|
|
]
|
|
col = ["sepal_length", "sepal_width", "petal_length", "petal_width", "type"]
|
|
iris = spark.createDataFrame(iris, col)
|
|
iris = pyspark.ml.feature.VectorAssembler(inputCols=col[:-1], outputCol="features").transform(iris)
|
|
iris = pyspark.ml.feature.StringIndexer(inputCol="type", outputCol="label").fit(iris).transform(iris)
|
|
|
|
classifiers = [
|
|
pyspark.ml.classification.GBTClassifier(labelCol="label", featuresCol="features"),
|
|
pyspark.ml.classification.RandomForestClassifier(labelCol="label", featuresCol="features"),
|
|
pyspark.ml.classification.DecisionTreeClassifier(labelCol="label", featuresCol="features"),
|
|
]
|
|
for classifier in classifiers:
|
|
model = classifier.fit(iris)
|
|
explainer = shap.TreeExplainer(model)
|
|
# Make sure the model can be serializable to run shap values with spark
|
|
pickle.dumps(explainer)
|
|
X = pd.DataFrame(data=iris_sk.data, columns=iris_sk.feature_names)[:100]
|
|
|
|
shap_values = explainer.shap_values(X, check_additivity=False)
|
|
expected_values = explainer.expected_value
|
|
|
|
predictions = (
|
|
model.transform(iris)
|
|
.select("rawPrediction")
|
|
.rdd.map(lambda x: [float(y) for y in x["rawPrediction"]])
|
|
.toDF(["class0", "class1"])
|
|
.toPandas()
|
|
)
|
|
|
|
if str(type(model)).endswith("GBTClassificationModel'>"):
|
|
diffs = expected_values + shap_values.sum(1) - predictions.class1
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output for class0!"
|
|
else:
|
|
normalizedPredictions = (predictions.T / predictions.sum(1)).T
|
|
diffs = expected_values[0] + shap_values[:, :, 0].sum(1) - normalizedPredictions.class0
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output for class0!" + model
|
|
diffs = expected_values[1] + shap_values[:, :, 1].sum(1) - normalizedPredictions.class1
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output for class1!" + model
|
|
assert (np.abs(expected_values - normalizedPredictions.mean()) < 1e-1).all(), "Bad expected_value!" + model
|
|
spark.stop()
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="fails due to OOM errors, see #4021")
|
|
def test_pyspark_regression_decision_tree(configure_pyspark_python):
|
|
pyspark = pytest.importorskip("pyspark")
|
|
pytest.importorskip("pyspark.ml")
|
|
try:
|
|
spark = pyspark.sql.SparkSession.builder.config(
|
|
conf=pyspark.SparkConf().set("spark.master", "local[*]")
|
|
).getOrCreate()
|
|
except Exception:
|
|
pytest.skip("Could not create pyspark context")
|
|
|
|
iris_sk = sklearn.datasets.load_iris()
|
|
iris = pd.DataFrame(data=np.c_[iris_sk["data"], iris_sk["target"]], columns=iris_sk["feature_names"] + ["target"])[
|
|
:100
|
|
]
|
|
|
|
# Simple regressor: try to predict sepal length based on the other features
|
|
col = ["sepal_length", "sepal_width", "petal_length", "petal_width", "type"]
|
|
iris = spark.createDataFrame(iris, col).drop("type")
|
|
iris = pyspark.ml.feature.VectorAssembler(inputCols=col[1:-1], outputCol="features").transform(iris)
|
|
|
|
regressors = [
|
|
pyspark.ml.regression.GBTRegressor(labelCol="sepal_length", featuresCol="features"),
|
|
pyspark.ml.regression.RandomForestRegressor(labelCol="sepal_length", featuresCol="features"),
|
|
pyspark.ml.regression.DecisionTreeRegressor(labelCol="sepal_length", featuresCol="features"),
|
|
]
|
|
for regressor in regressors:
|
|
model = regressor.fit(iris)
|
|
explainer = shap.TreeExplainer(model)
|
|
X = pd.DataFrame(data=iris_sk.data, columns=iris_sk.feature_names).drop("sepal length (cm)", axis=1)[:100]
|
|
|
|
shap_values = explainer.shap_values(X, check_additivity=False)
|
|
expected_values = explainer.expected_value
|
|
|
|
# validate values sum to the margin prediction of the model plus expected_value
|
|
predictions = model.transform(iris).select("prediction").toPandas()
|
|
diffs = expected_values + shap_values.sum(1) - predictions["prediction"]
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output for class0!"
|
|
assert (np.abs(expected_values - predictions.mean()) < 1e-1).all(), "Bad expected_value!"
|
|
spark.stop()
|
|
|
|
|
|
def create_binary_newsgroups_data():
|
|
categories = ["alt.atheism", "soc.religion.christian"]
|
|
newsgroups_train = sklearn.datasets.fetch_20newsgroups(subset="train", categories=categories)
|
|
newsgroups_test = sklearn.datasets.fetch_20newsgroups(subset="test", categories=categories)
|
|
class_names = ["atheism", "christian"]
|
|
return newsgroups_train, newsgroups_test, class_names
|
|
|
|
|
|
def create_random_forest_vectorizer():
|
|
from sklearn.base import TransformerMixin
|
|
from sklearn.ensemble import RandomForestClassifier
|
|
from sklearn.feature_extraction.text import CountVectorizer
|
|
from sklearn.pipeline import Pipeline
|
|
|
|
vectorizer = CountVectorizer(lowercase=False, min_df=0.0, binary=True)
|
|
|
|
class DenseTransformer(TransformerMixin):
|
|
def fit(self, X, y=None, **fit_params):
|
|
return self
|
|
|
|
def transform(self, X, y=None, **fit_params):
|
|
return X.toarray()
|
|
|
|
rf = RandomForestClassifier(n_estimators=500, random_state=777)
|
|
return Pipeline([("vectorizer", vectorizer), ("to_dense", DenseTransformer()), ("rf", rf)])
|
|
|
|
|
|
def test_sklearn_random_forest_newsgroups():
|
|
import shap
|
|
# from sklearn.ensemble import RandomForestClassifier
|
|
|
|
# note: this test used to fail in native TreeExplainer code due to memory corruption
|
|
newsgroups_train, newsgroups_test, _ = create_binary_newsgroups_data()
|
|
pipeline = create_random_forest_vectorizer()
|
|
pipeline.fit(newsgroups_train.data, newsgroups_train.target)
|
|
rf = pipeline.named_steps["rf"]
|
|
vectorizer = pipeline.named_steps["vectorizer"]
|
|
densifier = pipeline.named_steps["to_dense"]
|
|
|
|
dense_bg = densifier.transform(vectorizer.transform(newsgroups_test.data[0:20]))
|
|
|
|
test_row = newsgroups_test.data[83:84]
|
|
explainer = shap.TreeExplainer(rf, dense_bg, feature_perturbation="interventional")
|
|
vec_row = vectorizer.transform(test_row)
|
|
dense_row = densifier.transform(vec_row)
|
|
explainer.shap_values(dense_row)
|
|
|
|
|
|
def test_sklearn_decision_tree_multiclass():
|
|
import numpy as np
|
|
from sklearn.tree import DecisionTreeClassifier
|
|
|
|
import shap
|
|
|
|
X, y = shap.datasets.iris()
|
|
y[y == 2] = 1
|
|
model = DecisionTreeClassifier(max_depth=None, min_samples_split=2, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.abs(shap_values[0][0, 0] - 0.05) < 1e-1
|
|
assert np.abs(shap_values[1][0, 0] + 0.05) < 1e-1
|
|
|
|
|
|
def _common_lightgbm_regressor_test(create_model):
|
|
import numpy as np
|
|
|
|
import shap
|
|
|
|
# train lightgbm model on california housing price regression dataset
|
|
X, y = shap.datasets.california()
|
|
model = create_model()
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
ex = shap.TreeExplainer(model)
|
|
shap_values = ex.shap_values(X)
|
|
|
|
predicted = model.predict(X, raw_score=True)
|
|
|
|
assert np.abs(shap_values.sum(1) + ex.expected_value - predicted).max() < 1e-4, (
|
|
"SHAP values don't sum to model output!"
|
|
)
|
|
|
|
|
|
def test_lightgbm():
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
def create_model():
|
|
return lightgbm.sklearn.LGBMRegressor(categorical_feature=[8])
|
|
|
|
_common_lightgbm_regressor_test(create_model)
|
|
|
|
|
|
def test_lightgbm_mse_regressor():
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train the lightgbm model on a dataset with MSE objective
|
|
def create_model():
|
|
return lightgbm.sklearn.LGBMRegressor(categorical_feature=[8], objective="mean_squared_error")
|
|
|
|
_common_lightgbm_regressor_test(create_model)
|
|
|
|
|
|
def _common_lightgbm_nonsklearn_api(dataset, params, validation):
|
|
import lightgbm
|
|
from sklearn.model_selection import train_test_split
|
|
|
|
import shap
|
|
|
|
# train the lightgbm model using non-sklearn API with binary classification dataset
|
|
X_train, X_test, y_train, y_test = train_test_split(*dataset, test_size=0.2, random_state=0)
|
|
lgb_train = lightgbm.Dataset(X_train, y_train)
|
|
lgb_test = lightgbm.Dataset(X_test, y_test, reference=lgb_train)
|
|
|
|
booster = lightgbm.train(params, lgb_train, valid_sets=[lgb_train, lgb_test])
|
|
# explain the model's predictions using SHAP values
|
|
ex = shap.TreeExplainer(booster)
|
|
shap_values = ex.shap_values(X_test)
|
|
|
|
predicted = booster.predict(X_test, raw_score=True)
|
|
|
|
validation(shap_values, ex.expected_value, predicted)
|
|
|
|
|
|
def test_lightgbm_nonsklearn_api_binary():
|
|
import numpy as np
|
|
|
|
import shap
|
|
|
|
# train the lightgbm model using non-sklearn API with binary classification dataset
|
|
params = {
|
|
"objective": "binary",
|
|
"num_threads": 4,
|
|
"n_estimators": 8000,
|
|
"early_stopping_round": 50,
|
|
"metric": ["binary_error"],
|
|
"random_state": 7,
|
|
"verbose": 1,
|
|
}
|
|
|
|
def validation(shap_values, expected_value, predicted):
|
|
assert np.abs(shap_values.sum(1) + expected_value - predicted).max() < 1e-4, (
|
|
"SHAP values don't sum to model output!"
|
|
)
|
|
|
|
_common_lightgbm_nonsklearn_api(dataset=shap.datasets.iris(), params=params, validation=validation)
|
|
|
|
|
|
def test_lightgbm_nonsklearn_api_regressor():
|
|
import numpy as np
|
|
|
|
import shap
|
|
|
|
# train the lightgbm model using non-sklearn API with regression dataset
|
|
params = {
|
|
"num_threads": 4,
|
|
"n_estimators": 8000,
|
|
"early_stopping_round": 50,
|
|
"metric": ["rmse"],
|
|
"random_state": 7,
|
|
"verbose": 1,
|
|
}
|
|
|
|
def validation(shap_values, expected_value, predicted):
|
|
assert np.abs(shap_values.sum(1) + expected_value - predicted).max() < 1e-4, (
|
|
"SHAP values don't sum to model output!"
|
|
)
|
|
|
|
_common_lightgbm_nonsklearn_api(dataset=shap.datasets.adult(), params=params, validation=validation)
|
|
|
|
|
|
def test_gpboost():
|
|
gpboost = pytest.importorskip("gpboost")
|
|
# train gpboost model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
data_train = gpboost.Dataset(X, y)
|
|
model = gpboost.train(
|
|
params={"objective": "regression_l2", "learning_rate": 0.1, "verbose": 0},
|
|
train_set=data_train,
|
|
num_boost_round=10,
|
|
)
|
|
predicted = model.predict(X, pred_latent=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model, feature_perturbation="tree_path_dependent")
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
|
|
def test_catboost():
|
|
catboost = pytest.importorskip("catboost")
|
|
# train catboost model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
X["IsOld"] = (X["HouseAge"] > 30).astype(str)
|
|
model = catboost.CatBoostRegressor(iterations=30, learning_rate=0.1, random_seed=123)
|
|
p = catboost.Pool(X, y, cat_features=["IsOld"])
|
|
model.fit(p, verbose=False, plot=False)
|
|
predicted = model.predict(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
X, y = sklearn.datasets.load_breast_cancer(return_X_y=True)
|
|
model = catboost.CatBoostClassifier(iterations=10, learning_rate=0.5, random_seed=12)
|
|
model.fit(X, y, verbose=False, plot=False)
|
|
predicted = model.predict(X, prediction_type="RawFormulaVal")
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == X.shape
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.allclose(explanation.values.sum(1) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
|
|
def test_catboost_categorical():
|
|
catboost = pytest.importorskip("catboost")
|
|
X, y = shap.datasets.california(n_points=500)
|
|
X["IsOld"] = (X["HouseAge"] > 30).astype(str)
|
|
|
|
model = catboost.CatBoostRegressor(100, cat_features=["IsOld"], verbose=False)
|
|
model.fit(X, y)
|
|
predicted = model.predict(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
|
|
def test_catboost_interactions():
|
|
# GH #3324
|
|
catboost = pytest.importorskip("catboost")
|
|
|
|
X, y = shap.datasets.adult(n_points=50)
|
|
|
|
model = catboost.CatBoostClassifier(depth=1, iterations=10).fit(X, y)
|
|
predicted = model.predict(X, prediction_type="RawFormulaVal")
|
|
|
|
ex_cat = shap.TreeExplainer(model)
|
|
|
|
# catboost explanations
|
|
explanation = ex_cat(X, interactions=True)
|
|
assert np.allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
|
|
def _average_path_length(n_samples_leaf):
|
|
"""Vendored from: https://github.com/scikit-learn/scikit-learn/blob/399131c8545cd525724e4bacf553416c512ac82c/sklearn/ensemble/_iforest.py#L531
|
|
|
|
For use in isolation forest tests.
|
|
"""
|
|
n_samples_leaf = check_array(n_samples_leaf, ensure_2d=False)
|
|
|
|
n_samples_leaf_shape = n_samples_leaf.shape
|
|
n_samples_leaf = n_samples_leaf.reshape((1, -1))
|
|
average_path_length = np.zeros(n_samples_leaf.shape)
|
|
|
|
mask_1 = n_samples_leaf <= 1
|
|
mask_2 = n_samples_leaf == 2
|
|
not_mask = ~np.logical_or(mask_1, mask_2)
|
|
|
|
average_path_length[mask_1] = 0.0
|
|
average_path_length[mask_2] = 1.0
|
|
average_path_length[not_mask] = (
|
|
2.0 * (np.log(n_samples_leaf[not_mask] - 1.0) + np.euler_gamma)
|
|
- 2.0 * (n_samples_leaf[not_mask] - 1.0) / n_samples_leaf[not_mask]
|
|
)
|
|
|
|
return average_path_length.reshape(n_samples_leaf_shape)
|
|
|
|
|
|
def test_isolation_forest():
|
|
from sklearn.ensemble import IsolationForest
|
|
|
|
X, _ = shap.datasets.california(n_points=500)
|
|
for max_features in [1.0, 0.75]:
|
|
iso = IsolationForest(max_features=max_features)
|
|
iso.fit(X)
|
|
|
|
explainer = shap.TreeExplainer(iso)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
path_length = _average_path_length(np.array([iso.max_samples_]))[0]
|
|
score_from_shap = -(2 ** (-(explanation.values.sum(1) + explanation.base_values) / path_length))
|
|
assert np.allclose(iso.score_samples(X), score_from_shap, atol=1e-7)
|
|
|
|
|
|
def test_pyod_isolation_forest():
|
|
pytest.importorskip("pyod.models.iforest")
|
|
from pyod.models.iforest import IForest
|
|
|
|
X, _ = shap.datasets.california(n_points=500)
|
|
X = sklearn.utils.check_array(X)
|
|
for max_features in [1.0, 0.75]:
|
|
iso = IForest(max_features=max_features)
|
|
iso.fit(X)
|
|
|
|
explainer = shap.TreeExplainer(iso)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
path_length = _average_path_length(np.array([iso.max_samples_]))[0]
|
|
score_from_shap = -(2 ** (-(explanation.values.sum(1) + explanation.base_values) / path_length))
|
|
assert np.allclose(iso.detector_.score_samples(X), score_from_shap, atol=1e-7)
|
|
|
|
|
|
def test_provided_background_tree_path_dependent():
|
|
"""Tests xgboost explainer when feature_perturbation is tree_path_dependent and when background
|
|
data is provided.
|
|
"""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.adult(n_points=100)
|
|
dtrain = xgboost.DMatrix(X, label=y, feature_names=list(X.columns))
|
|
|
|
params = {
|
|
"booster": "gbtree",
|
|
"objective": "binary:logistic",
|
|
"max_depth": 2,
|
|
"eta": 0.05,
|
|
"nthread": -1,
|
|
"random_state": 42,
|
|
}
|
|
bst = xgboost.train(params=params, dtrain=dtrain, num_boost_round=10)
|
|
pred_scores = bst.predict(dtrain, output_margin=True)
|
|
|
|
explainer = shap.TreeExplainer(bst, data=X, feature_perturbation="tree_path_dependent")
|
|
diffs = explainer.expected_value + explainer.shap_values(X).sum(axis=1) - pred_scores
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output!"
|
|
assert np.abs(explainer.expected_value - pred_scores.mean()) < 1e-6, "Bad expected_value!"
|
|
|
|
|
|
def test_provided_background_independent():
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.iris()
|
|
# Select the first 100 rows, so that the y values contain only 0s and 1s
|
|
X = X[:100]
|
|
y = y[:100]
|
|
train_x, test_x, train_y, _ = sklearn.model_selection.train_test_split(X, y, random_state=1)
|
|
feature_names = ["a", "b", "c", "d"]
|
|
dtrain = xgboost.DMatrix(train_x, label=train_y, feature_names=feature_names)
|
|
dtest = xgboost.DMatrix(test_x, feature_names=feature_names)
|
|
|
|
params = {
|
|
"booster": "gbtree",
|
|
"objective": "binary:logistic",
|
|
"max_depth": 4,
|
|
"eta": 0.1,
|
|
"nthread": -1,
|
|
}
|
|
|
|
bst = xgboost.train(params=params, dtrain=dtrain, num_boost_round=100)
|
|
|
|
explainer = shap.TreeExplainer(bst, test_x, feature_perturbation="interventional")
|
|
diffs = explainer.expected_value + explainer.shap_values(test_x).sum(1) - bst.predict(dtest, output_margin=True)
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output!"
|
|
assert np.abs(explainer.expected_value - bst.predict(dtest, output_margin=True).mean()) < 1e-4, (
|
|
"Bad expected_value!"
|
|
)
|
|
|
|
|
|
def test_provided_background_independent_prob_output():
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.iris()
|
|
# Select the first 100 rows, so that the y values contain only 0s and 1s
|
|
X = X[:100]
|
|
y = y[:100]
|
|
train_x, test_x, train_y, _ = sklearn.model_selection.train_test_split(X, y, random_state=1)
|
|
feature_names = ["a", "b", "c", "d"]
|
|
dtrain = xgboost.DMatrix(train_x, label=train_y, feature_names=feature_names)
|
|
dtest = xgboost.DMatrix(test_x, feature_names=feature_names)
|
|
|
|
for objective in ["reg:logistic", "binary:logistic"]:
|
|
params = {
|
|
"booster": "gbtree",
|
|
"objective": objective,
|
|
"max_depth": 4,
|
|
"eta": 0.1,
|
|
"nthread": -1,
|
|
}
|
|
|
|
bst = xgboost.train(params=params, dtrain=dtrain, num_boost_round=100)
|
|
|
|
explainer = shap.TreeExplainer(bst, test_x, feature_perturbation="interventional", model_output="probability")
|
|
diffs = explainer.expected_value + explainer.shap_values(test_x).sum(1) - bst.predict(dtest)
|
|
assert np.max(np.abs(diffs)) < 1e-4, "SHAP values don't sum to model output!"
|
|
assert np.abs(explainer.expected_value - bst.predict(dtest).mean()) < 1e-4, "Bad expected_value!"
|
|
|
|
|
|
def test_single_tree_compare_with_kernel_shap():
|
|
"""Compare with Kernel SHAP, which makes the same independence assumptions
|
|
as Independent Tree SHAP. Namely, they both assume independence between the
|
|
set being conditioned on, and the remainder set.
|
|
"""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
# FIXME: this test should ideally pass with any random seed. See #2960
|
|
random_seed = 0
|
|
rs = np.random.RandomState(random_seed)
|
|
|
|
n = 100
|
|
X = rs.normal(size=(n, 7))
|
|
y = np.matmul(X, [-2, 1, 3, 5, 2, 20, -5])
|
|
|
|
# train a model with single tree
|
|
Xd = xgboost.DMatrix(X, label=y)
|
|
model = xgboost.train({"eta": 1, "max_depth": 6, "base_score": 0, "lambda": 0}, Xd, 1)
|
|
ypred = model.predict(Xd)
|
|
|
|
# Compare for five random samples
|
|
for _ in range(5):
|
|
x_ind = rs.choice(X.shape[1])
|
|
x = X[x_ind : x_ind + 1, :]
|
|
|
|
expl = shap.TreeExplainer(model, X, feature_perturbation="interventional")
|
|
|
|
def f(inp):
|
|
return model.predict(xgboost.DMatrix(inp))
|
|
|
|
expl_kern = shap.KernelExplainer(f, X)
|
|
|
|
itshap = expl.shap_values(x)
|
|
kshap = expl_kern.shap_values(x, nsamples=150)
|
|
assert np.allclose(itshap, kshap), "Kernel SHAP doesn't match Independent Tree SHAP!"
|
|
assert np.allclose(itshap.sum() + expl.expected_value, ypred[x_ind]), "SHAP values don't sum to model output!"
|
|
|
|
|
|
def test_several_trees():
|
|
"""Make sure Independent Tree SHAP sums up to the correct value for
|
|
larger models (20 trees).
|
|
"""
|
|
# FIXME: this test should ideally pass with any random seed. See #2960
|
|
random_seed = 0
|
|
xgboost = pytest.importorskip("xgboost")
|
|
rs = np.random.RandomState(random_seed)
|
|
|
|
n = 1000
|
|
X = rs.normal(size=(n, 7))
|
|
b = np.array([-2, 1, 3, 5, 2, 20, -5])
|
|
y = np.matmul(X, b)
|
|
max_depth = 6
|
|
|
|
# train a model with single tree
|
|
Xd = xgboost.DMatrix(X, label=y)
|
|
model = xgboost.train({"eta": 1, "max_depth": max_depth, "base_score": 0, "lambda": 0}, Xd, 20)
|
|
ypred = model.predict(Xd)
|
|
|
|
# Compare for five random samples
|
|
for _ in range(5):
|
|
x_ind = rs.choice(X.shape[1])
|
|
x = X[x_ind : x_ind + 1, :]
|
|
expl = shap.TreeExplainer(model, X, feature_perturbation="interventional")
|
|
itshap = expl.shap_values(x)
|
|
assert np.allclose(itshap.sum() + expl.expected_value, ypred[x_ind]), "SHAP values don't sum to model output!"
|
|
|
|
|
|
def test_single_tree_nonlinear_transformations():
|
|
"""Make sure Independent Tree SHAP single trees with non-linear
|
|
transformations.
|
|
"""
|
|
# Supported non-linear transforms
|
|
# def sigmoid(x):
|
|
# return(1/(1+np.exp(-x)))
|
|
|
|
# def log_loss(yt,yp):
|
|
# return(-(yt*np.log(yp) + (1 - yt)*np.log(1 - yp)))
|
|
|
|
# def mse(yt,yp):
|
|
# return(np.square(yt-yp))
|
|
|
|
# FIXME: this test should ideally pass with any random seed. See #2960
|
|
random_seed = 0
|
|
|
|
xgboost = pytest.importorskip("xgboost")
|
|
rs = np.random.RandomState(random_seed)
|
|
|
|
n = 100
|
|
X = rs.normal(size=(n, 7))
|
|
y = np.matmul(X, [-2, 1, 3, 5, 2, 20, -5])
|
|
y = y + abs(min(y))
|
|
y = rs.binomial(n=1, p=y / max(y))
|
|
|
|
# train a model with single tree
|
|
Xd = xgboost.DMatrix(X, label=y)
|
|
model = xgboost.train(
|
|
{"eta": 1, "max_depth": 6, "base_score": y.mean(), "lambda": 0, "objective": "binary:logistic"}, Xd, 1
|
|
)
|
|
pred = model.predict(Xd, output_margin=True) # In margin space (log odds)
|
|
trans_pred = model.predict(Xd) # In probability space
|
|
|
|
expl = shap.TreeExplainer(model, X, feature_perturbation="interventional")
|
|
|
|
def f(inp):
|
|
return model.predict(xgboost.DMatrix(inp), output_margin=True)
|
|
|
|
expl_kern = shap.KernelExplainer(f, X)
|
|
|
|
x_ind = 0
|
|
x = X[x_ind : x_ind + 1, :]
|
|
itshap = expl.shap_values(x)
|
|
kshap = expl_kern.shap_values(x, nsamples=300)
|
|
assert np.allclose(itshap.sum() + expl.expected_value, pred[x_ind]), (
|
|
"SHAP values don't sum to model output on explaining margin!"
|
|
)
|
|
assert np.allclose(itshap, kshap), "Independent Tree SHAP doesn't match Kernel SHAP on explaining margin!"
|
|
|
|
model.set_attr(objective="binary:logistic")
|
|
expl = shap.TreeExplainer(model, X, feature_perturbation="interventional", model_output="probability")
|
|
itshap = expl.shap_values(x)
|
|
assert np.allclose(itshap.sum() + expl.expected_value, trans_pred[x_ind]), (
|
|
"SHAP values don't sum to model output on explaining logistic!"
|
|
)
|
|
|
|
# expl = shap.TreeExplainer(model, X, feature_perturbation="interventional",
|
|
# model_output="logloss")
|
|
# itshap = expl.shap_values(x,y=y[x_ind])
|
|
# margin_pred = model.predict(xgb.DMatrix(x),output_margin=True)
|
|
# currpred = log_loss(y[x_ind],sigmoid(margin_pred))
|
|
# assert np.allclose(itshap.sum(), currpred - expl.expected_value), \
|
|
# "SHAP values don't sum to model output on explaining logloss!"
|
|
|
|
|
|
def test_skopt_rf_et():
|
|
skopt = pytest.importorskip("skopt")
|
|
|
|
# Define an objective function for skopt to optimise.
|
|
def objective_function(x):
|
|
return x[0] ** 2 - x[1] ** 2 + x[1] * x[0]
|
|
|
|
# Uneven bounds to prevent "objective has been evaluated" warnings.
|
|
problem_bounds = [(-1e6, 3e6), (-1e6, 3e6)]
|
|
|
|
# Don't worry about "objective has been evaluated" warnings.
|
|
result_et = skopt.forest_minimize(objective_function, problem_bounds, n_calls=100, base_estimator="ET")
|
|
result_rf = skopt.forest_minimize(objective_function, problem_bounds, n_calls=100, base_estimator="RF")
|
|
|
|
et_df = pd.DataFrame(result_et.x_iters, columns=["X0", "X1"])
|
|
|
|
# Explain the model's predictions.
|
|
explainer_et = shap.TreeExplainer(result_et.models[-1], et_df)
|
|
shap_values_et = explainer_et.shap_values(et_df)
|
|
|
|
rf_df = pd.DataFrame(result_rf.x_iters, columns=["X0", "X1"])
|
|
|
|
# Explain the model's predictions (Random forest).
|
|
explainer_rf = shap.TreeExplainer(result_rf.models[-1], rf_df)
|
|
shap_values_rf = explainer_rf.shap_values(rf_df)
|
|
|
|
assert np.allclose(shap_values_et.sum(1) + explainer_et.expected_value, result_et.models[-1].predict(et_df))
|
|
assert np.allclose(shap_values_rf.sum(1) + explainer_rf.expected_value, result_rf.models[-1].predict(rf_df))
|
|
|
|
|
|
class TestSingleTree:
|
|
"""Tests for the SingleTree class."""
|
|
|
|
def test_singletree_lightgbm_basic(self):
|
|
"""A basic test for checking that a LightGBM `dump_model()["tree_info"]`
|
|
dictionary is parsed properly into a `SingleTree` object.
|
|
"""
|
|
# Stump (only root node) tree
|
|
sample_tree = {
|
|
"tree_index": 256,
|
|
"num_leaves": 1,
|
|
"num_cat": 0,
|
|
"shrinkage": 1,
|
|
"tree_structure": {
|
|
"leaf_value": 0,
|
|
# "leaf_count": 123, # FIXME(upstream): lightgbm-org/LightGBM#5962
|
|
},
|
|
}
|
|
stree = SingleTree(sample_tree)
|
|
# just ensure that this does not error out
|
|
assert stree.children_left[0] == -1
|
|
# assert stree.node_sample_weight[0] == 123
|
|
assert hasattr(stree, "values")
|
|
|
|
# Depth=1 tree
|
|
sample_tree = {
|
|
"tree_index": 0,
|
|
"num_leaves": 2,
|
|
"num_cat": 0,
|
|
"shrinkage": 0.1,
|
|
"tree_structure": {
|
|
"split_index": 0,
|
|
"split_feature": 1,
|
|
"split_gain": 0.001471,
|
|
"threshold": 0,
|
|
"decision_type": "<=",
|
|
"default_left": True,
|
|
"missing_type": "None",
|
|
"internal_value": 0,
|
|
"internal_weight": 0,
|
|
"internal_count": 100,
|
|
"left_child": {"leaf_index": 0, "leaf_value": 0.0667, "leaf_weight": 0.00157, "leaf_count": 33},
|
|
"right_child": {"leaf_index": 1, "leaf_value": -0.0667, "leaf_weight": 0.00175, "leaf_count": 67},
|
|
},
|
|
}
|
|
|
|
stree = SingleTree(sample_tree)
|
|
# just ensure that the tree is parsed correctly
|
|
assert stree.node_sample_weight[0] == 100
|
|
assert hasattr(stree, "values")
|
|
|
|
|
|
class TestExplainerSklearn:
|
|
"""Tests for the TreeExplainer when the model passed in from scikit-learn (core).
|
|
|
|
Included models:
|
|
* tree.DecisionTreeClassifier
|
|
* ensemble.RandomForestClassifier
|
|
* ensemble.RandomForestRegressor
|
|
* ensemble.ExtraTreesRegressor
|
|
* ensemble.GradientBoostingClassifier
|
|
* ensemble.GradientBoostingRegressor
|
|
* ensemble.HistGradientBoostingClassifier
|
|
* ensemble.HistGradientBoostingRegressor
|
|
"""
|
|
|
|
def test_sklearn_decision_tree_multiclass(self):
|
|
X, y = shap.datasets.iris()
|
|
y[y == 2] = 1
|
|
model = sklearn.tree.DecisionTreeClassifier(max_depth=None, min_samples_split=2, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.abs(shap_values[0][0, 0] - 0.05) < 1e-1
|
|
assert np.abs(shap_values[1][0, 0] + 0.05) < 1e-1
|
|
|
|
def test_sum_match_random_forest_classifier(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(), test_size=0.2, random_state=0
|
|
)
|
|
clf = sklearn.ensemble.RandomForestClassifier(random_state=202, n_estimators=10, max_depth=10)
|
|
clf.fit(X_train, Y_train)
|
|
predicted = clf.predict_proba(X_test)
|
|
explainer = shap.TreeExplainer(clf)
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
num_classes = 2
|
|
assert explanation.values.shape == (*X_test.shape, num_classes)
|
|
assert explanation.base_values.shape == (len(X_test), num_classes)
|
|
|
|
# check that SHAP values sum to model output
|
|
class0_exp = explanation[..., 0]
|
|
assert np.abs(class0_exp.values.sum(1) + class0_exp.base_values - predicted[:, 0]).max() < 1e-4
|
|
|
|
def test_sklearn_random_forest_multiclass(self):
|
|
X, y = shap.datasets.iris()
|
|
y[y == 2] = 1
|
|
model = sklearn.ensemble.RandomForestClassifier(
|
|
n_estimators=100,
|
|
max_depth=None,
|
|
min_samples_split=2,
|
|
random_state=0,
|
|
)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
assert np.abs(shap_values[0, 0, 0] - 0.05) < 1e-3
|
|
assert np.abs(shap_values[0, 0, 1] + 0.05) < 1e-3
|
|
|
|
def test_sklearn_interaction_values(self):
|
|
X, _ = shap.datasets.iris()
|
|
X_train, _, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.iris(), test_size=0.2, random_state=0
|
|
)
|
|
rforest = sklearn.ensemble.RandomForestClassifier(
|
|
n_estimators=10,
|
|
max_depth=None,
|
|
min_samples_split=2,
|
|
random_state=0,
|
|
)
|
|
model = rforest.fit(X_train, Y_train)
|
|
|
|
# verify symmetry of the interaction values (this typically breaks if anything is wrong)
|
|
explainer = shap.TreeExplainer(model)
|
|
interaction_vals = explainer.shap_interaction_values(X)
|
|
assert np.allclose(interaction_vals, np.swapaxes(interaction_vals, 1, 2))
|
|
|
|
# ensure the interaction plot works
|
|
shap.summary_plot(interaction_vals[:, :, :, 0], X, show=False)
|
|
|
|
# text interaction call from TreeExplainer
|
|
X, y = shap.datasets.adult(n_points=50)
|
|
|
|
rfc = sklearn.ensemble.RandomForestClassifier(max_depth=1).fit(X, y)
|
|
predicted = rfc.predict_proba(X)
|
|
ex_rfc = shap.TreeExplainer(rfc)
|
|
explanation = ex_rfc(X, interactions=True)
|
|
assert np.allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted)
|
|
assert np.allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted)
|
|
|
|
def _create_vectorizer_for_randomforestclassifier(self):
|
|
"""Helper setup function"""
|
|
vectorizer = sklearn.feature_extraction.text.CountVectorizer(lowercase=False, min_df=0.0, binary=True)
|
|
|
|
class DenseTransformer(sklearn.base.TransformerMixin):
|
|
def fit(self, X, y=None, **fit_params):
|
|
return self
|
|
|
|
def transform(self, X, y=None, **fit_params):
|
|
return X.toarray()
|
|
|
|
rf = sklearn.ensemble.RandomForestClassifier(n_estimators=10, random_state=777)
|
|
return sklearn.pipeline.Pipeline([("vectorizer", vectorizer), ("to_dense", DenseTransformer()), ("rf", rf)])
|
|
|
|
def test_sklearn_random_forest_newsgroups(self):
|
|
"""note: this test used to fail in native TreeExplainer code due to memory corruption"""
|
|
newsgroups_train, newsgroups_test, _ = create_binary_newsgroups_data()
|
|
pipeline = self._create_vectorizer_for_randomforestclassifier()
|
|
pipeline.fit(newsgroups_train.data, newsgroups_train.target)
|
|
rf = pipeline.named_steps["rf"]
|
|
vectorizer = pipeline.named_steps["vectorizer"]
|
|
densifier = pipeline.named_steps["to_dense"]
|
|
|
|
dense_bg = densifier.transform(vectorizer.transform(newsgroups_test.data[0:20]))
|
|
|
|
test_row = newsgroups_test.data[83:84]
|
|
explainer = shap.TreeExplainer(rf, dense_bg, feature_perturbation="interventional")
|
|
vec_row = vectorizer.transform(test_row)
|
|
dense_row = densifier.transform(vec_row)
|
|
explainer.shap_values(dense_row)
|
|
|
|
def test_multi_target_random_forest_regressor(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.linnerud(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
est = sklearn.ensemble.RandomForestRegressor(random_state=202, n_estimators=10, max_depth=10)
|
|
est.fit(X_train, Y_train)
|
|
predicted = est.predict(X_test)
|
|
|
|
explainer = shap.TreeExplainer(est)
|
|
expected_values = np.asarray(explainer.expected_value)
|
|
assert len(expected_values) == est.n_outputs_, "Length of expected_values doesn't match n_outputs_"
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X_test.shape, est.n_outputs_)
|
|
assert explanation.base_values.shape == (len(X_test), est.n_outputs_)
|
|
|
|
# check that SHAP values sum to model output for all multioutputs
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_sum_match_extra_trees(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(), test_size=0.2, random_state=0
|
|
)
|
|
clf = sklearn.ensemble.ExtraTreesRegressor(random_state=202, n_estimators=10, max_depth=10)
|
|
clf.fit(X_train, Y_train)
|
|
predicted = clf.predict(X_test)
|
|
ex = shap.TreeExplainer(clf)
|
|
shap_values = ex.shap_values(X_test)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(shap_values.sum(1) + ex.expected_value - predicted).max() < 1e-4
|
|
|
|
# TODO: this has sometimes failed with strange answers, should run memcheck on this for any
|
|
# memory issues at some point...
|
|
def test_multi_target_extra_trees(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.linnerud(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
est = sklearn.ensemble.ExtraTreesRegressor(random_state=202, n_estimators=10, max_depth=10)
|
|
est.fit(X_train, Y_train)
|
|
predicted = est.predict(X_test)
|
|
|
|
explainer = shap.TreeExplainer(est)
|
|
expected_values = np.asarray(explainer.expected_value)
|
|
assert len(expected_values) == est.n_outputs_, "Length of expected_values doesn't match n_outputs_"
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X_test.shape, est.n_outputs_)
|
|
assert explanation.base_values.shape == (len(X_test), est.n_outputs_)
|
|
|
|
# check that SHAP values sum to model output for all multioutputs
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_gradient_boosting_classifier_invalid_init_estimator(self):
|
|
"""Currently only the logodds estimators are supported, so this test checks that
|
|
an appropriate error is thrown when other estimator types are passed in.
|
|
|
|
Remove/modify this test if we support other init estimator types in the future.
|
|
"""
|
|
clf = sklearn.ensemble.GradientBoostingClassifier(
|
|
n_estimators=10,
|
|
init="zero",
|
|
)
|
|
clf.fit(*shap.datasets.adult())
|
|
with pytest.raises(InvalidModelError):
|
|
shap.TreeExplainer(clf)
|
|
|
|
def test_single_row_gradient_boosting_classifier(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
clf = sklearn.ensemble.GradientBoostingClassifier(
|
|
random_state=202,
|
|
n_estimators=10,
|
|
max_depth=10,
|
|
)
|
|
clf.fit(X_train, Y_train)
|
|
predicted = clf.decision_function(X_test)
|
|
ex = shap.TreeExplainer(clf)
|
|
shap_values = ex.shap_values(X_test.iloc[0, :])
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(shap_values.sum() + ex.expected_value - predicted[0]) < 1e-4
|
|
|
|
def test_sum_match_gradient_boosting_classifier(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
clf = sklearn.ensemble.GradientBoostingClassifier(
|
|
random_state=202,
|
|
n_estimators=10,
|
|
max_depth=10,
|
|
)
|
|
clf.fit(X_train, Y_train)
|
|
|
|
# Use decision function to get prediction before it is mapped to a probability
|
|
predicted = clf.decision_function(X_test)
|
|
|
|
explainer = shap.TreeExplainer(clf)
|
|
initial_ex_value = explainer.expected_value
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X_test.shape,)
|
|
assert explanation.base_values.shape == (len(X_test),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.allclose(explanation.values.sum(1) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
# check initial expected value
|
|
assert np.allclose(initial_ex_value, explainer.expected_value, atol=1e-4), "Initial expected value is wrong!"
|
|
|
|
# check SHAP interaction values sum to model output
|
|
shap_interaction_values = explainer.shap_interaction_values(X_test.iloc[:10, :])
|
|
assert np.allclose(
|
|
shap_interaction_values.sum(axis=(1, 2)) + explainer.expected_value, predicted[:10], atol=1e-4
|
|
)
|
|
|
|
def test_single_row_gradient_boosting_regressor(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
clf = sklearn.ensemble.GradientBoostingRegressor(random_state=202, n_estimators=10, max_depth=10)
|
|
clf.fit(X_train, Y_train)
|
|
|
|
predicted = clf.predict(X_test)
|
|
ex = shap.TreeExplainer(clf)
|
|
shap_values = ex.shap_values(X_test.iloc[0, :])
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(shap_values.sum() + ex.expected_value - predicted[0]) < 1e-4
|
|
|
|
def test_sum_match_gradient_boosting_regressor(self):
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
clf = sklearn.ensemble.GradientBoostingRegressor(random_state=202, n_estimators=10, max_depth=10)
|
|
clf.fit(X_train, Y_train)
|
|
|
|
predicted = clf.predict(X_test)
|
|
explainer = shap.TreeExplainer(clf)
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X_test.shape,)
|
|
assert explanation.base_values.shape == (len(X_test),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_HistGradientBoostingClassifier_proba(self):
|
|
X, y = shap.datasets.adult()
|
|
model = sklearn.ensemble.HistGradientBoostingClassifier(max_iter=10, max_depth=6).fit(X, y)
|
|
predicted = model.predict_proba(X)
|
|
explainer = shap.TreeExplainer(model, shap.sample(X, 10), model_output="predict_proba")
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
num_classes = 2
|
|
assert explanation.values.shape == (*X.shape, num_classes)
|
|
assert explanation.base_values.shape == (len(X), num_classes)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_HistGradientBoostingClassifier_multidim(self, random_seed):
|
|
X, y = shap.datasets.adult(n_points=400)
|
|
rs = np.random.RandomState(random_seed)
|
|
y = rs.randint(0, 3, len(y))
|
|
model = sklearn.ensemble.HistGradientBoostingClassifier(max_iter=10, max_depth=6).fit(X, y)
|
|
predicted = model.decision_function(X)
|
|
explainer = shap.TreeExplainer(model, shap.sample(X, 10), model_output="raw")
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
num_classes = 3
|
|
assert explanation.values.shape == (*X.shape, num_classes)
|
|
assert explanation.base_values.shape == (len(X), num_classes)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_HistGradientBoostingRegressor(self):
|
|
X, y = shap.datasets.diabetes()
|
|
model = sklearn.ensemble.HistGradientBoostingRegressor(max_iter=500, max_depth=6).fit(X, y)
|
|
predicted = model.predict(X)
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
|
|
class TestExplainerXGBoost:
|
|
"""Tests for the TreeExplainer with XGBoost models.
|
|
|
|
Included models:
|
|
* XGBRegressor
|
|
* XGBClassifier
|
|
* XGBRFRegressor
|
|
* XGBRFClassifier
|
|
* XGBRanker
|
|
"""
|
|
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
regressors = [xgboost.XGBRegressor, xgboost.XGBRFRegressor]
|
|
classifiers = [xgboost.XGBClassifier, xgboost.XGBRFClassifier]
|
|
|
|
@pytest.mark.parametrize("Reg", regressors)
|
|
def test_xgboost_regression(self, Reg):
|
|
# train xgboost model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
model = Reg().fit(X, y)
|
|
predicted = model.predict(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
expected_diff = np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max()
|
|
assert expected_diff < 1e-4, "SHAP values don't sum to model output!"
|
|
|
|
@pytest.mark.parametrize("Clf", classifiers)
|
|
def test_xgboost_dmatrix_propagation(self, Clf):
|
|
"""Test that xgboost sklearn attributes are properly passed to the DMatrix
|
|
initiated during shap value calculation. See GH #3313
|
|
"""
|
|
X, y = shap.datasets.adult(n_points=100)
|
|
|
|
# Randomly add missing data to the input where missing data is encoded as 1e-8
|
|
# Cast all columns to float to allow imputing a float value
|
|
X_nan = X.copy().astype(float)
|
|
X_nan.loc[
|
|
X_nan.sample(frac=0.3, random_state=42).index,
|
|
X_nan.columns.to_series().sample(frac=0.5, random_state=42),
|
|
] = 1e-8
|
|
|
|
clf = Clf(missing=1e-8, random_state=42)
|
|
clf.fit(X_nan, y)
|
|
margin = clf.predict(X_nan, output_margin=True)
|
|
explainer = shap.TreeExplainer(clf)
|
|
shap_values = explainer.shap_values(X_nan)
|
|
# check that SHAP values sum to model output
|
|
np.testing.assert_allclose(margin, explainer.expected_value + shap_values.sum(axis=1), atol=1e-4, rtol=1e-4)
|
|
|
|
@pytest.mark.parametrize("Reg", regressors)
|
|
def test_xgboost_direct(self, Reg):
|
|
random_seed = 0
|
|
rs = np.random.RandomState(random_seed)
|
|
N = 100
|
|
M = 4
|
|
X = rs.standard_normal(size=(N, M))
|
|
y = rs.standard_normal(size=N)
|
|
|
|
model = Reg(random_state=rs)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
assert np.allclose(shap_values[0, :], _brute_force_tree_shap(explainer.model, X[0, :]))
|
|
|
|
# TODO: test against multiclass XGBRFClassifier
|
|
def test_xgboost_multiclass(self):
|
|
# train XGBoost model
|
|
X, y = shap.datasets.iris()
|
|
model = self.xgboost.XGBClassifier(n_estimators=10, max_depth=4)
|
|
model.fit(X, y)
|
|
predicted = model.predict(X, output_margin=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
assert np.allclose(explainer.model.predict(X), predicted)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
num_classes = 3
|
|
assert explanation.values.shape == (*X.shape, num_classes)
|
|
assert explanation.base_values.shape == (len(X), num_classes)
|
|
|
|
# check that SHAP values sum to model output
|
|
np.testing.assert_allclose(explanation.values.sum(1) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
int_explanation = explainer(X, interactions=True)
|
|
np.testing.assert_allclose(int_explanation.values.sum((1, 2)) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
# ensure plot works for first class
|
|
shap.dependence_plot(0, explanation[..., 0].values, X, show=False)
|
|
|
|
with pytest.raises(NotImplementedError, match="random forest"):
|
|
clf = self.xgboost.XGBRFClassifier(n_estimators=2)
|
|
clf.fit(X, y)
|
|
shap.TreeExplainer(clf).model.predict(X)
|
|
|
|
with pytest.raises(NotImplementedError, match="random forest"):
|
|
clf = self.xgboost.XGBClassifier(n_estimators=2, num_parallel_tree=3)
|
|
clf.fit(X, y)
|
|
shap.TreeExplainer(clf).model.predict(X)
|
|
|
|
def test_xgboost_ranking(self):
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
# train xgboost ranker model
|
|
x_train, y_train, x_test, _, q_train, _ = shap.datasets.rank()
|
|
params = {
|
|
"objective": "rank:pairwise",
|
|
"learning_rate": 0.1,
|
|
"gamma": 1.0,
|
|
"min_child_weight": 0.1,
|
|
"max_depth": 5,
|
|
"n_estimators": 4,
|
|
}
|
|
model = xgboost.sklearn.XGBRanker(**params)
|
|
model.fit(x_train, y_train, group=q_train.astype(int))
|
|
_validate_shap_values(model, x_test)
|
|
|
|
def test_xgboost_mixed_types(self):
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.california(n_points=500)
|
|
X["HouseAge"] = X["HouseAge"].astype(np.int64)
|
|
X["IsOld"] = X["HouseAge"] > 30
|
|
bst = xgboost.train({"learning_rate": 0.01}, xgboost.DMatrix(X, label=y), 1000)
|
|
shap_values = shap.TreeExplainer(bst).shap_values(X)
|
|
shap.dependence_plot(0, shap_values, X, show=False)
|
|
|
|
def test_xgboost_classifier_independent_margin(self):
|
|
# FIXME: this test should ideally pass with any random seed. See #2960
|
|
random_seed = 0
|
|
|
|
# train XGBoost model
|
|
rs = np.random.RandomState(random_seed)
|
|
n = 1000
|
|
X = rs.normal(size=(n, 7))
|
|
y = np.matmul(X, [-2, 1, 3, 5, 2, 20, -5])
|
|
y = y + abs(min(y))
|
|
y = rs.binomial(n=1, p=y / max(y))
|
|
|
|
model = self.xgboost.XGBClassifier(n_estimators=10, max_depth=5, random_state=random_seed, tree_method="exact")
|
|
model.fit(X, y)
|
|
predicted = model.predict(X, output_margin=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(
|
|
model,
|
|
X,
|
|
feature_perturbation="interventional",
|
|
model_output="raw",
|
|
)
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.allclose(
|
|
explanation.values.sum(1) + explanation.base_values,
|
|
predicted,
|
|
atol=1e-7,
|
|
)
|
|
|
|
def test_xgboost_classifier_independent_probability(self, random_seed):
|
|
# train XGBoost model
|
|
rs = np.random.RandomState(random_seed)
|
|
n = 1000
|
|
X = rs.normal(size=(n, 7))
|
|
b = np.array([-2, 1, 3, 5, 2, 20, -5])
|
|
y = np.matmul(X, b)
|
|
y = y + abs(min(y))
|
|
y = rs.binomial(n=1, p=y / max(y))
|
|
|
|
model = self.xgboost.XGBClassifier(n_estimators=10, max_depth=5, random_state=random_seed)
|
|
model.fit(X, y)
|
|
predicted = model.predict_proba(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(
|
|
model,
|
|
X,
|
|
feature_perturbation="interventional",
|
|
model_output="probability",
|
|
)
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.allclose(
|
|
explanation.values.sum(1) + explanation.base_values,
|
|
predicted[:, 1],
|
|
)
|
|
|
|
# def test_front_page_xgboost_global_path_dependent():
|
|
# try:
|
|
# xgboost = pytest.importorskip("xgboost")
|
|
# except Exception:
|
|
# print("Skipping test_front_page_xgboost!")
|
|
# return
|
|
#
|
|
# # train XGBoost model
|
|
# X, y = shap.datasets.california(n_points=500)
|
|
# model = xgboost.XGBRegressor()
|
|
# model.fit(X, y)
|
|
|
|
# # explain the model's predictions using SHAP values
|
|
# explainer = shap.TreeExplainer(model, X, feature_perturbation="global_path_dependent")
|
|
# shap_values = explainer.shap_values(X)
|
|
|
|
# assert np.allclose(shap_values.sum(1) + explainer.expected_value, model.predict(X))
|
|
|
|
def test_explanation_data_not_dmatrix(self, random_seed):
|
|
"""Checks that DMatrix is not stored in Explanation.data after TreeExplainer.__call__,
|
|
since it is not supported by our plotting functions.
|
|
|
|
See GH #3357 for more information.
|
|
"""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
rs = np.random.RandomState(random_seed)
|
|
X = rs.normal(size=(100, 7))
|
|
y = np.matmul(X, [-2, 1, 3, 5, 2, 20, -5])
|
|
|
|
# train a model with single tree
|
|
Xd = xgboost.DMatrix(X, label=y)
|
|
model = xgboost.train({"eta": 1, "max_depth": 6, "base_score": 0, "lambda": 0}, Xd, 1)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
explanation = explainer(Xd)
|
|
|
|
assert not isinstance(explanation.data, xgboost.core.DMatrix)
|
|
assert hasattr(explanation.data, "shape")
|
|
|
|
def test_tree_limit(self) -> None:
|
|
xgboost = pytest.importorskip("xgboost")
|
|
from sklearn.datasets import load_digits, load_iris
|
|
from sklearn.model_selection import train_test_split
|
|
|
|
# Load regression data
|
|
X, y = shap.datasets.california(n_points=500)
|
|
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=3)
|
|
|
|
# Test Booster
|
|
model = xgboost.train(
|
|
{"learning_rate": 0.01, "verbosity": 0},
|
|
xgboost.DMatrix(X_train, label=y_train),
|
|
num_boost_round=10,
|
|
evals=[(xgboost.DMatrix(X_test, y_test), "Valid")],
|
|
early_stopping_rounds=1,
|
|
)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
assert explainer.model.tree_limit == model.num_boosted_rounds()
|
|
|
|
# Test regressor
|
|
reg = xgboost.XGBRegressor(n_estimators=10)
|
|
reg.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(reg)
|
|
assert explainer.model.tree_limit == reg.n_estimators
|
|
|
|
# Test classifier
|
|
X, y = load_iris(return_X_y=True)
|
|
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=3)
|
|
|
|
# - multiclass
|
|
clf = xgboost.XGBClassifier(n_estimators=10)
|
|
clf.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(clf)
|
|
assert explainer.model.tree_limit == clf.n_estimators * len(np.unique(y))
|
|
|
|
# - multiclass, forest
|
|
clf = xgboost.XGBClassifier(n_estimators=10, num_parallel_tree=3)
|
|
clf.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(clf)
|
|
assert explainer.model.tree_limit == clf.n_estimators * len(np.unique(y)) * 3
|
|
|
|
# - multiclass, forest, early stop
|
|
clf = xgboost.XGBClassifier(n_estimators=1000, num_parallel_tree=3, early_stopping_rounds=1)
|
|
clf.fit(X_train, y_train, eval_set=[(X_test, y_test)])
|
|
# make sure we don't waste too much time on this test
|
|
assert clf.best_iteration < 15
|
|
|
|
explainer = shap.TreeExplainer(clf)
|
|
assert explainer.model.tree_limit == (clf.best_iteration + 1) * len(np.unique(y)) * 3
|
|
|
|
# - binary classification, forest
|
|
X, y = load_digits(return_X_y=True, n_class=2)
|
|
clf = xgboost.XGBClassifier(n_estimators=10, num_parallel_tree=3)
|
|
clf.fit(X, y)
|
|
explainer = shap.TreeExplainer(clf)
|
|
assert explainer.model.tree_limit == clf.n_estimators * clf.num_parallel_tree
|
|
|
|
# Test ranker
|
|
ltr = xgboost.XGBRanker(n_estimators=5, num_parallel_tree=3)
|
|
qid = np.zeros(X_train.shape[0])
|
|
qid[qid.shape[0] // 2 :] = 1
|
|
ltr.fit(X_train, y_train, qid=qid)
|
|
|
|
explainer = shap.TreeExplainer(ltr)
|
|
assert explainer.model.tree_limit == ltr.n_estimators * 3
|
|
|
|
|
|
class TestExplainerLightGBM:
|
|
"""Tests for the TreeExplainer when the model passed in is a LightGBM instance.
|
|
|
|
Included models:
|
|
* LGBMRegressor
|
|
* LGBMClassifier
|
|
"""
|
|
|
|
def test_lightgbm(self):
|
|
"""Test the basic `shap_values` calculation."""
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train lightgbm model
|
|
X, y = shap.datasets.california(n_points=500)
|
|
dataset = lightgbm.Dataset(data=X, label=y, categorical_feature=[8])
|
|
model = lightgbm.train(
|
|
{
|
|
"objective": "regression",
|
|
"verbosity": -1,
|
|
"num_threads": 1,
|
|
},
|
|
train_set=dataset,
|
|
num_boost_round=1_000,
|
|
)
|
|
predicted = model.predict(X, raw_score=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == (*X.shape,)
|
|
assert explanation.base_values.shape == (len(X),)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
def test_lightgbm_constant_prediction(self):
|
|
# note: this test used to fail with lightgbm 2.2.1 with error:
|
|
# ValueError: zero-size array to reduction operation maximum which has no identity
|
|
# on TreeExplainer when trying to compute max nodes:
|
|
# max_nodes = np.max([len(t.values) for t in self.trees])
|
|
# The test does not fail with latest lightgbm 2.2.3 however
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train lightgbm model with a constant value for y
|
|
X, y = shap.datasets.california(n_points=500)
|
|
# use the mean for all values
|
|
y.fill(np.mean(y))
|
|
dataset = lightgbm.Dataset(data=X, label=y, categorical_feature=[8])
|
|
model = lightgbm.train(
|
|
{"objective": "regression", "verbosity": -1, "num_threads": 1}, train_set=dataset, num_boost_round=1000
|
|
)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
shap.TreeExplainer(model).shap_values(X)
|
|
|
|
def test_lightgbm_binary(self):
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train lightgbm model
|
|
X_train, X_test, Y_train, _ = sklearn.model_selection.train_test_split(
|
|
*shap.datasets.adult(n_points=500),
|
|
test_size=0.2,
|
|
random_state=0,
|
|
)
|
|
dataset = lightgbm.Dataset(data=X_train, label=Y_train)
|
|
model = lightgbm.train(
|
|
{
|
|
"objective": "binary",
|
|
"verbosity": -1,
|
|
"num_threads": 1,
|
|
},
|
|
train_set=dataset,
|
|
num_boost_round=1_000,
|
|
)
|
|
predicted = model.predict(X_test, raw_score=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
shap_values = explainer.shap_values(X_test)
|
|
# validate structure of shap values, must be a list of ndarray for both classes
|
|
assert isinstance(shap_values, np.ndarray)
|
|
assert shap_values.shape == X_test.shape
|
|
|
|
explanation = explainer(X_test)
|
|
# check the properties of Explanation object
|
|
assert explanation.values.shape == X_test.shape
|
|
assert explanation.base_values.shape == (len(X_test),)
|
|
|
|
# check that SHAP values sum to model output
|
|
np.allclose(explanation.values.sum(1) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
# ensure plot works for first class
|
|
shap.dependence_plot(0, shap_values, X_test, show=False)
|
|
|
|
def test_lightgbm_constant_multiclass(self):
|
|
# note: this test used to fail with lightgbm 2.2.1 with error:
|
|
# ValueError: zero-size array to reduction operation maximum which has no identity
|
|
# on TreeExplainer when trying to compute max nodes:
|
|
# max_nodes = np.max([len(t.values) for t in self.trees])
|
|
# The test does not fail with latest lightgbm 2.2.3 however
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train lightgbm model
|
|
X, Y = shap.datasets.iris()
|
|
Y.fill(1)
|
|
model = lightgbm.LGBMClassifier(
|
|
n_estimators=50,
|
|
num_classes=3,
|
|
objective="multiclass",
|
|
n_jobs=1,
|
|
)
|
|
model.fit(X, Y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
shap.TreeExplainer(model).shap_values(X)
|
|
|
|
def test_lightgbm_multiclass(self):
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train lightgbm model
|
|
X, Y = shap.datasets.iris()
|
|
model = lightgbm.LGBMClassifier(n_jobs=1)
|
|
model.fit(X, Y)
|
|
predicted = model.predict(X, raw_score=True)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
explanation = explainer(X)
|
|
# check the properties of Explanation object
|
|
num_classes = 3
|
|
assert explanation.values.shape == (*X.shape, num_classes)
|
|
assert explanation.base_values.shape == (len(X), num_classes)
|
|
|
|
# check that SHAP values sum to model output
|
|
assert np.abs(explanation.values.sum(1) + explanation.base_values - predicted).max() < 1e-4
|
|
|
|
# def test_lightgbm_ranking(self):
|
|
# try:
|
|
# import lightgbm
|
|
# except Exception:
|
|
# print("Skipping test_lightgbm_ranking!")
|
|
# return
|
|
#
|
|
# # train lightgbm ranker model
|
|
# x_train, y_train, x_test, y_test, q_train, q_test = shap.datasets.rank()
|
|
# model = lightgbm.LGBMRanker()
|
|
# model.fit(
|
|
# x_train, y_train, group=q_train,
|
|
# eval_set=[(x_test, y_test)],
|
|
# eval_group=[q_test],
|
|
# eval_at=[1, 3],
|
|
# early_stopping_rounds=5,
|
|
# verbose=False,
|
|
# callbacks=[lightgbm.reset_parameter(learning_rate=lambda x: 0.95 ** x * 0.1)],
|
|
# )
|
|
# _validate_shap_values(model, x_test)
|
|
|
|
def test_lightgbm_interaction(self):
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# train LightGBM model
|
|
X, y = shap.datasets.california(n_points=50)
|
|
model = lightgbm.LGBMRegressor(n_estimators=20, n_jobs=1)
|
|
model.fit(X, y)
|
|
|
|
# verify symmetry of the interaction values (this typically breaks if anything is wrong)
|
|
interaction_vals = shap.TreeExplainer(model).shap_interaction_values(X)
|
|
interaction_vals_swapped = np.swapaxes(np.copy(interaction_vals), 1, 2)
|
|
assert np.allclose(interaction_vals, interaction_vals_swapped, atol=1e-4)
|
|
|
|
# verify output matches shap values for a single observation
|
|
ex = shap.TreeExplainer(model)
|
|
|
|
interaction_vals = ex(X.iloc[0, :], interactions=True) # type: ignore[assignment]
|
|
prediction = model.predict(X.iloc[[0], :], raw_score=True)
|
|
np.testing.assert_allclose(
|
|
interaction_vals.values.sum((0, 1)) + interaction_vals.base_values[0], # type: ignore[attr-defined]
|
|
prediction[0],
|
|
atol=1e-4,
|
|
)
|
|
|
|
def test_lightgbm_call_explanation(self):
|
|
"""Checks that __call__ runs without error and returns a valid Explanation object.
|
|
|
|
Related to GH dsgibbons#66.
|
|
"""
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
# NOTE: the categorical column is necessary for testing GH dsgibbons#66.
|
|
X, y = shap.datasets.adult(n_points=300)
|
|
X["categ"] = pd.Categorical(
|
|
[p for p in ("M", "F") for _ in range(150)],
|
|
ordered=False,
|
|
)
|
|
model = lightgbm.LGBMClassifier(n_estimators=7, n_jobs=1)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
explanation = explainer(X)
|
|
|
|
shap_values: list[np.ndarray] = explainer.shap_values(X) # type: ignore[assignment]
|
|
|
|
# checks that the call returns a valid Explanation object
|
|
assert len(explanation.base_values) == len(y)
|
|
assert isinstance(explanation.values, np.ndarray)
|
|
assert isinstance(shap_values, np.ndarray)
|
|
assert (explanation.values == shap_values).all()
|
|
|
|
def test_lightgbm_categorical_split(self):
|
|
# GH 480
|
|
"""Checks that shap interaction values are computed without error when the LightGBM model has categorical splits."""
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
X, y = shap.datasets.california(n_points=10000)
|
|
# Add HouseAgeGroup categorical variable
|
|
target_variable = "HouseAge"
|
|
X["HouseAgeGroup"] = pd.cut(
|
|
X[target_variable],
|
|
bins=[-float("inf"), 17, 27, 37, float("inf")],
|
|
labels=[0, 1, 2, 3],
|
|
right=False,
|
|
).astype(int)
|
|
model = lightgbm.LGBMRegressor(n_estimators=400, max_cat_to_onehot=1)
|
|
model.fit(
|
|
X, y, categorical_feature=[X.columns.get_loc("HouseAgeGroup")]
|
|
) # Set HouseAgeGroup as categorical variable
|
|
preds = model.predict(X, raw_score=True)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Check SHAP interaction values sum to model output
|
|
shap_interaction_values = explainer.shap_interaction_values(X.iloc[:10, :])
|
|
assert np.allclose(shap_interaction_values.sum(axis=(1, 2)) + explainer.expected_value, preds[:10], atol=1e-4)
|
|
|
|
shap_values = explainer.shap_values(X.iloc[:10, :])
|
|
assert np.allclose(shap_values.sum(axis=1) + explainer.expected_value, preds[:10], atol=1e-4)
|
|
|
|
|
|
def test_check_consistent_outputs_binary_classification():
|
|
# GH 3187
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
catboost = pytest.importorskip("catboost")
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.adult(n_points=50)
|
|
|
|
lgbm = lightgbm.LGBMClassifier(max_depth=1).fit(X, y)
|
|
xgb = xgboost.XGBClassifier(max_depth=1).fit(X, y)
|
|
cat = catboost.CatBoostClassifier(depth=1, iterations=10).fit(X, y)
|
|
rfc = sklearn.ensemble.RandomForestClassifier(n_estimators=10).fit(X, y)
|
|
|
|
ex_lgbm = shap.TreeExplainer(lgbm)
|
|
ex_xgb = shap.TreeExplainer(xgb)
|
|
ex_cat = shap.TreeExplainer(cat)
|
|
ex_rfc = shap.TreeExplainer(rfc)
|
|
|
|
# random forest explanations
|
|
e_rfc_bin = ex_rfc(X, interactions=False)
|
|
e_rfc = ex_rfc(X, interactions=True)
|
|
# we use here predict proba since it is the only way to get the probabilities
|
|
rfc_pred = rfc.predict_proba(X)
|
|
|
|
# lightgbm explanations
|
|
e_lgbm_bin = ex_lgbm(X, interactions=False)
|
|
e_lgbm = ex_lgbm(X, interactions=True)
|
|
lgbm_pred = lgbm.predict_proba(X, raw_score=True)
|
|
|
|
# xgboost explanations
|
|
e_xgb_bin = ex_xgb(X, interactions=False)
|
|
e_xgb = ex_xgb(X, interactions=True)
|
|
xgb_pred = xgb.predict(X, output_margin=True)
|
|
|
|
# catboost explanations
|
|
e_cat_bin = ex_cat(X, interactions=False)
|
|
e_cat = ex_cat(X, interactions=True)
|
|
cat_pred = cat.predict(X, prediction_type="RawFormulaVal")
|
|
|
|
for output in [e_lgbm_bin, e_xgb_bin, e_cat_bin]:
|
|
assert output.shape == X.shape
|
|
# Since random forest classifiers have one dimension for each class, we have one output dimension per class
|
|
assert e_rfc_bin.shape == (X.shape[0], X.shape[1], ex_rfc.model.num_outputs) # shape: examples x features x classes
|
|
|
|
for output in [e_lgbm, e_xgb, e_cat]:
|
|
assert output.shape == (X.shape[0], X.shape[1], X.shape[1])
|
|
|
|
assert e_rfc.shape == (X.shape[0], X.shape[1], X.shape[1], ex_rfc.model.num_outputs)
|
|
|
|
# Sum interaction values
|
|
for explanation, predicted in [(e_xgb, xgb_pred), (e_cat, cat_pred), (e_rfc, rfc_pred), (e_lgbm, lgbm_pred)]:
|
|
assert np.allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
# Sum binary values
|
|
for explanation, predicted in [
|
|
(e_xgb_bin, xgb_pred),
|
|
(e_cat_bin, cat_pred),
|
|
(e_rfc_bin, rfc_pred),
|
|
(e_lgbm_bin, lgbm_pred),
|
|
]:
|
|
assert np.allclose(explanation.values.sum(1) + explanation.base_values, predicted, atol=1e-4)
|
|
|
|
|
|
# todo: multi class classification + multi class regression tests
|
|
# todo: test binary classification with model_output="predict_proba"
|
|
|
|
|
|
def test_check_consistent_outputs_for_regression():
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
catboost = pytest.importorskip("catboost")
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = shap.datasets.california(n_points=50)
|
|
|
|
lgbm = lightgbm.LGBMRegressor(max_depth=1).fit(X, y)
|
|
xgb = xgboost.XGBRegressor(max_depth=1).fit(X, y)
|
|
cat = catboost.CatBoostRegressor(depth=1, iterations=10).fit(X, y)
|
|
rfc = sklearn.ensemble.RandomForestRegressor(n_estimators=10).fit(X, y)
|
|
|
|
ex_lgbm = shap.TreeExplainer(lgbm)
|
|
ex_xgb = shap.TreeExplainer(xgb)
|
|
ex_cat = shap.TreeExplainer(cat)
|
|
ex_rfc = shap.TreeExplainer(rfc)
|
|
|
|
# lightgbm explanations
|
|
e_lgbm_bin = ex_lgbm(X, interactions=False)
|
|
e_lgbm = ex_lgbm(X, interactions=True)
|
|
lgbm_pred = lgbm.predict(X, raw_score=True)
|
|
|
|
# xgboost explanations
|
|
e_xgb_bin = ex_xgb(X, interactions=False)
|
|
e_xgb = ex_xgb(X, interactions=True)
|
|
xgb_pred = xgb.predict(X)
|
|
|
|
# random forest explanations
|
|
e_rfc_bin = ex_rfc(X, interactions=False)
|
|
e_rfc = ex_rfc(X, interactions=True)
|
|
rfc_pred = rfc.predict(X)
|
|
|
|
# catboost
|
|
e_cat_bin = ex_cat(X, interactions=False)
|
|
e_cat = ex_cat(X, interactions=True)
|
|
cat_pred = cat.predict(X, prediction_type="RawFormulaVal")
|
|
|
|
assert (50, 8) == e_lgbm_bin.shape == e_xgb_bin.shape == e_rfc_bin.shape, (
|
|
f"LightGBM: {e_lgbm_bin.shape}, XGBoost: {e_xgb_bin.shape}, RandomForest: {e_rfc_bin.shape}"
|
|
)
|
|
assert (50, 8, 8) == e_lgbm.shape == e_xgb.shape == e_rfc.shape, (
|
|
f"Interactions LightGBM: {e_lgbm.shape}, XGBoost: {e_xgb.shape}, RandomForest: {e_rfc.shape}"
|
|
)
|
|
for outputs, pred in [(e_lgbm_bin, lgbm_pred), (e_xgb_bin, xgb_pred), (e_rfc_bin, rfc_pred), (e_cat_bin, cat_pred)]:
|
|
assert np.allclose(outputs.values.sum(1) + outputs.base_values, pred, atol=1e-4)
|
|
for outputs, pred in [(e_lgbm, lgbm_pred), (e_xgb, xgb_pred), (e_rfc, rfc_pred), (e_cat, cat_pred)]:
|
|
assert np.allclose(outputs.values.sum((1, 2)) + outputs.base_values, pred, atol=1e-4)
|
|
|
|
|
|
def test_catboost_regression_interactions():
|
|
catboost = pytest.importorskip("catboost")
|
|
|
|
X, y = shap.datasets.california(n_points=50)
|
|
model = catboost.CatBoostRegressor(depth=1, iterations=10).fit(X, y)
|
|
ex_cat = shap.TreeExplainer(model)
|
|
predicted = model.predict(X, prediction_type="RawFormulaVal")
|
|
explanation = ex_cat(X, interactions=False)
|
|
assert np.allclose(explanation.values.sum(1) + explanation.base_values, predicted)
|
|
|
|
explanation = ex_cat(X, interactions=True)
|
|
assert np.allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted)
|
|
|
|
|
|
def test_lightgbm_interactions():
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
X, y = sklearn.datasets.load_digits(return_X_y=True)
|
|
|
|
model = lightgbm.LGBMClassifier(n_estimators=10, max_depth=3).fit(X, y)
|
|
explainer = shap.TreeExplainer(model)
|
|
predicted = model.predict(pd.DataFrame(X, columns=model.feature_names_in_), raw_score=True)
|
|
explanation = explainer(X, interactions=False)
|
|
np.testing.assert_allclose(explanation.values.sum(axis=(1)) + explanation.base_values, predicted)
|
|
|
|
explanation = explainer(X, interactions=True)
|
|
np.testing.assert_allclose(explanation.values.sum(axis=(1, 2)) + explanation.base_values, predicted)
|
|
|
|
# test flat input
|
|
explanation_flat = explainer(X[0, :], interactions=True)
|
|
predicted_flat = model.predict(pd.DataFrame(X[[0], :], columns=model.feature_names_in_), raw_score=True)
|
|
|
|
np.testing.assert_allclose(
|
|
explanation_flat.values.sum((0, 1)) + explanation_flat.base_values[0], predicted_flat[0], atol=1e-4
|
|
)
|
|
|
|
|
|
def test_catboost_column_names_with_special_characters():
|
|
# GH #3475
|
|
catboost = pytest.importorskip("catboost")
|
|
# Seed
|
|
np.random.seed(42)
|
|
|
|
# Simulate a dataset
|
|
x_train = pd.DataFrame(
|
|
{
|
|
"x5=ROMÁNIA": np.random.choice([0, 1], size=10),
|
|
}
|
|
)
|
|
|
|
y_train = np.random.choice([0, 1], size=10)
|
|
# Fit a CatBoostClassifier
|
|
cb_best = catboost.CatBoostClassifier(random_state=42, allow_writing_files=False, iterations=3, depth=1)
|
|
cb_best.fit(x_train, y_train)
|
|
|
|
# Create a SHAP TreeExplainer
|
|
explainer = shap.TreeExplainer(
|
|
cb_best, data=x_train, model_output="probability", feature_perturbation="interventional"
|
|
)
|
|
shap_values = explainer.shap_values(x_train)
|
|
assert np.allclose(shap_values.sum(1) + explainer.expected_value, cb_best.predict_proba(x_train)[:, 1])
|
|
|
|
|
|
def test_xgboost_tweedie_regression():
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X, y = np.random.randn(100, 5), np.random.exponential(size=100)
|
|
model = xgboost.XGBRegressor(
|
|
objective="reg:tweedie",
|
|
)
|
|
model.fit(X, y)
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
assert np.allclose(shap_values.sum(1) + explainer.expected_value, np.log(model.predict(X)), atol=1e-4)
|
|
|
|
|
|
def test_xgboost_dart_regression():
|
|
"""GH #3665"""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
model = xgboost.XGBRegressor(booster="dart")
|
|
X = np.random.rand(10, 5)
|
|
label = np.array([0] * 5 + [1] * 5)
|
|
model.fit(X, label)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.allclose(shap_values.sum(1) + explainer.expected_value, model.predict(X), atol=1e-4)
|
|
|
|
|
|
def test_feature_perturbation_refactoring():
|
|
X, y = sklearn.datasets.make_regression(n_samples=100, n_features=10, random_state=0)
|
|
model = sklearn.ensemble.RandomForestRegressor().fit(X, y)
|
|
|
|
# check the behaviour of "auto" and the switch from "interventional" to "tree_path_dependent"
|
|
feature_perturbation = "auto"
|
|
explainer = shap.explainers.Tree(model, feature_perturbation=feature_perturbation) # type: ignore[arg-type]
|
|
assert explainer.feature_perturbation == "tree_path_dependent"
|
|
|
|
explainer = shap.explainers.Tree(model, data=X, feature_perturbation=feature_perturbation) # type: ignore[arg-type]
|
|
assert explainer.feature_perturbation == "interventional"
|
|
|
|
# check that we raise a FutureWarning when switching "interventional" to "tree_path_dependent"
|
|
feature_perturbation = "interventional"
|
|
warn_msg = "In the future, passing feature_perturbation='interventional'"
|
|
with pytest.warns(FutureWarning, match=warn_msg):
|
|
explainer = shap.explainers.Tree(model, feature_perturbation=feature_perturbation) # type: ignore[arg-type]
|
|
assert explainer.feature_perturbation == "tree_path_dependent"
|
|
|
|
# raise an error if the option is unknown
|
|
feature_perturbation = "random"
|
|
err_msg = "feature_perturbation must be"
|
|
with pytest.raises(shap.utils._exceptions.InvalidFeaturePerturbationError, match=err_msg):
|
|
explainer = shap.explainers.Tree(model, feature_perturbation=feature_perturbation) # type: ignore[arg-type]
|
|
|
|
|
|
# the expected results can be found in the paper "Consistent Individualized Feature Attribution for Tree Ensembles",
|
|
# https://arxiv.org/abs/1802.03888
|
|
@pytest.mark.parametrize(
|
|
"expected_result, approximate",
|
|
[
|
|
(np.array([[0.0, -20.0], [-40.0, 20.0], [0.0, -20.0], [40.0, 20.0]]), True),
|
|
(np.array([[-10.0, -10.0], [-30.0, 10.0], [10.0, -30.0], [30.0, 30.0]]), False),
|
|
],
|
|
)
|
|
def test_consistency_approximate(expected_result, approximate):
|
|
"""GH #3764.
|
|
Test that the call interface and shap_values interface are consistent when called with `approximate=True`."""
|
|
|
|
dtc = sklearn.tree.DecisionTreeRegressor(max_depth=2)
|
|
arr = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
|
|
target = np.array([0, 0, 0, 80])
|
|
|
|
dtc.fit(arr, target)
|
|
|
|
exp = shap.explainers.TreeExplainer(dtc)
|
|
explanations_call_approx = exp(arr, approximate=approximate)
|
|
explanations_shap_values_approx = exp.shap_values(arr, approximate=approximate)
|
|
np.testing.assert_allclose(explanations_call_approx.values, explanations_shap_values_approx)
|
|
np.testing.assert_allclose(explanations_call_approx.values, expected_result)
|
|
|
|
|
|
@pytest.mark.parametrize("n_rows", [3, 5])
|
|
@pytest.mark.parametrize("n_estimators", [1, 100])
|
|
def test_gh_3948(n_rows, n_estimators):
|
|
rng = np.random.default_rng(0)
|
|
X = rng.integers(low=0, high=2, size=(n_rows, 90_000)).astype(np.float64)
|
|
y = rng.integers(low=0, high=2, size=n_rows)
|
|
clf = sklearn.ensemble.RandomForestClassifier(n_estimators=n_estimators, random_state=0)
|
|
clf.fit(X, y)
|
|
clf.predict_proba(X)
|
|
exp = shap.TreeExplainer(clf, X)
|
|
exp.shap_values(X)
|
|
|
|
|
|
@pytest.fixture
|
|
def model_explainer():
|
|
rng = np.random.default_rng(0)
|
|
X = np.array([[1.0, 1.0, 0.99999], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]])
|
|
y = rng.integers(low=0, high=2, size=len(X))
|
|
clf = sklearn.ensemble.ExtraTreesClassifier(n_estimators=100, random_state=0)
|
|
clf.fit(X, y)
|
|
clf.predict_proba(X)
|
|
exp = shap.TreeExplainer(clf, X)
|
|
return exp
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"phi, model_output",
|
|
[
|
|
(
|
|
[
|
|
np.array([[0.0, 0.0, -0.24750001], [0.0, 0.0, 0.0825], [0.0, 0.0, 0.0825], [0.0, 0.0, 0.0825]]),
|
|
np.array(
|
|
[[0.0, 0.0, 0.24749997], [0.0, 0.0, -0.08249999], [0.0, 0.0, -0.08249999], [0.0, 0.0, -0.08249999]]
|
|
),
|
|
],
|
|
np.array([[0.0, 1.0], [0.33333333, 0.66666667], [0.33333333, 0.66666667], [0.33333333, 0.66666667]]),
|
|
),
|
|
],
|
|
)
|
|
def test_tight_sensitivity_extra(model_explainer, phi, model_output):
|
|
model_explainer.assert_additivity(phi, model_output)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"X, y, expected_shap_values",
|
|
[
|
|
(
|
|
np.array([[1], [None], [np.nan], [float("nan")], [100]]),
|
|
np.array(
|
|
[
|
|
1,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
]
|
|
),
|
|
np.array([4 / 5, -1 / 5, -1 / 5, -1 / 5, -1 / 5]),
|
|
),
|
|
],
|
|
)
|
|
def test_sklearn_tree_explainer_with_missing_values(X, y, expected_shap_values):
|
|
"""Test that TreeExplainer works with scikit-learn trees that handle missing values.
|
|
|
|
This test verifies that SHAP values are computed correctly when using scikit-learn
|
|
trees with missing values (None, NaN), which is supported starting from scikit-learn 1.3.
|
|
"""
|
|
# Train a simple decision tree classifier
|
|
clf = sklearn.tree.DecisionTreeClassifier()
|
|
clf.fit(X, y)
|
|
|
|
# Create explainer and get SHAP values
|
|
explainer = shap.TreeExplainer(clf)
|
|
shap_values = explainer.shap_values(X)[:, :, 1].flatten()
|
|
|
|
# Verify SHAP values match expected values
|
|
np.testing.assert_allclose(shap_values, expected_shap_values)
|
|
|
|
|
|
@pytest.mark.xslow
|
|
def test_overflow_tree_path_dependent():
|
|
"""GH #4002
|
|
Test SHAP values computation for `feature_perturbation='tree_path_dependent'` with large number of features."""
|
|
seed = 0
|
|
n_rows = 2_000
|
|
rng = np.random.default_rng(seed)
|
|
X = rng.integers(low=0, high=2, size=(n_rows, 1_100_000)).astype(np.float64)
|
|
y = rng.integers(low=0, high=2, size=n_rows)
|
|
|
|
clf = sklearn.ensemble.RandomForestClassifier(random_state=seed)
|
|
clf.fit(X, y)
|
|
clf.predict_proba(X)
|
|
exp = shap.Explainer(clf, algorithm="tree", feature_perturbation="tree_path_dependent")
|
|
exp(X)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"n_estimators",
|
|
[
|
|
5,
|
|
],
|
|
)
|
|
def test_check_consistent_outputs_for_causalml_causal_trees(causalml_synth_data, n_estimators, random_seed):
|
|
"""
|
|
Causal trees predict individual treatment effect based on continuous outcomes Y|X,T
|
|
where T is the particular type of treatment. In the basic scenario we have T=0 and T=1.
|
|
|
|
Thus, causal tree terminal nodes separately contain multiple outcomes as conditioned sample means:
|
|
Y_hat|X,T=0 and Y_hat|X,T=1
|
|
in the same manner as sklearn DecisionTreeRegressor with multiple outputs: (n_samples, n_outputs).
|
|
|
|
However, unlike standard regression tree the final output of the predict() method in causal trees is
|
|
the individual treatment effect: Y_hat|X,T=1 - Y_hat|X,T=0 with an option of returning possible outcomes Y_hat|X,T
|
|
|
|
During research, it is important to analyze Y_hat|X,T=t, t={0,1,...t} aside from individual effects estimation.
|
|
That is why we should carefully track the shape of the following arrays along with other checks:
|
|
shap values: (n_observations, n_features, n_outcomes)
|
|
base values: (n_observations, n_outcomes) arrays
|
|
"""
|
|
causalml = pytest.importorskip("causalml")
|
|
|
|
data, n_outcomes = causalml_synth_data
|
|
y, X, treatment, tau, b, e = data
|
|
n_observations, n_features = X.shape
|
|
|
|
ctree = causalml.inference.tree.CausalTreeRegressor(random_state=random_seed)
|
|
ctree.fit(X=X, treatment=treatment, y=y)
|
|
ctree_preds = ctree.predict(X)
|
|
ctree_explainer = shap.TreeExplainer(ctree)
|
|
|
|
cforest = causalml.inference.tree.CausalRandomForestRegressor(n_estimators=n_estimators, random_state=random_seed)
|
|
cforest.fit(X=X, treatment=treatment, y=y)
|
|
cforest_preds = cforest.predict(X)
|
|
cforest_explainer = shap.TreeExplainer(cforest)
|
|
|
|
for explainer, preds in zip([ctree_explainer, cforest_explainer], [ctree_preds, cforest_preds]):
|
|
explanation = explainer(X)
|
|
shap_values = explainer.shap_values(X)
|
|
|
|
assert isinstance(explanation, Explanation)
|
|
assert isinstance(explanation.data, np.ndarray)
|
|
assert isinstance(explanation.base_values, np.ndarray)
|
|
assert isinstance(explanation.values, np.ndarray)
|
|
assert isinstance(shap_values, np.ndarray)
|
|
|
|
# Explanation.values and the output of TreeExplainer.shap_values() are two ways to get shap values
|
|
np.testing.assert_allclose(explanation.values, shap_values)
|
|
np.testing.assert_allclose(explanation.data, X)
|
|
|
|
# Check Explanation class
|
|
assert explanation.data.shape == (n_observations, n_features)
|
|
assert explanation.base_values.shape == (n_observations, n_outcomes)
|
|
assert explanation.values.shape == (n_observations, n_features, n_outcomes)
|
|
|
|
# Check that shap values and base values can be collapsed into
|
|
# model prediction of individual treatment effects
|
|
y_outcomes = explanation.base_values + explanation.values.sum(axis=1)
|
|
individual_effects = y_outcomes[:, 1] - y_outcomes[:, 0]
|
|
|
|
np.testing.assert_allclose(preds, individual_effects, atol=1e-4)
|
|
|
|
|
|
def test_tree_explainer_with_single_tree():
|
|
"""Test TreeExplainer with a single decision tree."""
|
|
# Create synthetic data
|
|
X = np.random.randn(100, 5)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
# Train a single decision tree
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Create explainer
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Get SHAP values
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# Classifiers return shape (n_samples, n_features, n_classes)
|
|
assert shap_values.shape == (10, 5, 2) or shap_values.shape == (10, 5)
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:10])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
# Shape is (n_samples, n_features, n_classes)
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
# Shape is (n_samples, n_features) - already for positive class
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_decision_tree_regressor():
|
|
"""Test TreeExplainer with DecisionTreeRegressor."""
|
|
X = np.random.randn(100, 4)
|
|
y = X[:, 0] * 2 + X[:, 1] - 0.5 * X[:, 2]
|
|
|
|
model = DecisionTreeRegressor(max_depth=4, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:5])
|
|
|
|
assert shap_values.shape == (5, 4)
|
|
|
|
# Check additivity
|
|
predictions = model.predict(X[:5])
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_dataframe():
|
|
"""Test TreeExplainer with pandas DataFrame input."""
|
|
df = pd.DataFrame(np.random.randn(100, 3), columns=["a", "b", "c"])
|
|
y = (df["a"] + df["b"] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(df, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(df[:10])
|
|
|
|
# Classifiers return shape (n_samples, n_features, n_classes)
|
|
assert shap_values.shape == (10, 3, 2) or shap_values.shape == (10, 3)
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(df[:10])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_feature_perturbation_interventional():
|
|
"""Test TreeExplainer with interventional feature perturbation."""
|
|
X = np.random.randn(100, 4)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Explicitly specify interventional
|
|
explainer = shap.TreeExplainer(model, X, feature_perturbation="interventional")
|
|
shap_values = explainer.shap_values(X[:5])
|
|
|
|
assert shap_values.shape == (5, 4, 2) or shap_values.shape == (5, 4)
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:5])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_feature_perturbation_tree_path_dependent():
|
|
"""Test TreeExplainer with tree_path_dependent feature perturbation."""
|
|
X = np.random.randn(100, 4)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model, feature_perturbation="tree_path_dependent")
|
|
shap_values = explainer.shap_values(X[:5])
|
|
|
|
assert shap_values.shape == (5, 4, 2) or shap_values.shape == (5, 4)
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:5])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_random_forest_binary_classification():
|
|
"""Test TreeExplainer with RandomForestClassifier for binary classification."""
|
|
X = np.random.randn(150, 5)
|
|
y = (X[:, 0] + 2 * X[:, 1] - X[:, 2] > 0).astype(int)
|
|
|
|
model = RandomForestClassifier(n_estimators=10, max_depth=4, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# For binary classification, might return list of length 2 or single array
|
|
if isinstance(shap_values, list):
|
|
assert len(shap_values) == 2
|
|
assert shap_values[0].shape == (10, 5)
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:10])[:, 1]
|
|
shap_sum = shap_values[1].sum(1) + explainer.expected_value[1]
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
else:
|
|
# Can be (10, 5) or (10, 5, 2) depending on model
|
|
assert shap_values.shape in [(10, 5), (10, 5, 2)]
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:10])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_gradient_boosting_regressor():
|
|
"""Test TreeExplainer with GradientBoostingRegressor."""
|
|
X = np.random.randn(120, 6)
|
|
y = X[:, 0] ** 2 + X[:, 1] + np.random.randn(120) * 0.1
|
|
|
|
model = GradientBoostingRegressor(n_estimators=20, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:8])
|
|
|
|
assert shap_values.shape == (8, 6)
|
|
|
|
# Check additivity
|
|
predictions = model.predict(X[:8])
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_gradient_boosting_classifier():
|
|
"""Test TreeExplainer with GradientBoostingClassifier."""
|
|
X = np.random.randn(150, 4)
|
|
y = (X[:, 0] + X[:, 1] * 2 > 0.5).astype(int)
|
|
|
|
model = GradientBoostingClassifier(n_estimators=15, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
assert shap_values.shape == (10, 4)
|
|
|
|
# Check additivity (GradientBoostingClassifier uses decision_function for raw output)
|
|
predictions = model.decision_function(X[:10])
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_background_data():
|
|
"""Test TreeExplainer with explicit background data."""
|
|
X = np.random.randn(100, 4)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Use a subset as background
|
|
background = X[:50]
|
|
|
|
explainer = shap.TreeExplainer(model, background)
|
|
shap_values = explainer.shap_values(X[50:60])
|
|
|
|
assert shap_values.shape == (10, 4, 2) or shap_values.shape == (10, 4)
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[50:60])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_check_additivity():
|
|
"""Test that SHAP values sum to prediction - expected_value."""
|
|
X = np.random.randn(50, 3)
|
|
y = X[:, 0] + X[:, 1] - X[:, 2] + np.random.randn(50) * 0.1
|
|
|
|
model = DecisionTreeRegressor(max_depth=4, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# Verify additivity: sum of SHAP values + expected_value ≈ prediction
|
|
predictions = model.predict(X[:10])
|
|
|
|
if isinstance(explainer.expected_value, np.ndarray):
|
|
expected = explainer.expected_value[0]
|
|
else:
|
|
expected = explainer.expected_value
|
|
|
|
shap_sum = shap_values.sum(axis=1) + expected
|
|
|
|
np.testing.assert_allclose(shap_sum, predictions, rtol=1e-3, atol=1e-3)
|
|
|
|
|
|
def test_tree_explainer_single_sample():
|
|
"""Test TreeExplainer with a single sample."""
|
|
X = np.random.randn(100, 4)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Single sample as 1D array
|
|
single_sample = X[0]
|
|
shap_values = explainer.shap_values(single_sample)
|
|
|
|
# Classifier with single sample can have various shapes
|
|
assert shap_values.shape in [(4,), (1, 4), (4, 2), (1, 4, 2)]
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
prediction = model.predict_proba(single_sample.reshape(1, -1))[0, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[0, :, 1].sum() + explainer.expected_value[1]
|
|
elif shap_values.ndim == 2 and shap_values.shape[1] == 2:
|
|
shap_sum = shap_values[:, 1].sum() + explainer.expected_value[1]
|
|
elif shap_values.ndim == 2:
|
|
shap_sum = shap_values[0].sum() + explainer.expected_value
|
|
else:
|
|
shap_sum = shap_values.sum() + explainer.expected_value
|
|
assert abs(shap_sum - prediction) < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_xgboost_basic():
|
|
"""Test TreeExplainer with basic XGBoost model."""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X = np.random.randn(100, 5)
|
|
y = X[:, 0] + 2 * X[:, 1] - X[:, 2] + np.random.randn(100) * 0.1
|
|
|
|
model = xgboost.XGBRegressor(n_estimators=10, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
assert shap_values.shape == (10, 5)
|
|
|
|
# Check additivity
|
|
predictions = model.predict(X[:10])
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_xgboost_classifier():
|
|
"""Test TreeExplainer with XGBoost classifier."""
|
|
xgboost = pytest.importorskip("xgboost")
|
|
|
|
X = np.random.randn(120, 4)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = xgboost.XGBClassifier(n_estimators=15, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
assert shap_values.shape == (10, 4)
|
|
|
|
# Check additivity - XGBoost outputs log-odds for binary classification
|
|
# SHAP values are in log-odds space, so transform probabilities to log-odds
|
|
proba = model.predict_proba(X[:10])[:, 1]
|
|
log_odds = np.log(proba / (1 - proba))
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - log_odds).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_lightgbm_regressor():
|
|
"""Test TreeExplainer with LightGBM regressor."""
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
X = np.random.randn(100, 5)
|
|
y = X[:, 0] + X[:, 1] ** 2 + np.random.randn(100) * 0.1
|
|
|
|
model = lightgbm.LGBMRegressor(n_estimators=10, max_depth=3, random_state=0, verbose=-1)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
assert shap_values.shape == (10, 5)
|
|
|
|
# Check additivity
|
|
predictions = model.predict(pd.DataFrame(X[:10], columns=model.feature_names_in_))
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_lightgbm_classifier():
|
|
"""Test TreeExplainer with LightGBM classifier."""
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
X = np.random.randn(120, 4)
|
|
y = (X[:, 0] - X[:, 1] > 0).astype(int)
|
|
|
|
model = lightgbm.LGBMClassifier(n_estimators=10, max_depth=3, random_state=0, verbose=-1)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# LightGBM binary classifier returns array, not list
|
|
assert shap_values.shape == (10, 4) or (isinstance(shap_values, list) and len(shap_values) == 2)
|
|
|
|
# Check additivity (SHAP values are in raw score space, not probability space)
|
|
predictions = model.predict(pd.DataFrame(X[:10], columns=model.feature_names_in_), raw_score=True)
|
|
if isinstance(shap_values, list):
|
|
shap_sum = shap_values[1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_expected_value():
|
|
"""Test that expected_value is computed correctly."""
|
|
X = np.random.randn(100, 3)
|
|
y = X[:, 0] + X[:, 1]
|
|
|
|
model = DecisionTreeRegressor(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# expected_value should be close to mean prediction on training data
|
|
mean_pred = model.predict(X).mean()
|
|
|
|
# expected_value can be float or array
|
|
if isinstance(explainer.expected_value, np.ndarray):
|
|
assert abs(explainer.expected_value[0] - mean_pred) < 1.0
|
|
else:
|
|
assert isinstance(explainer.expected_value, (float, np.floating))
|
|
assert abs(explainer.expected_value - mean_pred) < 1.0
|
|
|
|
# Check additivity
|
|
shap_values = explainer.shap_values(X[:10])
|
|
predictions = model.predict(X[:10])
|
|
expected = (
|
|
explainer.expected_value[0] if isinstance(explainer.expected_value, np.ndarray) else explainer.expected_value
|
|
)
|
|
assert np.abs(shap_values.sum(1) + expected - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_interactions():
|
|
"""Test TreeExplainer with interaction detection."""
|
|
X = np.random.randn(80, 4)
|
|
# Create interaction between features 0 and 1
|
|
y = X[:, 0] * X[:, 1] + X[:, 2]
|
|
|
|
model = DecisionTreeRegressor(max_depth=5, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Test interactions
|
|
shap_interaction_values = explainer.shap_interaction_values(X[:10])
|
|
|
|
assert shap_interaction_values.shape == (10, 4, 4)
|
|
|
|
# Check additivity for interactions (sum of all interaction values equals main effects)
|
|
predictions = model.predict(X[:10])
|
|
# Sum of all elements in interaction matrix should equal prediction - expected_value
|
|
expected = (
|
|
explainer.expected_value[0] if isinstance(explainer.expected_value, np.ndarray) else explainer.expected_value
|
|
)
|
|
for i in range(10):
|
|
interaction_sum = shap_interaction_values[i].sum()
|
|
assert abs(interaction_sum + expected - predictions[i]) < 1e-4
|
|
|
|
|
|
def test_tree_explainer_output_as_explanation_object():
|
|
"""Test TreeExplainer returning Explanation object."""
|
|
X = np.random.randn(50, 3)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Call explainer directly (should return Explanation object)
|
|
explanation = explainer(X[:5])
|
|
|
|
assert isinstance(explanation, shap.Explanation)
|
|
# Classifiers have extra dimension for classes
|
|
assert explanation.values.shape in [(5, 3), (5, 3, 2)]
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:5])[:, 1]
|
|
if explanation.values.ndim == 3:
|
|
shap_sum = explanation.values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = explanation.values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_model_output_parameter():
|
|
"""Test TreeExplainer with different model_output parameters."""
|
|
X = np.random.randn(80, 3)
|
|
y = (X[:, 0] + X[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Test with model_output="raw"
|
|
explainer_raw = shap.TreeExplainer(model, model_output="raw")
|
|
shap_values_raw = explainer_raw.shap_values(X[:5])
|
|
|
|
# Test with model_output="probability" requires interventional mode with background data
|
|
background = X[:40]
|
|
explainer_prob = shap.TreeExplainer(
|
|
model, background, model_output="probability", feature_perturbation="interventional"
|
|
)
|
|
shap_values_prob = explainer_prob.shap_values(X[:5])
|
|
|
|
# Both should work - classifiers have extra dimension
|
|
assert shap_values_raw.shape in [(5, 3), (5, 3, 2)]
|
|
assert shap_values_prob.shape in [(5, 3), (5, 3, 2)]
|
|
|
|
# Check additivity for raw output
|
|
predictions_raw = model.predict_proba(X[:5])[:, 1]
|
|
if shap_values_raw.ndim == 3:
|
|
shap_sum = shap_values_raw[:, :, 1].sum(1) + explainer_raw.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values_raw.sum(1) + explainer_raw.expected_value
|
|
assert np.abs(shap_sum - predictions_raw).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_different_dtypes():
|
|
"""Test TreeExplainer with different data types."""
|
|
# Test with float32
|
|
X_float32 = np.random.randn(60, 3).astype(np.float32)
|
|
y = (X_float32[:, 0] + X_float32[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=3, random_state=0)
|
|
model.fit(X_float32, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X_float32[:5])
|
|
|
|
assert shap_values.shape in [(5, 3), (5, 3, 2)]
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X_float32[:5])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_sparse_data():
|
|
"""Test TreeExplainer behavior with sparse-like data (many zeros)."""
|
|
X_dense = np.random.randn(80, 5)
|
|
# Make it sparse-like
|
|
X_dense[X_dense < 0.5] = 0
|
|
y = (X_dense[:, 0] + X_dense[:, 1] > 0).astype(int)
|
|
|
|
model = DecisionTreeClassifier(max_depth=4, random_state=0)
|
|
model.fit(X_dense, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X_dense[:10])
|
|
|
|
assert shap_values.shape in [(10, 5), (10, 5, 2)]
|
|
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X_dense[:10])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_approximate():
|
|
"""Test TreeExplainer with approximate=True (Saabas method)."""
|
|
X = np.random.randn(100, 4)
|
|
y = X[:, 0] + X[:, 1] - X[:, 2]
|
|
|
|
model = DecisionTreeRegressor(max_depth=4, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10], approximate=True)
|
|
|
|
assert shap_values.shape == (10, 4)
|
|
|
|
# Note: approximate mode may not be perfectly additive
|
|
predictions = model.predict(X[:10])
|
|
# Use larger tolerance for approximate mode
|
|
assert np.abs(shap_values.sum(1) + explainer.expected_value - predictions).max() < 1e-2
|
|
|
|
|
|
def test_tree_explainer_with_check_additivity_false():
|
|
"""Test TreeExplainer with check_additivity=False."""
|
|
X = np.random.randn(80, 3)
|
|
y = X[:, 0] + X[:, 1]
|
|
|
|
model = DecisionTreeRegressor(max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10], check_additivity=False)
|
|
|
|
assert shap_values.shape == (10, 3)
|
|
|
|
|
|
def test_tree_explainer_with_tree_limit():
|
|
"""Test TreeExplainer with tree_limit parameter."""
|
|
X = np.random.randn(100, 4)
|
|
y = X[:, 0] + X[:, 1]
|
|
|
|
model = GradientBoostingRegressor(n_estimators=20, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Use only first 10 trees (disable additivity check since we're using subset)
|
|
shap_values = explainer.shap_values(X[:5], tree_limit=10, check_additivity=False)
|
|
|
|
assert shap_values.shape == (5, 4)
|
|
|
|
|
|
def test_tree_explainer_multiclass():
|
|
"""Test TreeExplainer with multi-class classification (>2 classes)."""
|
|
X = np.random.randn(150, 4)
|
|
# Create 3 classes
|
|
y = np.zeros(150, dtype=int)
|
|
y[X[:, 0] > 0.5] = 1
|
|
y[X[:, 0] < -0.5] = 2
|
|
|
|
model = DecisionTreeClassifier(max_depth=4, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# Multi-class should return list or 3D array
|
|
if isinstance(shap_values, list):
|
|
assert len(shap_values) == 3
|
|
assert shap_values[0].shape == (10, 4)
|
|
# Check additivity for each class
|
|
predictions = model.predict_proba(X[:10])
|
|
for class_idx in range(3):
|
|
shap_sum = shap_values[class_idx].sum(1) + explainer.expected_value[class_idx]
|
|
assert np.abs(shap_sum - predictions[:, class_idx]).max() < 1e-4
|
|
else:
|
|
assert shap_values.shape in [(10, 4, 3), (10, 4)]
|
|
# Check additivity for at least one class
|
|
predictions = model.predict_proba(X[:10])
|
|
if shap_values.ndim == 3:
|
|
for class_idx in range(3):
|
|
shap_sum = shap_values[:, :, class_idx].sum(1) + explainer.expected_value[class_idx]
|
|
assert np.abs(shap_sum - predictions[:, class_idx]).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_with_pandas_series():
|
|
"""Test TreeExplainer with pandas Series input."""
|
|
df = pd.DataFrame(np.random.randn(100, 4), columns=["a", "b", "c", "d"])
|
|
y = df["a"] + df["b"]
|
|
|
|
model = DecisionTreeRegressor(max_depth=3, random_state=0)
|
|
model.fit(df, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
|
|
# Test with single row as Series
|
|
single_row = df.iloc[0]
|
|
shap_values = explainer.shap_values(single_row)
|
|
|
|
# Single sample can have various shapes
|
|
assert shap_values.shape in [(4,), (1, 4)]
|
|
|
|
# Check additivity for single sample
|
|
prediction = model.predict(single_row.to_frame().T)[0]
|
|
shap_sum = shap_values.sum() if shap_values.ndim == 1 else shap_values.sum(1)[0]
|
|
assert abs(shap_sum + explainer.expected_value - prediction) < 1e-4
|
|
|
|
|
|
def test_tree_explainer_random_forest_multiclass():
|
|
"""Test TreeExplainer with RandomForestClassifier multi-class."""
|
|
X = np.random.randn(150, 4)
|
|
# Create 3 classes
|
|
y = np.zeros(150, dtype=int)
|
|
y[X[:, 0] > 0.3] = 1
|
|
y[X[:, 0] < -0.3] = 2
|
|
|
|
model = RandomForestClassifier(n_estimators=10, max_depth=3, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# Should handle multi-class output
|
|
if isinstance(shap_values, list):
|
|
assert len(shap_values) == 3
|
|
# Check additivity for each class
|
|
predictions = model.predict_proba(X[:10])
|
|
for class_idx in range(3):
|
|
shap_sum = shap_values[class_idx].sum(1) + explainer.expected_value[class_idx]
|
|
assert np.abs(shap_sum - predictions[:, class_idx]).max() < 1e-4
|
|
else:
|
|
assert shap_values.shape in [(10, 4), (10, 4, 3)]
|
|
# Check additivity for at least one class
|
|
if shap_values.ndim == 3:
|
|
predictions = model.predict_proba(X[:10])
|
|
for class_idx in range(3):
|
|
shap_sum = shap_values[:, :, class_idx].sum(1) + explainer.expected_value[class_idx]
|
|
assert np.abs(shap_sum - predictions[:, class_idx]).max() < 1e-4
|
|
|
|
|
|
def test_tree_explainer_random_forest_regressor():
|
|
"""Test TreeExplainer with RandomForestRegressor."""
|
|
X = np.random.randn(100, 5)
|
|
y = X[:, 0] ** 2 + X[:, 1] - 0.5 * X[:, 2]
|
|
|
|
model = RandomForestClassifier(n_estimators=10, max_depth=4, random_state=0)
|
|
model.fit(X, (y > 0).astype(int))
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
shap_values = explainer.shap_values(X[:10])
|
|
|
|
# Verify shape is correct
|
|
if isinstance(shap_values, list):
|
|
assert len(shap_values) == 2
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:10])[:, 1]
|
|
shap_sum = shap_values[1].sum(1) + explainer.expected_value[1]
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
else:
|
|
assert shap_values.shape in [(10, 5), (10, 5, 2)]
|
|
# Check additivity for class 1 (positive class)
|
|
predictions = model.predict_proba(X[:10])[:, 1]
|
|
if shap_values.ndim == 3:
|
|
shap_sum = shap_values[:, :, 1].sum(1) + explainer.expected_value[1]
|
|
else:
|
|
shap_sum = shap_values.sum(1) + explainer.expected_value
|
|
assert np.abs(shap_sum - predictions).max() < 1e-4
|
|
|
|
|
|
def test_path_dependent_small_background():
|
|
"""Path-dependent SHAP with small background that has uncovered leaves.
|
|
|
|
LightGBM multiclass on iris with 20-sample background deterministically
|
|
produces zero-weight leaves. Without the epsilon fix, unwind_path()
|
|
divides by zero, producing NaN. Addresses #3574.
|
|
"""
|
|
from shap.explainers._tree import SingleTree, TreeEnsemble
|
|
|
|
lightgbm = pytest.importorskip("lightgbm")
|
|
|
|
X, y = sklearn.datasets.load_iris(return_X_y=True)
|
|
model = lightgbm.LGBMClassifier(n_estimators=10, num_leaves=8, verbose=-1, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
bg = X[:20] # small background — guarantees uncovered leaves
|
|
explainer = shap.TreeExplainer(model, data=bg, feature_perturbation="tree_path_dependent")
|
|
|
|
assert isinstance(explainer.model, TreeEnsemble) # make mypy happy
|
|
assert isinstance(explainer.model.trees, list) # make mypy happy
|
|
assert all(isinstance(t, SingleTree) for t in explainer.model.trees) # make mypy happy
|
|
# Confirm zero-weight nodes were present and got epsilon-replaced
|
|
assert any(np.any(t.node_sample_weight == 1e-6) for t in explainer.model.trees)
|
|
|
|
sv = explainer.shap_values(X[:5], check_additivity=False)
|
|
assert not np.any(np.isnan(sv)), "SHAP values contain NaN"
|
|
|
|
# Additivity
|
|
pred = explainer.model.predict(X[:5])
|
|
assert isinstance(pred, np.ndarray) # make mypy happy
|
|
for c in range(3):
|
|
shap_sum = explainer.expected_value[c] + sv[:, :, c].sum(axis=1)
|
|
np.testing.assert_allclose(shap_sum, pred[:, c], atol=1e-6)
|
|
|
|
|
|
def test_nullable_pandas_dtype():
|
|
"""TreeExplainer handles pandas nullable dtypes (Int64, Float64) with NA values.
|
|
|
|
Previously, DataFrame.values on nullable dtypes produced object arrays,
|
|
and astype(np.float32) failed on pd.NA with:
|
|
TypeError: float() argument must be a string or a real number, not 'NAType'
|
|
Addresses #4011.
|
|
"""
|
|
X = pd.DataFrame(
|
|
{
|
|
"x1": pd.array([1.0, 2.0, 3.0, 4.0, 5.0] * 20, dtype="Float64"),
|
|
"x2": pd.array([10, 20, 30, 40, 50] * 20, dtype="Int64"),
|
|
}
|
|
)
|
|
y = np.array([0, 1, 0, 1, 0] * 20)
|
|
|
|
model = DecisionTreeClassifier(max_depth=2, random_state=0)
|
|
model.fit(X, y)
|
|
|
|
# Introduce NA values in test data
|
|
X_test = X.iloc[:5].copy()
|
|
X_test.iloc[2, 0] = pd.NA
|
|
X_test.iloc[3, 1] = pd.NA
|
|
|
|
# Confirm nullable dtypes are present (precondition)
|
|
assert X_test["x1"].dtype == pd.Float64Dtype()
|
|
assert X_test["x2"].dtype == pd.Int64Dtype()
|
|
|
|
explainer = shap.TreeExplainer(model)
|
|
sv = explainer.shap_values(X_test)
|
|
assert not np.any(np.isnan(sv[~np.isnan(X_test.to_numpy(dtype=float, na_value=np.nan)).any(axis=1)]))
|