294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Unit tests for the Linear explainer."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import scipy.special
|
|
from sklearn.datasets import make_multilabel_classification
|
|
from sklearn.linear_model import LogisticRegression, Ridge
|
|
|
|
import shap
|
|
import shap.maskers
|
|
from shap import maskers
|
|
from shap.utils._exceptions import InvalidFeaturePerturbationError
|
|
|
|
|
|
def test_tied_pair():
|
|
beta = np.array([1, 0, 0])
|
|
mu = np.zeros(3)
|
|
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
|
X = np.ones((1, 3))
|
|
masker = maskers.Impute({"mean": mu, "cov": Sigma})
|
|
explainer = shap.LinearExplainer((beta, 0), masker)
|
|
assert np.abs(explainer.shap_values(X) - np.array([0.5, 0.5, 0])).max() < 0.05
|
|
|
|
|
|
def test_tied_pair_independent():
|
|
beta = np.array([1, 0, 0])
|
|
mu = np.zeros(3)
|
|
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
|
X = np.ones((1, 3))
|
|
masker = maskers.Independent({"mean": mu, "cov": Sigma})
|
|
explainer = shap.LinearExplainer((beta, 0), masker)
|
|
assert np.abs(explainer.shap_values(X) - np.array([1, 0, 0])).max() < 0.05
|
|
|
|
|
|
def test_tied_pair_new():
|
|
beta = np.array([1, 0, 0])
|
|
mu = np.zeros(3)
|
|
Sigma = np.array([[1, 0.999999, 0], [0.999999, 1, 0], [0, 0, 1]])
|
|
X = np.ones((1, 3))
|
|
explainer = shap.explainers.LinearExplainer((beta, 0), shap.maskers.Impute({"mean": mu, "cov": Sigma}))
|
|
assert np.abs(explainer.shap_values(X) - np.array([0.5, 0.5, 0])).max() < 0.05
|
|
|
|
|
|
def test_wrong_masker():
|
|
with pytest.raises(NotImplementedError):
|
|
shap.explainers.LinearExplainer((0, 0), shap.maskers.Fixed())
|
|
|
|
|
|
def test_tied_triple():
|
|
beta = np.array([0, 1, 0, 0])
|
|
mu = 1 * np.ones(4)
|
|
Sigma = np.array([[1, 0.999999, 0.999999, 0], [0.999999, 1, 0.999999, 0], [0.999999, 0.999999, 1, 0], [0, 0, 0, 1]])
|
|
X = 2 * np.ones((1, 4))
|
|
masker = maskers.Impute({"mean": mu, "cov": Sigma})
|
|
explainer = shap.LinearExplainer((beta, 0), masker)
|
|
assert explainer.expected_value == 1
|
|
assert np.abs(explainer.shap_values(X) - np.array([0.33333, 0.33333, 0.33333, 0])).max() < 0.05
|
|
|
|
|
|
def test_sklearn_linear():
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X)
|
|
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
|
explainer.shap_values(X)
|
|
|
|
|
|
def test_sklearn_linear_old_style():
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, maskers.Independent(X))
|
|
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
|
explainer.shap_values(X)
|
|
|
|
|
|
def test_sklearn_linear_new():
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.explainers.LinearExplainer(model, X)
|
|
shap_values = explainer(X)
|
|
assert np.abs(shap_values.values.sum(1) + shap_values.base_values - model.predict(X)).max() < 1e-6 # type: ignore[union-attr, union-attr]
|
|
assert np.abs(shap_values.base_values[0] - model.predict(X).mean()) < 1e-6 # type: ignore[union-attr]
|
|
|
|
|
|
def test_sklearn_multiclass_no_intercept():
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
|
|
# make y multiclass
|
|
multiclass_y = np.expand_dims(y, axis=-1)
|
|
model = Ridge(fit_intercept=False)
|
|
model.fit(X, multiclass_y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X)
|
|
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
|
explainer.shap_values(X)
|
|
|
|
|
|
def test_perfect_colinear():
|
|
LinearRegression = pytest.importorskip("sklearn.linear_model").LinearRegression
|
|
|
|
X, y = shap.datasets.california(n_points=100)
|
|
X.iloc[:, 0] = X.iloc[:, 4] # test duplicated features
|
|
X.iloc[:, 5] = X.iloc[:, 6] - X.iloc[:, 6] # test multiple colinear features
|
|
X.iloc[:, 3] = 0 # test null features
|
|
model = LinearRegression()
|
|
model.fit(X, y)
|
|
explainer = shap.LinearExplainer(model, maskers.Impute(X))
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.abs(shap_values.sum(1) - model.predict(X) + model.predict(X).mean()).sum() < 1e-7
|
|
|
|
|
|
def test_shape_values_linear_many_features():
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
coef = np.array([1, 2]).T
|
|
|
|
# FIXME: this test should ideally pass with any random seed. See #2960
|
|
random_seed = 0
|
|
rs = np.random.RandomState(random_seed)
|
|
# generate linear data
|
|
X = rs.normal(1, 10, size=(1000, len(coef)))
|
|
y = np.dot(X, coef) + 1 + rs.normal(scale=0.1, size=1000)
|
|
|
|
# train linear model
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X.mean(0).reshape(1, -1))
|
|
|
|
values = explainer.shap_values(X)
|
|
|
|
assert values.shape == (1000, 2)
|
|
|
|
expected = (X - X.mean(0)) * coef
|
|
np.testing.assert_allclose(expected - values, 0, atol=0.01)
|
|
|
|
|
|
def test_single_feature(random_seed):
|
|
"""Make sure things work with a univariate linear regression."""
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# generate linear data
|
|
rs = np.random.RandomState(random_seed)
|
|
X = rs.normal(1, 10, size=(100, 1))
|
|
y = 2 * X[:, 0] + 1 + rs.normal(scale=0.1, size=100)
|
|
|
|
# train linear model
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X)
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.abs(explainer.expected_value - model.predict(X).mean()) < 1e-6
|
|
assert np.max(np.abs(explainer.expected_value + shap_values.sum(1) - model.predict(X))) < 1e-6
|
|
|
|
|
|
def test_sparse():
|
|
"""Validate running LinearExplainer on scipy sparse data"""
|
|
n_features = 20
|
|
X, y = make_multilabel_classification(n_samples=100, sparse=True, n_features=n_features, n_classes=1, n_labels=2)
|
|
|
|
# train linear model
|
|
model = LogisticRegression()
|
|
model.fit(X, y.squeeze())
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X)
|
|
shap_values = explainer.shap_values(X)
|
|
assert (
|
|
np.max(
|
|
np.abs(scipy.special.expit(explainer.expected_value + shap_values.sum(1)) - model.predict_proba(X)[:, 1])
|
|
)
|
|
< 1e-6
|
|
)
|
|
|
|
|
|
@pytest.mark.xfail(reason="This should pass but it doesn't.")
|
|
def test_sparse_multi_class():
|
|
"""Validate running LinearExplainer on scipy sparse data"""
|
|
n_features = 4
|
|
X, y = make_multilabel_classification(n_samples=100, sparse=False, n_features=n_features, n_classes=3, n_labels=2)
|
|
y = np.argmax(y, axis=1)
|
|
|
|
# train linear model
|
|
model = LogisticRegression(max_iter=1000)
|
|
model.fit(X, y)
|
|
pred = model.predict_proba(X)
|
|
|
|
# explain the model's predictions using SHAP values
|
|
explainer = shap.LinearExplainer(model, X)
|
|
shap_values = explainer(X)
|
|
np.testing.assert_allclose(
|
|
scipy.special.expit(shap_values.values.sum(1) + shap_values.base_values), # type: ignore[union-attr]
|
|
pred,
|
|
atol=1e-6,
|
|
)
|
|
|
|
|
|
@pytest.mark.filterwarnings("ignore:The feature_perturbation option is now deprecated")
|
|
def test_invalid_feature_perturbation_raises():
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
model = Ridge(0.1).fit(X, y)
|
|
|
|
with pytest.raises(InvalidFeaturePerturbationError, match="feature_perturbation must be one of "):
|
|
shap.LinearExplainer(model, X, feature_perturbation="nonsense") # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.mark.filterwarnings("ignore:The feature_perturbation option is now deprecated")
|
|
@pytest.mark.parametrize(
|
|
"feature_pertubation,masker",
|
|
[
|
|
(None, shap.maskers.Independent),
|
|
("interventional", shap.maskers.Independent),
|
|
("correlation_dependent", shap.maskers.Impute),
|
|
],
|
|
)
|
|
def test_feature_perturbation_sets_correct_masker(feature_pertubation, masker):
|
|
Ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.california(n_points=100)
|
|
model = Ridge(0.1)
|
|
model.fit(X, y)
|
|
|
|
explainer = shap.explainers.LinearExplainer(model, X, feature_perturbation=feature_pertubation)
|
|
assert isinstance(explainer.masker, masker)
|
|
|
|
|
|
def test_interventional_multi_regression():
|
|
ridge = pytest.importorskip("sklearn.linear_model").Ridge
|
|
|
|
# train linear model
|
|
X, y = shap.datasets.linnerud(n_points=100)
|
|
model = ridge(0.1)
|
|
model.fit(X, y)
|
|
outputs = model.predict(X)
|
|
|
|
explainer = shap.explainers.LinearExplainer(model, maskers.Independent(X))
|
|
shap_values = explainer.shap_values(X)
|
|
assert np.allclose(shap_values.sum(1) + explainer.expected_value, outputs, atol=1e-6)
|
|
|
|
|
|
def test_linear_explainer_warns_singular_covariance():
|
|
"""LinearExplainer should warn when n_samples <= n_features."""
|
|
import warnings
|
|
|
|
from sklearn.linear_model import LinearRegression
|
|
|
|
rng = np.random.default_rng(42)
|
|
n_features = 10
|
|
X_train = rng.normal(size=(8, n_features))
|
|
y_train = X_train @ np.arange(1, n_features + 1, dtype=float)
|
|
model = LinearRegression().fit(X_train, y_train)
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
shap.LinearExplainer(
|
|
model,
|
|
X_train,
|
|
feature_perturbation="correlation_dependent",
|
|
)
|
|
user_warnings = [
|
|
x for x in w if issubclass(x.category, UserWarning) and "singular covariance" in str(x.message).lower()
|
|
]
|
|
|
|
assert len(user_warnings) == 1, (
|
|
f"Expected a UserWarning about singular covariance matrix but got: {[str(x.message) for x in w]}"
|
|
)
|