231 lines
9.2 KiB
Python
231 lines
9.2 KiB
Python
from typing import Any, Literal
|
|
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
|
|
from ..utils import MaskedModel, safe_isinstance
|
|
from ._explainer import Explainer
|
|
|
|
|
|
class AdditiveExplainer(Explainer):
|
|
"""Computes SHAP values for generalized additive models.
|
|
|
|
This assumes that the model only has first-order effects. Extending this to
|
|
second- and third-order effects is future work (if you apply this to those models right now
|
|
you will get incorrect answers that fail additivity).
|
|
"""
|
|
|
|
_expected_value: float | npt.NDArray[np.floating[Any]]
|
|
_zero_offset: float | npt.NDArray[np.floating[Any]]
|
|
_input_offsets: npt.NDArray[np.floating[Any]]
|
|
|
|
def __init__(
|
|
self,
|
|
model: Any,
|
|
masker: Any,
|
|
link: Any = None,
|
|
feature_names: list[str] | None = None,
|
|
linearize_link: bool = True,
|
|
) -> None:
|
|
"""Build an Additive explainer for the given model using the given masker object.
|
|
|
|
Parameters
|
|
----------
|
|
model : function
|
|
A callable python object that executes the model given a set of input data samples.
|
|
|
|
masker : function or numpy.array or pandas.DataFrame
|
|
A callable python object used to "mask" out hidden features of the form ``masker(mask, *fargs)``.
|
|
It takes a single a binary mask and an input sample and returns a matrix of masked samples. These
|
|
masked samples are evaluated using the model function and the outputs are then averaged.
|
|
As a shortcut for the standard masking used by SHAP you can pass a background data matrix
|
|
instead of a function and that matrix will be used for masking. To use a clustering
|
|
game structure you can pass a ``shap.maskers.Tabular(data, hclustering="correlation")`` object, but
|
|
note that this structure information has no effect on the explanations of additive models.
|
|
|
|
"""
|
|
super().__init__(model, masker, feature_names=feature_names, linearize_link=linearize_link)
|
|
|
|
if safe_isinstance(model, "interpret.glassbox.ExplainableBoostingClassifier"):
|
|
self.model = model.decision_function
|
|
|
|
if self.masker is None:
|
|
self._expected_value = model.intercept_
|
|
# num_features = len(model.additive_terms_)
|
|
|
|
# fm = MaskedModel(self.model, self.masker, self.link, np.zeros(num_features))
|
|
# masks = np.ones((1, num_features), dtype=bool)
|
|
# outputs = fm(masks)
|
|
# self.model(np.zeros(num_features))
|
|
# self._zero_offset = self.model(np.zeros(num_features))#model.intercept_#outputs[0]
|
|
# self._input_offsets = np.zeros(num_features) #* self._zero_offset
|
|
raise NotImplementedError(
|
|
"Masker not given and we don't yet support pulling the distribution centering directly from the EBM model!"
|
|
)
|
|
return
|
|
|
|
# here we need to compute the offsets ourselves because we can't pull them directly from a model we know about
|
|
assert safe_isinstance(self.masker, "shap.maskers.Independent"), (
|
|
"The Additive explainer only supports the Tabular masker at the moment!"
|
|
)
|
|
|
|
# pre-compute per-feature offsets
|
|
fm = MaskedModel(self.model, self.masker, self.link, self.linearize_link, np.zeros(self.masker.shape[1]))
|
|
masks = np.ones((self.masker.shape[1] + 1, self.masker.shape[1]), dtype=bool)
|
|
for i in range(1, self.masker.shape[1] + 1):
|
|
masks[i, i - 1] = False
|
|
outputs = fm(masks)
|
|
self._zero_offset = outputs[0]
|
|
self._input_offsets = np.zeros(masker.shape[1])
|
|
for i in range(1, self.masker.shape[1] + 1):
|
|
self._input_offsets[i - 1] = outputs[i] - self._zero_offset
|
|
|
|
self._expected_value = self._input_offsets.sum() + self._zero_offset
|
|
|
|
def __call__(
|
|
self,
|
|
*args: Any,
|
|
max_evals: int | Literal["auto"] = "auto",
|
|
main_effects: bool = False,
|
|
error_bounds: bool = False,
|
|
batch_size: int | Literal["auto"] = "auto",
|
|
outputs: Any = None,
|
|
silent: bool = False,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
"""Explains the output of model(*args), where args represents one or more parallel iterable args."""
|
|
# we entirely rely on the general call implementation
|
|
return super().__call__(
|
|
*args,
|
|
max_evals=max_evals,
|
|
main_effects=main_effects,
|
|
error_bounds=error_bounds,
|
|
batch_size=batch_size,
|
|
outputs=outputs,
|
|
silent=silent,
|
|
**kwargs,
|
|
)
|
|
|
|
@staticmethod
|
|
def supports_model_with_masker(model: Any, masker: Any) -> bool:
|
|
"""Determines if this explainer can handle the given model.
|
|
|
|
This is an abstract static method meant to be implemented by each subclass.
|
|
"""
|
|
if safe_isinstance(model, "interpret.glassbox.ExplainableBoostingClassifier"):
|
|
if model.interactions != 0:
|
|
raise NotImplementedError("Need to add support for interaction effects!")
|
|
return True
|
|
|
|
return False
|
|
|
|
def explain_row(
|
|
self,
|
|
*row_args: Any,
|
|
max_evals: int | Literal["auto"],
|
|
main_effects: bool,
|
|
error_bounds: bool,
|
|
outputs: Any,
|
|
silent: bool,
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
"""Explains a single row and returns the tuple (row_values, row_expected_values, row_mask_shapes)."""
|
|
x = row_args[0]
|
|
inputs = np.zeros((len(x), len(x)))
|
|
for i in range(len(x)):
|
|
inputs[i, i] = x[i]
|
|
|
|
phi = self.model(inputs) - self._zero_offset - self._input_offsets
|
|
|
|
return {
|
|
"values": phi,
|
|
"expected_values": self._expected_value,
|
|
"mask_shapes": [a.shape for a in row_args],
|
|
"main_effects": phi,
|
|
"clustering": getattr(self.masker, "clustering", None),
|
|
}
|
|
|
|
|
|
# class AdditiveExplainer(Explainer):
|
|
# """ Computes SHAP values for generalized additive models.
|
|
|
|
# This assumes that the model only has first order effects. Extending this to
|
|
# 2nd and third order effects is future work (if you apply this to those models right now
|
|
# you will get incorrect answers that fail additivity).
|
|
|
|
# Parameters
|
|
# ----------
|
|
# model : function or ExplainableBoostingRegressor
|
|
# User supplied additive model either as either a function or a model object.
|
|
|
|
# data : numpy.array, pandas.DataFrame
|
|
# The background dataset to use for computing conditional expectations.
|
|
# feature_perturbation : "interventional"
|
|
# Only the standard interventional SHAP values are supported by AdditiveExplainer right now.
|
|
# """
|
|
|
|
# def __init__(self, model, data, feature_perturbation="interventional"):
|
|
# if feature_perturbation != "interventional":
|
|
# raise Exception("Unsupported type of feature_perturbation provided: " + feature_perturbation)
|
|
|
|
# if safe_isinstance(model, "interpret.glassbox.ebm.ebm.ExplainableBoostingRegressor"):
|
|
# self.f = model.predict
|
|
# elif callable(model):
|
|
# self.f = model
|
|
# else:
|
|
# raise ValueError("The passed model must be a recognized object or a function!")
|
|
|
|
# # convert dataframes
|
|
# if isinstance(data, (pd.Series, pd.DataFrame)):
|
|
# data = data.values
|
|
# self.data = data
|
|
|
|
# # compute the expected value of the model output
|
|
# self.expected_value = self.f(data).mean()
|
|
|
|
# # pre-compute per-feature offsets
|
|
# tmp = np.zeros(data.shape)
|
|
# self._zero_offset = self.f(tmp).mean()
|
|
# self._feature_offset = np.zeros(data.shape[1])
|
|
# for i in range(data.shape[1]):
|
|
# tmp[:,i] = data[:,i]
|
|
# self._feature_offset[i] = self.f(tmp).mean() - self._zero_offset
|
|
# tmp[:,i] = 0
|
|
|
|
|
|
# def shap_values(self, X):
|
|
# """ Estimate the SHAP values for a set of samples.
|
|
|
|
# Parameters
|
|
# ----------
|
|
# X : numpy.array, pandas.DataFrame or scipy.csr_matrix
|
|
# A matrix of samples (# samples x # features) on which to explain the model's output.
|
|
|
|
# Returns
|
|
# -------
|
|
# For models with a single output this returns a matrix of SHAP values
|
|
# (# samples x # features). Each row sums to the difference between the model output for that
|
|
# sample and the expected value of the model output (which is stored as expected_value
|
|
# attribute of the explainer).
|
|
# """
|
|
|
|
# # convert dataframes
|
|
# if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
# X = X.values
|
|
|
|
# # assert isinstance(X, np.ndarray), "Unknown instance type: " + str(type(X))
|
|
# assert len(X.shape) == 1 or len(X.shape) == 2, "Instance must have 1 or 2 dimensions!"
|
|
|
|
# # convert dataframes
|
|
# if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
# X = X.values
|
|
|
|
# phi = np.zeros(X.shape)
|
|
# tmp = np.zeros(X.shape)
|
|
# for i in range(X.shape[1]):
|
|
# tmp[:,i] = X[:,i]
|
|
# phi[:,i] = self.f(tmp) - self._zero_offset - self._feature_offset[i]
|
|
# tmp[:,i] = 0
|
|
|
|
# return phi
|