292 lines
12 KiB
Python
292 lines
12 KiB
Python
import functools
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
from packaging.version import Version
|
|
from sklearn.pipeline import Pipeline as sk_Pipeline
|
|
|
|
import mlflow
|
|
from mlflow import MlflowException
|
|
from mlflow.models.evaluation.base import EvaluationMetric, EvaluationResult, _ModelType
|
|
from mlflow.models.evaluation.default_evaluator import (
|
|
BuiltInEvaluator,
|
|
_extract_predict_fn,
|
|
_extract_raw_model,
|
|
_get_dataframe_with_renamed_columns,
|
|
)
|
|
from mlflow.models.evaluation.evaluators.classifier import (
|
|
_is_continuous,
|
|
_suppress_class_imbalance_errors,
|
|
)
|
|
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
|
|
from mlflow.pyfunc import _ServedPyFuncModel
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SUPPORTED_SHAP_ALGORITHMS = ("exact", "permutation", "partition", "kernel")
|
|
_DEFAULT_SAMPLE_ROWS_FOR_SHAP = 2000
|
|
|
|
|
|
def _shap_predict_fn(x, predict_fn, feature_names):
|
|
return predict_fn(_get_dataframe_with_renamed_columns(x, feature_names))
|
|
|
|
|
|
class ShapEvaluator(BuiltInEvaluator):
|
|
"""
|
|
A built-in evaluator to get SHAP explainability insights for classifier and regressor models.
|
|
|
|
This evaluator often run with the main evaluator for the model like ClassifierEvaluator.
|
|
"""
|
|
|
|
name = "shap"
|
|
|
|
@classmethod
|
|
def can_evaluate(cls, *, model_type, evaluator_config, **kwargs):
|
|
return model_type in (_ModelType.CLASSIFIER, _ModelType.REGRESSOR) and evaluator_config.get(
|
|
"log_model_explainability", True
|
|
)
|
|
|
|
def _evaluate(
|
|
self,
|
|
model: Optional["mlflow.pyfunc.PyFuncModel"],
|
|
extra_metrics: list[EvaluationMetric],
|
|
custom_artifacts=None,
|
|
**kwargs,
|
|
) -> EvaluationResult | None:
|
|
if isinstance(model, _ServedPyFuncModel):
|
|
_logger.warning(
|
|
"Skipping model explainability because a model server is used for environment "
|
|
"restoration."
|
|
)
|
|
return
|
|
|
|
model_loader_module, raw_model = _extract_raw_model(model)
|
|
if model_loader_module == "mlflow.spark":
|
|
# TODO: Shap explainer need to manipulate on each feature values,
|
|
# but spark model input dataframe contains Vector type feature column
|
|
# which shap explainer does not support.
|
|
# To support this, we need expand the Vector type feature column into
|
|
# multiple scalar feature columns and pass it to shap explainer.
|
|
_logger.warning(
|
|
"Logging model explainability insights is not currently supported for PySpark "
|
|
"models."
|
|
)
|
|
return
|
|
|
|
self.y_true = self.dataset.labels_data
|
|
self.label_list = self.evaluator_config.get("label_list")
|
|
self.pos_label = self.evaluator_config.get("pos_label")
|
|
|
|
if not (np.issubdtype(self.y_true.dtype, np.number) or self.y_true.dtype == np.bool_):
|
|
# Note: python bool type inherits number type but np.bool_ does not inherit np.number.
|
|
_logger.warning(
|
|
"Skip logging model explainability insights because it requires all label "
|
|
"values to be numeric or boolean."
|
|
)
|
|
return
|
|
|
|
algorithm = self.evaluator_config.get("explainability_algorithm", None)
|
|
if algorithm is not None and algorithm not in _SUPPORTED_SHAP_ALGORITHMS:
|
|
raise MlflowException(
|
|
message=f"Specified explainer algorithm {algorithm} is unsupported. Currently only "
|
|
f"support {','.join(_SUPPORTED_SHAP_ALGORITHMS)} algorithms.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if algorithm != "kernel":
|
|
feature_dtypes = list(self.X.get_original().dtypes)
|
|
for feature_dtype in feature_dtypes:
|
|
if not np.issubdtype(feature_dtype, np.number):
|
|
_logger.warning(
|
|
"Skip logging model explainability insights because the shap explainer "
|
|
f"{algorithm} requires all feature values to be numeric, and each feature "
|
|
"column must only contain scalar values."
|
|
)
|
|
return
|
|
|
|
try:
|
|
import shap
|
|
from matplotlib import pyplot
|
|
except ImportError:
|
|
_logger.warning(
|
|
"SHAP or matplotlib package is not installed, so model explainability insights "
|
|
"will not be logged."
|
|
)
|
|
return
|
|
|
|
if Version(shap.__version__) < Version("0.40"):
|
|
_logger.warning(
|
|
"Shap package version is lower than 0.40, Skip log model explainability."
|
|
)
|
|
return
|
|
|
|
sample_rows = self.evaluator_config.get(
|
|
"explainability_nsamples", _DEFAULT_SAMPLE_ROWS_FOR_SHAP
|
|
)
|
|
|
|
X_df = self.X.copy_to_avoid_mutation()
|
|
|
|
sampled_X = shap.sample(X_df, sample_rows, random_state=0)
|
|
|
|
mode_or_mean_dict = _compute_df_mode_or_mean(X_df)
|
|
sampled_X = sampled_X.fillna(mode_or_mean_dict)
|
|
|
|
# shap explainer might call provided `predict_fn` with a `numpy.ndarray` type
|
|
# argument, this might break some model inference, so convert the argument into
|
|
# a pandas dataframe.
|
|
# The `shap_predict_fn` calls model's predict function, we need to restore the input
|
|
# dataframe with original column names, because some model prediction routine uses
|
|
# the column name.
|
|
|
|
predict_fn = _extract_predict_fn(model)
|
|
shap_predict_fn = functools.partial(
|
|
_shap_predict_fn, predict_fn=predict_fn, feature_names=self.dataset.feature_names
|
|
)
|
|
|
|
if self.label_list is None:
|
|
# If label list is not specified, infer label list from model output.
|
|
# We need to copy the input data as the model might mutate the input data.
|
|
y_pred = predict_fn(X_df.copy()) if predict_fn else self.dataset.predictions_data
|
|
self.label_list = np.unique(np.concatenate([self.y_true, y_pred]))
|
|
|
|
try:
|
|
if algorithm:
|
|
if algorithm == "kernel":
|
|
# We need to lazily import shap, so lazily import `_PatchedKernelExplainer`
|
|
from mlflow.models.evaluation._shap_patch import _PatchedKernelExplainer
|
|
|
|
kernel_link = self.evaluator_config.get(
|
|
"explainability_kernel_link", "identity"
|
|
)
|
|
if kernel_link not in ["identity", "logit"]:
|
|
raise ValueError(
|
|
"explainability_kernel_link config can only be set to 'identity' or "
|
|
f"'logit', but got '{kernel_link}'."
|
|
)
|
|
background_X = shap.sample(X_df, sample_rows, random_state=3)
|
|
background_X = background_X.fillna(mode_or_mean_dict)
|
|
|
|
explainer = _PatchedKernelExplainer(
|
|
shap_predict_fn, background_X, link=kernel_link
|
|
)
|
|
else:
|
|
explainer = shap.Explainer(
|
|
shap_predict_fn,
|
|
sampled_X,
|
|
feature_names=self.dataset.feature_names,
|
|
algorithm=algorithm,
|
|
)
|
|
else:
|
|
if (
|
|
raw_model
|
|
and not len(self.label_list) > 2
|
|
and not isinstance(raw_model, sk_Pipeline)
|
|
):
|
|
# For mulitnomial classifier, shap.Explainer may choose Tree/Linear explainer
|
|
# for raw model, this case shap plot doesn't support it well, so exclude the
|
|
# multinomial_classifier case here.
|
|
explainer = shap.Explainer(
|
|
raw_model, sampled_X, feature_names=self.dataset.feature_names
|
|
)
|
|
else:
|
|
# fallback to default explainer
|
|
explainer = shap.Explainer(
|
|
shap_predict_fn, sampled_X, feature_names=self.dataset.feature_names
|
|
)
|
|
|
|
_logger.info(f"Shap explainer {explainer.__class__.__name__} is used.")
|
|
|
|
shap_values = explainer(sampled_X)
|
|
except Exception as e:
|
|
# Shap evaluation might fail on some edge cases, e.g., unsupported input data values
|
|
# or unsupported model on specific shap explainer. Catch exception to prevent it
|
|
# breaking the whole `evaluate` function.
|
|
|
|
if not self.evaluator_config.get("ignore_exceptions", True):
|
|
raise e
|
|
|
|
_logger.warning(
|
|
f"Shap evaluation failed. Reason: {e!r}. "
|
|
"Set logging level to DEBUG to see the full traceback."
|
|
)
|
|
_logger.debug("", exc_info=True)
|
|
return
|
|
|
|
if self.evaluator_config.get("log_explainer", False):
|
|
try:
|
|
mlflow.shap.log_explainer(explainer, name="explainer")
|
|
except Exception as e:
|
|
# TODO: The explainer saver is buggy, if `get_underlying_model_flavor` return
|
|
# "unknown", then fallback to shap explainer saver, and shap explainer will call
|
|
# `model.save` for sklearn model, there is no `.save` method, so error will
|
|
# happen.
|
|
_logger.warning(
|
|
f"Logging explainer failed. Reason: {e!r}. "
|
|
"Set logging level to DEBUG to see the full traceback."
|
|
)
|
|
_logger.debug("", exc_info=True)
|
|
|
|
def _adjust_color_bar():
|
|
pyplot.gcf().axes[-1].set_aspect("auto")
|
|
pyplot.gcf().axes[-1].set_box_aspect(50)
|
|
|
|
def _adjust_axis_tick():
|
|
pyplot.xticks(fontsize=10)
|
|
pyplot.yticks(fontsize=10)
|
|
|
|
def plot_beeswarm():
|
|
shap.plots.beeswarm(shap_values, show=False, color_bar=True)
|
|
_adjust_color_bar()
|
|
_adjust_axis_tick()
|
|
|
|
with _suppress_class_imbalance_errors(ValueError, log_warning=False):
|
|
self._log_image_artifact(
|
|
plot_beeswarm,
|
|
"shap_beeswarm_plot",
|
|
)
|
|
|
|
def plot_summary():
|
|
shap.summary_plot(shap_values, show=False, color_bar=True)
|
|
_adjust_color_bar()
|
|
_adjust_axis_tick()
|
|
|
|
with _suppress_class_imbalance_errors(TypeError, log_warning=False):
|
|
self._log_image_artifact(
|
|
plot_summary,
|
|
"shap_summary_plot",
|
|
)
|
|
|
|
def plot_feature_importance():
|
|
shap.plots.bar(shap_values, show=False)
|
|
_adjust_axis_tick()
|
|
|
|
with _suppress_class_imbalance_errors(IndexError, log_warning=False):
|
|
self._log_image_artifact(
|
|
plot_feature_importance,
|
|
"shap_feature_importance_plot",
|
|
)
|
|
|
|
return EvaluationResult(
|
|
metrics=self.aggregate_metrics,
|
|
artifacts=self.artifacts,
|
|
run_id=self.run_id,
|
|
)
|
|
|
|
|
|
def _compute_df_mode_or_mean(df):
|
|
"""
|
|
Compute mean (for continuous columns) and compute mode (for other columns) for the
|
|
input dataframe, return a dict, key is column name, value is the corresponding mode or
|
|
mean value, this function calls `_is_continuous` to determine whether the
|
|
column is continuous column.
|
|
"""
|
|
continuous_cols = [c for c in df.columns if _is_continuous(df[c])]
|
|
df_cont = df[continuous_cols]
|
|
df_non_cont = df.drop(continuous_cols, axis=1)
|
|
|
|
means = {} if df_cont.empty else df_cont.mean().to_dict()
|
|
modes = {} if df_non_cont.empty else df_non_cont.mode().loc[0].to_dict()
|
|
return means | modes
|