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

496 lines
17 KiB
Python

import json
from unittest import mock
import numpy as np
import pandas as pd
import pytest
import shap
import sklearn
from numba import njit
from packaging.version import Version
from sklearn.datasets import load_diabetes
import mlflow
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow import MlflowClient
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.utils import PYTHON_VERSION
from mlflow.utils.model_utils import _get_flavor_configuration
from tests.helper_functions import (
_assert_pip_requirements,
_compare_logged_code_paths,
_mlflow_major_version_string,
pyfunc_serve_and_score_model,
)
@pytest.fixture(scope="module")
def shap_model():
X, y = load_diabetes(return_X_y=True, as_frame=True)
model = sklearn.ensemble.RandomForestRegressor(n_estimators=100)
model.fit(X, y)
return shap.Explainer(model.predict, X, algorithm="permutation")
def get_test_dataset():
X, y = load_diabetes(as_frame=True, return_X_y=True)
return X, y
def test_sklearn_log_explainer():
"""
Tests mlflow.shap log_explainer with mlflow serialization of the underlying model
"""
with mlflow.start_run() as run:
run_id = run.info.run_id
X, y = get_test_dataset()
model = sklearn.ensemble.RandomForestRegressor(n_estimators=100)
model.fit(X, y)
explainer_original = shap.Explainer(model.predict, X, algorithm="permutation")
shap_values_original = explainer_original(X[:5])
mlflow.shap.log_explainer(explainer_original, "test_explainer")
explainer_uri = "runs:/" + run_id + "/test_explainer"
explainer_loaded = mlflow.shap.load_explainer(explainer_uri)
shap_values_new = explainer_loaded(X[:5])
explainer_path = _download_artifact_from_uri(artifact_uri=explainer_uri)
flavor_conf = _get_flavor_configuration(
model_path=explainer_path, flavor_name=mlflow.shap.FLAVOR_NAME
)
underlying_model_flavor = flavor_conf["underlying_model_flavor"]
assert underlying_model_flavor == mlflow.sklearn.FLAVOR_NAME
np.testing.assert_array_equal(shap_values_original.base_values, shap_values_new.base_values)
np.testing.assert_allclose(
shap_values_original.values, shap_values_new.values, rtol=100, atol=100
)
def test_sklearn_log_explainer_self_serialization():
"""
Tests mlflow.shap log_explainer with SHAP internal serialization of the underlying model
"""
with mlflow.start_run() as run:
run_id = run.info.run_id
X, y = get_test_dataset()
model = sklearn.ensemble.RandomForestRegressor(n_estimators=100)
model.fit(X, y)
explainer_original = shap.Explainer(model.predict, X, algorithm="permutation")
shap_values_original = explainer_original(X[:5])
mlflow.shap.log_explainer(
explainer_original, "test_explainer", serialize_model_using_mlflow=False
)
explainer_uri = "runs:/" + run_id + "/test_explainer"
explainer_loaded = mlflow.shap.load_explainer("runs:/" + run_id + "/test_explainer")
shap_values_new = explainer_loaded(X[:5])
explainer_path = _download_artifact_from_uri(artifact_uri=explainer_uri)
flavor_conf = _get_flavor_configuration(
model_path=explainer_path, flavor_name=mlflow.shap.FLAVOR_NAME
)
underlying_model_flavor = flavor_conf["underlying_model_flavor"]
assert underlying_model_flavor is None
np.testing.assert_array_equal(shap_values_original.base_values, shap_values_new.base_values)
np.testing.assert_allclose(
shap_values_original.values, shap_values_new.values, rtol=100, atol=100
)
def test_sklearn_log_explainer_pyfunc():
"""
Tests mlflow.shap log_explainer with mlflow
serialization of the underlying model using pyfunc flavor
"""
with mlflow.start_run() as run:
run_id = run.info.run_id
X, y = get_test_dataset()
model = sklearn.ensemble.RandomForestRegressor(n_estimators=100)
model.fit(X, y)
explainer_original = shap.Explainer(model.predict, X, algorithm="permutation")
shap_values_original = explainer_original(X[:2])
mlflow.shap.log_explainer(explainer_original, "test_explainer")
explainer_pyfunc = mlflow.pyfunc.load_model("runs:/" + run_id + "/test_explainer")
shap_values_new = explainer_pyfunc.predict(X[:2])
np.testing.assert_allclose(shap_values_original.values, shap_values_new, rtol=100, atol=100)
def test_log_explanation_doesnt_create_autologged_run():
try:
mlflow.sklearn.autolog(disable=False, exclusive=False)
X, y = sklearn.datasets.load_diabetes(return_X_y=True, as_frame=True)
X = X.iloc[:50, :4]
y = y.iloc[:50]
model = sklearn.linear_model.LinearRegression()
model.fit(X, y)
with mlflow.start_run() as run:
mlflow.shap.log_explanation(model.predict, X)
run_data = MlflowClient().get_run(run.info.run_id).data
metrics = run_data.metrics
params = run_data.params
tags = run_data.tags
assert not metrics
assert not params
assert all("mlflow." in key for key in tags)
assert "mlflow.autologging" not in tags
finally:
mlflow.sklearn.autolog(disable=True)
def test_load_pyfunc(tmp_path):
X, y = get_test_dataset()
model = sklearn.ensemble.RandomForestRegressor(n_estimators=100)
model.fit(X, y)
explainer_original = shap.Explainer(model.predict, X, algorithm="permutation")
shap_values_original = explainer_original(X[:2])
path = str(tmp_path.joinpath("pyfunc_test"))
mlflow.shap.save_explainer(explainer_original, path)
explainer_pyfunc = mlflow.shap._load_pyfunc(path)
shap_values_new = explainer_pyfunc.predict(X[:2])
np.testing.assert_allclose(shap_values_original.values, shap_values_new, rtol=100, atol=100)
def test_merge_environment():
expected_mlflow_version = _mlflow_major_version_string()
test_shap_env = {
"channels": ["conda-forge"],
"dependencies": ["python=3.8.5", "pip", {"pip": [expected_mlflow_version, "shap==0.38.0"]}],
}
test_model_env = {
"channels": ["conda-forge"],
"dependencies": [
"python=3.8.5",
"pip",
{"pip": [expected_mlflow_version, "scikit-learn==0.24.0", "cloudpickle==1.6.0"]},
],
}
expected_merged_env = {
"name": "mlflow-env",
"channels": ["conda-forge"],
"dependencies": [
f"python={PYTHON_VERSION}",
"pip",
{
"pip": [
expected_mlflow_version,
"scikit-learn==0.24.0",
"cloudpickle==1.6.0",
"shap==0.38.0",
]
},
],
}
actual_merged_env = mlflow.shap._merge_environments(test_shap_env, test_model_env)
assert sorted(expected_merged_env["channels"]) == sorted(actual_merged_env["channels"])
expected_conda_deps, expected_pip_deps = mlflow.shap._get_conda_and_pip_dependencies(
expected_merged_env
)
actual_conda_deps, actual_pip_deps = mlflow.shap._get_conda_and_pip_dependencies(
actual_merged_env
)
assert sorted(expected_pip_deps) == actual_pip_deps
assert sorted(expected_conda_deps) == actual_conda_deps
def test_merge_environment_with_duplicates():
expected_mlflow_version = _mlflow_major_version_string()
duplicate_dependency = "numpy==1.19.2"
# Introduce the duplicate in both environments
test_shap_env = {
"channels": ["conda-forge"],
"dependencies": [
"python=3.8.5",
"pip",
{"pip": [expected_mlflow_version, "shap==0.38.0", duplicate_dependency]},
],
}
test_model_env = {
"channels": ["conda-forge"],
"dependencies": [
"python=3.8.5",
"pip",
{
"pip": [
expected_mlflow_version,
"scikit-learn==0.24.0",
"cloudpickle==1.6.0",
duplicate_dependency,
]
},
],
}
# The expected merged environment should not have duplicates
expected_merged_env = {
"name": "mlflow-env",
"channels": ["conda-forge"],
"dependencies": [
f"python={PYTHON_VERSION}",
"pip",
{
"pip": [
expected_mlflow_version,
"scikit-learn==0.24.0",
"cloudpickle==1.6.0",
"shap==0.38.0",
duplicate_dependency,
]
},
],
}
actual_merged_env = mlflow.shap._merge_environments(test_shap_env, test_model_env)
assert sorted(expected_merged_env["channels"]) == sorted(actual_merged_env["channels"])
expected_conda_deps, expected_pip_deps = mlflow.shap._get_conda_and_pip_dependencies(
expected_merged_env
)
actual_conda_deps, actual_pip_deps = mlflow.shap._get_conda_and_pip_dependencies(
actual_merged_env
)
# Check that there are no duplicates in the actual pip dependencies
assert sorted(actual_pip_deps) == sorted(set(actual_pip_deps))
assert sorted(expected_pip_deps) == actual_pip_deps
assert sorted(expected_conda_deps) == actual_conda_deps
def test_log_model_with_pip_requirements(shap_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
sklearn_default_reqs = mlflow.sklearn.get_default_pip_requirements(include_cloudpickle=True)
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(shap_model, "model", pip_requirements=str(req_file))
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, "a", *sklearn_default_reqs],
strict=False,
)
# List of requirements
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(
shap_model, "model", pip_requirements=[f"-r {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, "a", "b", *sklearn_default_reqs],
strict=False,
)
# Constraints file
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(
shap_model, "model", pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
model_info.model_uri,
[expected_mlflow_version, "b", "-c constraints.txt", *sklearn_default_reqs],
["a"],
strict=False,
)
def test_log_model_with_extra_pip_requirements(shap_model, tmp_path):
expected_mlflow_version = _mlflow_major_version_string()
shap_default_reqs = mlflow.shap.get_default_pip_requirements()
sklearn_default_reqs = mlflow.sklearn.get_default_pip_requirements(include_cloudpickle=True)
# Path to a requirements file
req_file = tmp_path.joinpath("requirements.txt")
req_file.write_text("a")
with mlflow.start_run():
log_info = mlflow.shap.log_explainer(
shap_model, "model", extra_pip_requirements=str(req_file)
)
_assert_pip_requirements(
log_info.model_uri,
[expected_mlflow_version, *shap_default_reqs, "a", *sklearn_default_reqs],
)
# List of requirements
with mlflow.start_run():
log_info = mlflow.shap.log_explainer(
shap_model, "model", extra_pip_requirements=[f"-r {req_file}", "b"]
)
_assert_pip_requirements(
log_info.model_uri,
[expected_mlflow_version, *shap_default_reqs, "a", "b", *sklearn_default_reqs],
)
# Constraints file
with mlflow.start_run():
log_info = mlflow.shap.log_explainer(
shap_model, "model", extra_pip_requirements=[f"-c {req_file}", "b"]
)
_assert_pip_requirements(
log_info.model_uri,
[
expected_mlflow_version,
*shap_default_reqs,
"b",
"-c constraints.txt",
*sklearn_default_reqs,
],
["a"],
)
def create_identity_function():
def identity(x):
return x
def _identity_inverse(x):
return x
identity.inverse = _identity_inverse
return identity
@pytest.mark.skipif(Version(shap.__version__) < Version("0.42.0"), reason="numba njit compatible")
def test_pyfunc_serve_and_score_njit():
# Create a numba-compatible identify link function due to breaking changes in shap
# version 0.42.0. Python functions can no longer be passed to the numba jit compiler
# with the changes introduced in that version.
@njit
def identity_function(x):
return x
X, y = get_test_dataset()
reg = sklearn.ensemble.RandomForestRegressor(n_estimators=10).fit(X, y)
model = shap.Explainer(
reg.predict,
masker=X,
algorithm="permutation",
# `link` defaults to `shap.links.identity` which is decorated by `numba.jit` and causes
# the following error when loading the explainer for serving:
# ```
# Exception: The passed link function needs to be callable and have a callable
# .inverse property!
# ```
# As a workaround, use an identify function that's NOT decorated by `numba.jit`.
link=identity_function,
)
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(model, artifact_path)
resp = pyfunc_serve_and_score_model(
model_info.model_uri,
data=pd.DataFrame(X[:3]),
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
)
decoded_json = json.loads(resp.content.decode("utf-8"))
scores = pd.DataFrame(data=decoded_json["predictions"]).values
np.testing.assert_allclose(scores, model(X[:3]).values, rtol=100, atol=100)
@pytest.mark.skipif(Version(shap.__version__) > Version("0.41.0"), reason="numba jit compatible")
def test_pyfunc_serve_and_score():
# Note: this implementation of an identify function is only compatible with versions of
# shap <= 0.41.0. A breaking change was introduced with how numba is used with shap in version
# 0.42.0.
X, y = get_test_dataset()
reg = sklearn.ensemble.RandomForestRegressor(n_estimators=10).fit(X, y)
model = shap.Explainer(
reg.predict,
masker=X,
algorithm="permutation",
# `link` defaults to `shap.links.identity` which is decorated by `numba.jit` and causes
# the following error when loading the explainer for serving:
# ```
# Exception: The passed link function needs to be callable and have a callable
# .inverse property!
# ```
# As a workaround, use an identify function that's NOT decorated by `numba.jit`.
link=create_identity_function(),
)
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(model, artifact_path)
resp = pyfunc_serve_and_score_model(
model_info.model_uri,
data=pd.DataFrame(X[:3]),
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
)
decoded_json = json.loads(resp.content.decode("utf-8"))
scores = pd.DataFrame(data=decoded_json["predictions"]).values
np.testing.assert_allclose(scores, model(X[:3]).values, rtol=100, atol=100)
def test_log_model_with_code_paths(shap_model):
artifact_path = "model"
with (
mlflow.start_run(),
mock.patch("mlflow.shap._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.shap.log_explainer(shap_model, artifact_path, code_paths=[__file__])
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.shap.FLAVOR_NAME)
mlflow.shap.load_explainer(model_info.model_uri)
add_mock.assert_called()
def test_model_save_load_with_metadata(shap_model, tmp_path):
model_path = str(tmp_path.joinpath("pyfunc_test"))
mlflow.shap.save_explainer(
shap_model, path=model_path, metadata={"metadata_key": "metadata_value"}
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_path)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"
def test_model_log_with_metadata(shap_model):
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.shap.log_explainer(
shap_model, artifact_path=artifact_path, metadata={"metadata_key": "metadata_value"}
)
reloaded_model = mlflow.pyfunc.load_model(model_uri=model_info.model_uri)
assert reloaded_model.metadata.metadata["metadata_key"] == "metadata_value"