418 lines
18 KiB
Python
418 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Literal
|
|
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
from numba import njit # type: ignore[attr-defined]
|
|
|
|
from .. import links
|
|
from .._cutils import compute_grey_code_row_values
|
|
from ..models import Model
|
|
from ..utils import (
|
|
MaskedModel,
|
|
delta_minimization_order,
|
|
make_masks,
|
|
shapley_coefficients,
|
|
)
|
|
from ._explainer import Explainer
|
|
|
|
log = logging.getLogger("shap")
|
|
|
|
|
|
class ExactExplainer(Explainer):
|
|
"""Computes SHAP values via an optimized exact enumeration.
|
|
|
|
This works well for standard Shapley value maskers for models with less than ~15 features that vary
|
|
from the background per sample. It also works well for Owen values from hclustering structured
|
|
maskers when there are less than ~100 features that vary from the background per sample. This
|
|
explainer minimizes the number of function evaluations needed by ordering the masking sets to
|
|
minimize sequential differences. This is done using gray codes for standard Shapley values
|
|
and a greedy sorting method for hclustering structured maskers.
|
|
|
|
Examples
|
|
--------
|
|
See `Exact explainer examples <https://shap.readthedocs.io/en/latest/example_notebooks/api_examples/explainers/Exact.html>`_
|
|
"""
|
|
|
|
model: Model
|
|
_partition_masks: npt.NDArray[np.bool_]
|
|
_partition_masks_inds: list[list[npt.NDArray[np.intp]]]
|
|
_partition_delta_indexes: npt.NDArray[np.intp]
|
|
_gray_code_cache: dict[int, npt.NDArray[np.intp]]
|
|
|
|
def __init__(
|
|
self,
|
|
model: Any,
|
|
masker: Any,
|
|
link: Any = links.identity,
|
|
linearize_link: bool = True,
|
|
feature_names: Any = None,
|
|
) -> None:
|
|
"""Build an explainers.Exact object 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.TabularPartitions(data) object.
|
|
|
|
link : function
|
|
The link function used to map between the output units of the model and the SHAP value units. By
|
|
default it is shap.links.identity, but shap.links.logit can be useful so that expectations are
|
|
computed in probability units while explanations remain in the (more naturally additive) log-odds
|
|
units. For more details on how link functions work see any overview of link functions for generalized
|
|
linear models.
|
|
|
|
linearize_link : bool
|
|
If we use a non-linear link function to take expectations then models that are additive with respect to that
|
|
link function for a single background sample will no longer be additive when using a background masker with
|
|
many samples. This for example means that a linear logistic regression model would have interaction effects
|
|
that arise from the non-linear changes in expectation averaging. To retain the additively of the model with
|
|
still respecting the link function we linearize the link function by default.
|
|
|
|
""" # TODO link to the link linearization paper when done
|
|
super().__init__(model, masker, link=link, linearize_link=linearize_link, feature_names=feature_names)
|
|
|
|
self.model = Model(model)
|
|
|
|
if getattr(masker, "clustering", None) is not None:
|
|
self._partition_masks, self._partition_masks_inds = partition_masks(masker.clustering)
|
|
self._partition_delta_indexes = partition_delta_indexes(masker.clustering, self._partition_masks)
|
|
|
|
self._gray_code_cache = {} # used to avoid regenerating the same gray code patterns
|
|
|
|
def __call__( # type: ignore[override]
|
|
self,
|
|
*args: Any,
|
|
max_evals: int | Literal["auto"] = 100000,
|
|
main_effects: bool = False,
|
|
error_bounds: bool = False,
|
|
batch_size: int | Literal["auto"] = "auto",
|
|
interactions: bool | int = 1,
|
|
silent: bool = False,
|
|
) -> Any:
|
|
"""Explains the output of model(*args), where args represents one or more parallel iterators."""
|
|
# we entirely rely on the general call implementation, we override just to remove **kwargs
|
|
# from the function signature
|
|
return super().__call__(
|
|
*args,
|
|
max_evals=max_evals,
|
|
main_effects=main_effects,
|
|
error_bounds=error_bounds,
|
|
batch_size=batch_size,
|
|
interactions=interactions,
|
|
silent=silent,
|
|
)
|
|
|
|
def _cached_gray_codes(self, n: int) -> npt.NDArray[np.intp]:
|
|
if n not in self._gray_code_cache:
|
|
self._gray_code_cache[n] = gray_code_indexes(n)
|
|
return self._gray_code_cache[n]
|
|
|
|
def explain_row( # type: ignore[override]
|
|
self,
|
|
*row_args: Any,
|
|
max_evals: int | Literal["auto"],
|
|
main_effects: bool,
|
|
error_bounds: bool,
|
|
batch_size: int | Literal["auto"],
|
|
outputs: Any,
|
|
interactions: bool | int,
|
|
silent: bool,
|
|
) -> dict[str, Any]:
|
|
"""Explains a single row and returns the tuple (row_values, row_expected_values, row_mask_shapes)."""
|
|
# build a masked version of the model for the current input sample
|
|
fm = MaskedModel(self.model, self.masker, self.link, self.linearize_link, *row_args)
|
|
|
|
# do the standard Shapley values
|
|
inds = None
|
|
if getattr(self.masker, "clustering", None) is None:
|
|
# see which elements we actually need to perturb
|
|
inds = fm.varying_inputs()
|
|
if len(inds) == 0:
|
|
# if nothing varies then we can just return the expected value as the output and be done with it
|
|
outputs = fm(np.array([MaskedModel.delta_mask_noop_value]), zero_index=0, batch_size=batch_size)
|
|
# todo: not quite sure about values, that should be constantly 0!
|
|
return {
|
|
"values": np.zeros(row_args[0].shape),
|
|
"expected_values": outputs[0],
|
|
"mask_shapes": fm.mask_shapes,
|
|
"main_effects": None,
|
|
"clustering": getattr(self.masker, "clustering", None),
|
|
}
|
|
|
|
# make sure we have enough evals
|
|
if max_evals is not None and max_evals != "auto" and max_evals < 2 ** len(inds):
|
|
raise ValueError(
|
|
f"It takes {2 ** len(inds)} masked evaluations to run the Exact explainer on this instance, but max_evals={max_evals}!"
|
|
)
|
|
|
|
# generate the masks in gray code order (so that we change the inputs as little
|
|
# as possible while we iterate to minimize the need to re-eval when the inputs
|
|
# don't vary from the background)
|
|
delta_indexes = self._cached_gray_codes(len(inds))
|
|
|
|
# map to a larger mask that includes the invariant entries
|
|
extended_delta_indexes = np.zeros(2 ** len(inds), dtype=int)
|
|
for i in range(2 ** len(inds)):
|
|
if delta_indexes[i] == MaskedModel.delta_mask_noop_value:
|
|
extended_delta_indexes[i] = delta_indexes[i]
|
|
else:
|
|
extended_delta_indexes[i] = inds[delta_indexes[i]]
|
|
|
|
# run the model
|
|
outputs = fm(extended_delta_indexes, zero_index=0, batch_size=batch_size)
|
|
|
|
# Shapley values
|
|
# Care: Need to distinguish between `True` and `1`
|
|
if interactions is False or (interactions == 1 and interactions is not True):
|
|
# loop over all the outputs to update the rows
|
|
coeff = shapley_coefficients(len(inds))
|
|
row_values = np.zeros((len(fm),) + outputs.shape[1:])
|
|
mask = np.zeros(len(fm), dtype=bool)
|
|
|
|
compute_grey_code_row_values(
|
|
row_values, mask, inds, outputs, coeff, extended_delta_indexes, MaskedModel.delta_mask_noop_value
|
|
)
|
|
|
|
# Shapley-Taylor interaction values
|
|
elif interactions is True or interactions == 2:
|
|
# loop over all the outputs to update the rows
|
|
coeff = shapley_coefficients(len(inds))
|
|
row_values = np.zeros((len(fm), len(fm)) + outputs.shape[1:])
|
|
mask = np.zeros(len(fm), dtype=bool)
|
|
_compute_grey_code_row_values_st(
|
|
row_values, mask, inds, outputs, coeff, extended_delta_indexes, MaskedModel.delta_mask_noop_value
|
|
)
|
|
|
|
elif interactions > 2:
|
|
raise NotImplementedError(
|
|
"Currently the Exact explainer does not support interactions higher than order 2!"
|
|
)
|
|
|
|
# do a partition tree constrained version of Shapley values
|
|
else:
|
|
# make sure we have enough evals
|
|
if max_evals is not None and max_evals != "auto" and max_evals < len(fm) ** 2:
|
|
raise ValueError(
|
|
f"It takes {len(fm) ** 2} masked evaluations to run the Exact explainer on this instance, but max_evals={max_evals}!"
|
|
)
|
|
|
|
# generate the masks in a hclust order (so that we change the inputs as little
|
|
# as possible while we iterate to minimize the need to re-eval when the inputs
|
|
# don't vary from the background)
|
|
delta_indexes = self._partition_delta_indexes
|
|
|
|
# run the model
|
|
outputs = fm(delta_indexes, batch_size=batch_size)
|
|
|
|
# loop over each output feature
|
|
row_values = np.zeros((len(fm),) + outputs.shape[1:])
|
|
for i in range(len(fm)):
|
|
on_outputs = outputs[self._partition_masks_inds[i][1]]
|
|
off_outputs = outputs[self._partition_masks_inds[i][0]]
|
|
row_values[i] = (on_outputs - off_outputs).mean(0)
|
|
|
|
# compute the main effects if we need to
|
|
main_effect_values = None
|
|
if main_effects or interactions is True or interactions == 2:
|
|
if inds is None:
|
|
inds = np.arange(len(fm))
|
|
main_effect_values = fm.main_effects(inds)
|
|
if interactions is True or interactions == 2:
|
|
for i in range(len(fm)):
|
|
row_values[i, i] = main_effect_values[i]
|
|
|
|
return {
|
|
"values": row_values,
|
|
"expected_values": outputs[0],
|
|
"mask_shapes": fm.mask_shapes,
|
|
"main_effects": main_effect_values if main_effects else None,
|
|
"clustering": getattr(self.masker, "clustering", None),
|
|
}
|
|
|
|
|
|
@njit
|
|
def _compute_grey_code_row_values_st(
|
|
row_values: npt.NDArray[Any],
|
|
mask: npt.NDArray[np.bool_],
|
|
inds: npt.NDArray[np.intp],
|
|
outputs: npt.NDArray[Any],
|
|
shapley_coeff: npt.NDArray[Any],
|
|
extended_delta_indexes: npt.NDArray[np.intp],
|
|
noop_code: int,
|
|
) -> None:
|
|
set_size = 0
|
|
M = len(inds)
|
|
for i in range(2**M):
|
|
# update the mask
|
|
delta_ind = extended_delta_indexes[i]
|
|
if delta_ind != noop_code:
|
|
mask[delta_ind] = ~mask[delta_ind]
|
|
if mask[delta_ind]:
|
|
set_size += 1
|
|
else:
|
|
set_size -= 1
|
|
|
|
# distribute the effect of this mask set over all the terms it impacts
|
|
out = outputs[i]
|
|
for j in range(M):
|
|
for k in range(j + 1, M):
|
|
if not mask[j] and not mask[k]:
|
|
delta = out * shapley_coeff[set_size] # * 2
|
|
elif (not mask[j] and mask[k]) or (mask[j] and not mask[k]):
|
|
delta = -out * shapley_coeff[set_size - 1] # * 2
|
|
else: # both true
|
|
delta = out * shapley_coeff[set_size - 2] # * 2
|
|
row_values[j, k] += delta
|
|
row_values[k, j] += delta
|
|
|
|
|
|
def partition_delta_indexes(partition_tree: npt.NDArray[Any], all_masks: npt.NDArray[np.bool_]) -> npt.NDArray[np.intp]:
|
|
"""Return an delta index encoded array of all the masks possible while following the given partition tree."""
|
|
# convert the masks to delta index format
|
|
mask = np.zeros(all_masks.shape[1], dtype=bool)
|
|
delta_inds = []
|
|
for i in range(len(all_masks)):
|
|
inds = np.where(mask ^ all_masks[i, :])[0]
|
|
|
|
for j in inds[:-1]:
|
|
delta_inds.append(-j - 1) # negative + (-1) means we have more inds still to change...
|
|
if len(inds) == 0:
|
|
delta_inds.append(MaskedModel.delta_mask_noop_value)
|
|
else:
|
|
delta_inds.extend(inds[-1:])
|
|
mask = all_masks[i, :]
|
|
|
|
return np.array(delta_inds)
|
|
|
|
|
|
def partition_masks(
|
|
partition_tree: npt.NDArray[Any],
|
|
) -> tuple[npt.NDArray[np.bool_], list[list[npt.NDArray[np.intp]]]]:
|
|
"""Return an array of all the masks possible while following the given partition tree."""
|
|
M = partition_tree.shape[0] + 1
|
|
mask_matrix = make_masks(partition_tree)
|
|
all_masks = []
|
|
m00 = np.zeros(M, dtype=bool)
|
|
all_masks.append(m00)
|
|
all_masks.append(~m00)
|
|
# inds_stack = [0,1]
|
|
inds_lists: list[list[list[int]]] = [[[], []] for i in range(M)] # type: ignore[var-annotated]
|
|
_partition_masks_recurse(len(partition_tree) - 1, m00, 0, 1, inds_lists, mask_matrix, partition_tree, M, all_masks)
|
|
|
|
all_masks = np.array(all_masks) # type: ignore[assignment]
|
|
|
|
# we resort the clustering matrix to minimize the sequential difference between the masks
|
|
# this minimizes the number of model evaluations we need to run when the background sometimes
|
|
# matches the foreground. We seem to average about 1.5 feature changes per mask with this
|
|
# approach. This is not as clean as the grey code ordering, but a perfect 1 feature change
|
|
# ordering is not possible with a clustering tree
|
|
order = delta_minimization_order(all_masks)
|
|
inverse_order = np.arange(len(order))[np.argsort(order)]
|
|
|
|
for inds_list0, inds_list1 in inds_lists:
|
|
for i in range(len(inds_list0)):
|
|
inds_list0[i] = inverse_order[inds_list0[i]]
|
|
inds_list1[i] = inverse_order[inds_list1[i]]
|
|
|
|
# Care: inds_lists have different lengths, so partition_masks_inds is a "ragged" array. See GH #3063
|
|
partition_masks = all_masks[order]
|
|
partition_masks_inds = [[np.array(on), np.array(off)] for on, off in inds_lists]
|
|
return partition_masks, partition_masks_inds
|
|
|
|
|
|
# TODO: this should be a jit function... which would require preallocating the inds_lists (sizes are 2**depth of that ind)
|
|
# TODO: we could also probable avoid making the masks at all and just record the deltas if we want...
|
|
def _partition_masks_recurse(
|
|
index: int,
|
|
m00: npt.NDArray[np.bool_],
|
|
ind00: int,
|
|
ind11: int,
|
|
inds_lists: list[list[list[int]]],
|
|
mask_matrix: npt.NDArray[np.bool_],
|
|
partition_tree: npt.NDArray[Any],
|
|
M: int,
|
|
all_masks: list[npt.NDArray[np.bool_]],
|
|
) -> None:
|
|
if index < 0:
|
|
inds_lists[index + M][0].append(ind00)
|
|
inds_lists[index + M][1].append(ind11)
|
|
return
|
|
|
|
# get our children indexes
|
|
left_index = int(partition_tree[index, 0] - M)
|
|
right_index = int(partition_tree[index, 1] - M)
|
|
|
|
# build more refined masks
|
|
m10 = m00.copy() # we separate the copy from the add so as to not get converted to a matrix
|
|
m10[:] += mask_matrix[left_index + M, :]
|
|
m01 = m00.copy()
|
|
m01[:] += mask_matrix[right_index + M, :]
|
|
|
|
# record the new masks we made
|
|
ind01 = len(all_masks)
|
|
all_masks.append(m01)
|
|
ind10 = len(all_masks)
|
|
all_masks.append(m10)
|
|
|
|
# inds_stack.append(len(all_masks) - 2)
|
|
# inds_stack.append(len(all_masks) - 1)
|
|
|
|
# recurse left and right with both 1 (True) and 0 (False) contexts
|
|
_partition_masks_recurse(left_index, m00, ind00, ind10, inds_lists, mask_matrix, partition_tree, M, all_masks)
|
|
_partition_masks_recurse(right_index, m10, ind10, ind11, inds_lists, mask_matrix, partition_tree, M, all_masks)
|
|
_partition_masks_recurse(left_index, m01, ind01, ind11, inds_lists, mask_matrix, partition_tree, M, all_masks)
|
|
_partition_masks_recurse(right_index, m00, ind00, ind01, inds_lists, mask_matrix, partition_tree, M, all_masks)
|
|
|
|
|
|
def gray_code_masks(nbits: int) -> npt.NDArray[np.bool_]:
|
|
"""Produces an array of all binary patterns of size nbits in gray code order.
|
|
|
|
This is based on code from: http://code.activestate.com/recipes/576592-gray-code-generatoriterator/
|
|
"""
|
|
out = np.zeros((2**nbits, nbits), dtype=bool)
|
|
li = np.zeros(nbits, dtype=bool)
|
|
|
|
for term in range(2, (1 << nbits) + 1):
|
|
if term % 2 == 1: # odd
|
|
for i in range(-1, -nbits, -1):
|
|
if li[i] == 1:
|
|
li[i - 1] = li[i - 1] ^ 1
|
|
break
|
|
else: # even
|
|
li[-1] = li[-1] ^ 1
|
|
|
|
out[term - 1, :] = li
|
|
return out
|
|
|
|
|
|
def gray_code_indexes(nbits: int) -> npt.NDArray[np.intp]:
|
|
"""Produces an array of which bits flip at which position.
|
|
|
|
We assume the masks start at all zero and -1 means don't do a flip.
|
|
This is a more efficient representation of the gray_code_masks version.
|
|
"""
|
|
out = np.ones(2**nbits, dtype=int) * MaskedModel.delta_mask_noop_value
|
|
li = np.zeros(nbits, dtype=bool)
|
|
for term in range((1 << nbits) - 1):
|
|
if term % 2 == 1: # odd
|
|
for i in range(-1, -nbits, -1):
|
|
if li[i] == 1:
|
|
li[i - 1] = li[i - 1] ^ 1
|
|
out[term + 1] = nbits + (i - 1)
|
|
break
|
|
else: # even
|
|
li[-1] = li[-1] ^ 1
|
|
out[term + 1] = nbits - 1
|
|
return out
|