Files
2026-07-13 13:22:52 +08:00

183 lines
6.6 KiB
Python

"""Unit tests for the Exact explainer."""
import pickle
import numpy as np
from conftest import compare_numpy_outputs_against_baseline
import shap
from . import common
def test_tabular_simple_case():
import pytest
xgboost = pytest.importorskip("xgboost")
sk = pytest.importorskip("sklearn")
model = xgboost.XGBClassifier(tree_method="exact", base_score=0.5)
X, y = sk.datasets.make_classification(n_samples=100, n_features=2, n_informative=2, n_redundant=0, return_X_y=True)
X_train = X[:80]
X_test = X[80:]
y_train = y[:80]
model.fit(X_train, y_train)
ex = shap.explainers.ExactExplainer(model.predict_proba, X_train)
shap_values = ex(X_test)
pred = model.predict_proba(X_test)
# check additivity
np.testing.assert_allclose(shap_values.base_values + shap_values.values.sum(axis=1), pred, atol=1e-6)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_interactions():
model, data = common.basic_xgboost_scenario(100)
return common.test_interactions_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_single_output_auto_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_single_output_auto_masker_single_value():
# This currently fails with an MemoryError, I assume due to having a different dimension than required!
model, data = common.basic_xgboost_scenario(1)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_single_output_auto_masker_minimal():
model, data = common.basic_xgboost_scenario(2)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_multi_output_auto_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict_proba, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_single_output_partition_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, shap.maskers.Partition(data), data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_multi_output_partition_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(
shap.explainers.ExactExplainer, model.predict_proba, shap.maskers.Partition(data), data
)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_single_output_independent_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(shap.explainers.ExactExplainer, model.predict, shap.maskers.Independent(data), data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_tabular_multi_output_independent_masker():
model, data = common.basic_xgboost_scenario(100)
return common.test_additivity(
shap.explainers.ExactExplainer, model.predict_proba, shap.maskers.Independent(data), data
)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_serialization():
model, data = common.basic_xgboost_scenario()
return common.test_serialization(shap.explainers.ExactExplainer, model.predict, data, data)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_serialization_no_model_or_masker():
model, data = common.basic_xgboost_scenario()
return common.test_serialization(
shap.explainers.ExactExplainer,
model.predict,
data,
data,
model_saver=False,
masker_saver=False,
model_loader=lambda _: model.predict,
masker_loader=lambda _: data,
)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_serialization_no_model_or_masker_reduced():
import pytest
X, y = shap.datasets.adult()
X = X.iloc[:, :3]
xgboost = pytest.importorskip("xgboost")
data = X
model = xgboost.XGBClassifier(tree_method="exact", base_score=0.5, seed=42)
model.fit(X, y)
return common.test_serialization(
shap.explainers.ExactExplainer,
model.predict,
data,
data,
model_saver=False,
masker_saver=False,
model_loader=lambda _: model.predict,
masker_loader=lambda _: data,
)
@compare_numpy_outputs_against_baseline(func_file=__file__)
def test_serialization_custom_model_save():
model, data = common.basic_xgboost_scenario()
return common.test_serialization(
shap.explainers.ExactExplainer, model.predict, data, data, model_saver=pickle.dump, model_loader=pickle.load
)
def test_multi_output_with_non_varying_features():
"""Test 2D code path when some features don't vary from background.
This reproduces a bug in compute_grey_code_row_values_2d where the inner
loop iterates over rv.shape(0) and indexes rv(rvi, ...) instead of
iterating over inds.shape(0) and indexing rv(inds(rvi), ...).
The bug is invisible when all features vary (inds == [0,1,...,M-1]),
but causes wrong results when only a subset varies.
"""
# 4 features, multi-output model
# Background: single sample so we can control exactly which features vary
background = np.array([[0.0, 1.0, 2.0, 3.0]])
# Simple linear multi-output model: returns [sum_of_features, 2*sum_of_features]
def model(X):
s = X.sum(axis=1)
return np.column_stack([s, 2 * s])
# Test sample: features 0 and 2 match the background, features 1 and 3 differ
# So inds should be [1, 3] (only 2 of 4 features vary)
test_x = np.array([[0.0, 5.0, 2.0, 7.0]])
explainer = shap.explainers.ExactExplainer(model, background)
shap_values = explainer(test_x)
# Additivity check: base_values + sum(shap_values) == model prediction
pred = model(test_x)
reconstructed = shap_values.base_values + shap_values.values.sum(axis=1)
np.testing.assert_allclose(reconstructed, pred, atol=1e-10)
# Non-varying features (0 and 2) should have zero SHAP values
np.testing.assert_allclose(shap_values.values[0, 0, :], 0.0, atol=1e-10)
np.testing.assert_allclose(shap_values.values[0, 2, :], 0.0, atol=1e-10)
# Varying features (1 and 3) should have non-zero SHAP values
assert np.any(np.abs(shap_values.values[0, 1, :]) > 1e-10)
assert np.any(np.abs(shap_values.values[0, 3, :]) > 1e-10)