1008 lines
38 KiB
Python
1008 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import operator
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
import pandas as pd
|
|
import scipy.cluster
|
|
import scipy.sparse
|
|
import scipy.spatial
|
|
import sklearn
|
|
from slicer import Alias, Obj, Slicer
|
|
|
|
from .utils._clustering import hclust_ordering
|
|
from .utils._exceptions import DimensionError
|
|
from .utils._general import OpChain
|
|
|
|
op_chain_root = OpChain("shap.Explanation")
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Sequence
|
|
|
|
|
|
@dataclass
|
|
class OpHistoryItem:
|
|
"""An operation that has been applied to an Explanation object."""
|
|
|
|
name: str
|
|
prev_shape: tuple[int, ...]
|
|
args: tuple[Any, ...] = ()
|
|
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
collapsed_instances: bool = False
|
|
|
|
|
|
class MetaExplanation(type):
|
|
"""This metaclass exposes the Explanation object's class methods for creating template op chains."""
|
|
|
|
def __getitem__(cls, item: Any) -> OpChain:
|
|
return op_chain_root.__getitem__(item)
|
|
|
|
@property
|
|
def abs(cls) -> OpChain:
|
|
"""Element-wise absolute value op."""
|
|
return op_chain_root.abs
|
|
|
|
@property
|
|
def identity(cls) -> OpChain:
|
|
"""A no-op."""
|
|
return op_chain_root.identity
|
|
|
|
@property
|
|
def argsort(cls) -> OpChain:
|
|
"""Numpy style argsort."""
|
|
return op_chain_root.argsort
|
|
|
|
@property
|
|
def flip(cls) -> OpChain:
|
|
"""Numpy style flip."""
|
|
return op_chain_root.flip
|
|
|
|
@property
|
|
def sum(cls) -> OpChain:
|
|
"""Numpy style sum."""
|
|
return op_chain_root.sum
|
|
|
|
@property
|
|
def max(cls) -> OpChain:
|
|
"""Numpy style max."""
|
|
return op_chain_root.max
|
|
|
|
@property
|
|
def min(cls) -> OpChain:
|
|
"""Numpy style min."""
|
|
return op_chain_root.min
|
|
|
|
@property
|
|
def mean(cls) -> OpChain:
|
|
"""Numpy style mean."""
|
|
return op_chain_root.mean
|
|
|
|
@property
|
|
def sample(cls) -> OpChain:
|
|
"""Numpy style sample."""
|
|
return op_chain_root.sample
|
|
|
|
@property
|
|
def hclust(cls) -> OpChain:
|
|
"""Hierarchical clustering op."""
|
|
return op_chain_root.hclust
|
|
|
|
|
|
class Explanation(metaclass=MetaExplanation):
|
|
"""A sliceable set of parallel arrays representing a SHAP explanation.
|
|
|
|
Notes
|
|
-----
|
|
The *instance* methods such as `.max()` return new Explanation objects with the
|
|
operation applied.
|
|
|
|
The *class* methods such as `Explanation.max` return OpChain objects that represent
|
|
a set of dot chained operations without actually running them.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
values: npt.NDArray[Any] | list[Any] | Explanation,
|
|
base_values: npt.NDArray[Any] | list[Any] | float | None = None,
|
|
data: npt.NDArray[Any] | pd.DataFrame | list[Any] | None = None,
|
|
display_data: npt.NDArray[Any] | pd.DataFrame | None = None,
|
|
instance_names: Sequence[str] | npt.NDArray[Any] | None = None,
|
|
feature_names: Sequence[str] | npt.NDArray[Any] | Alias | None = None,
|
|
output_names: Sequence[str] | npt.NDArray[Any] | str | Alias | None = None,
|
|
output_indexes: npt.NDArray[Any] | None = None,
|
|
lower_bounds: npt.NDArray[Any] | None = None,
|
|
upper_bounds: npt.NDArray[Any] | None = None,
|
|
error_std: npt.NDArray[Any] | None = None,
|
|
main_effects: npt.NDArray[Any] | None = None,
|
|
hierarchical_values: npt.NDArray[Any] | list[Any] | None = None,
|
|
clustering: npt.NDArray[Any] | list[Any] | None = None,
|
|
compute_time: float | None = None,
|
|
) -> None:
|
|
self.op_history: list[OpHistoryItem] = []
|
|
|
|
self.compute_time = compute_time
|
|
|
|
# TODO: better cloning :)
|
|
if isinstance(values, Explanation):
|
|
e = values
|
|
values = e.values
|
|
base_values = e.base_values
|
|
data = e.data
|
|
|
|
self.output_dims = compute_output_dims(values, base_values, data, output_names)
|
|
values_shape = _compute_shape(values)
|
|
|
|
if output_names is None and len(self.output_dims) == 1:
|
|
num_names = values_shape[self.output_dims[0]]
|
|
assert num_names is not None, "Unexpected shape of values"
|
|
output_names = [f"Output {i}" for i in range(num_names)]
|
|
|
|
if (
|
|
feature_names is not None and len(_compute_shape(feature_names)) == 1
|
|
): # TODO: should always be an alias once slicer supports per-row aliases
|
|
if len(values_shape) >= 2 and len(feature_names) == values_shape[1]:
|
|
feature_names = Alias(list(feature_names), 1) # type: ignore[arg-type]
|
|
elif len(values_shape) >= 1 and len(feature_names) == values_shape[0]:
|
|
feature_names = Alias(list(feature_names), 0) # type: ignore[arg-type]
|
|
|
|
if (
|
|
output_names is not None and len(_compute_shape(output_names)) == 1
|
|
): # TODO: should always be an alias once slicer supports per-row aliases
|
|
output_names = Alias(list(output_names), self.output_dims[0]) # type: ignore[arg-type]
|
|
# if len(values_shape) >= 1 and len(output_names) == values_shape[0]:
|
|
# output_names = Alias(list(output_names), 0)
|
|
# elif len(values_shape) >= 2 and len(output_names) == values_shape[1]:
|
|
# output_names = Alias(list(output_names), 1)
|
|
|
|
if output_names is not None and not isinstance(output_names, Alias):
|
|
output_names_order = len(_compute_shape(output_names))
|
|
if output_names_order == 0:
|
|
pass
|
|
elif output_names_order == 1:
|
|
output_names = Obj(output_names, self.output_dims)
|
|
elif output_names_order == 2:
|
|
output_names = Obj(output_names, [0] + list(self.output_dims))
|
|
else:
|
|
raise ValueError("shap.Explanation does not yet support output_names of order greater than 3!")
|
|
|
|
if base_values is None or not hasattr(base_values, "__len__") or len(base_values) == 0:
|
|
pass
|
|
elif len(_compute_shape(base_values)) == len(self.output_dims):
|
|
base_values = Obj(base_values, list(self.output_dims))
|
|
else:
|
|
base_values = Obj(base_values, [0] + list(self.output_dims))
|
|
|
|
self._s = Slicer(
|
|
values=values,
|
|
base_values=base_values,
|
|
data=list_wrap(data),
|
|
display_data=list_wrap(display_data),
|
|
instance_names=None if instance_names is None else Alias(instance_names, 0),
|
|
feature_names=feature_names,
|
|
output_names=output_names,
|
|
output_indexes=None if output_indexes is None else (self.output_dims, output_indexes),
|
|
lower_bounds=list_wrap(lower_bounds),
|
|
upper_bounds=list_wrap(upper_bounds),
|
|
error_std=list_wrap(error_std),
|
|
main_effects=list_wrap(main_effects),
|
|
hierarchical_values=list_wrap(hierarchical_values),
|
|
clustering=None if clustering is None else Obj(clustering, [0]),
|
|
)
|
|
|
|
# =================== Slicer passthrough ===================
|
|
|
|
@property
|
|
def values(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.values
|
|
|
|
@values.setter
|
|
def values(self, new_values):
|
|
self._s.values = new_values
|
|
|
|
@property
|
|
def base_values(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.base_values
|
|
|
|
@base_values.setter
|
|
def base_values(self, new_base_values):
|
|
self._s.base_values = new_base_values
|
|
|
|
@property
|
|
def data(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.data
|
|
|
|
@data.setter
|
|
def data(self, new_data):
|
|
self._s.data = new_data
|
|
|
|
@property
|
|
def display_data(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.display_data
|
|
|
|
@display_data.setter
|
|
def display_data(self, new_display_data):
|
|
if isinstance(new_display_data, pd.DataFrame):
|
|
new_display_data = new_display_data.values
|
|
self._s.display_data = new_display_data
|
|
|
|
@property
|
|
def instance_names(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.instance_names
|
|
|
|
@property
|
|
def output_names(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.output_names
|
|
|
|
@output_names.setter
|
|
def output_names(self, new_output_names):
|
|
self._s.output_names = new_output_names
|
|
|
|
@property
|
|
def output_indexes(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.output_indexes
|
|
|
|
@property
|
|
def feature_names(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.feature_names
|
|
|
|
@feature_names.setter
|
|
def feature_names(self, new_feature_names):
|
|
self._s.feature_names = new_feature_names
|
|
|
|
@property
|
|
def lower_bounds(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.lower_bounds
|
|
|
|
@property
|
|
def upper_bounds(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.upper_bounds
|
|
|
|
@property
|
|
def error_std(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.error_std
|
|
|
|
@property
|
|
def main_effects(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.main_effects
|
|
|
|
@main_effects.setter
|
|
def main_effects(self, new_main_effects):
|
|
self._s.main_effects = new_main_effects
|
|
|
|
@property
|
|
def hierarchical_values(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.hierarchical_values
|
|
|
|
@hierarchical_values.setter
|
|
def hierarchical_values(self, new_hierarchical_values):
|
|
self._s.hierarchical_values = new_hierarchical_values
|
|
|
|
@property
|
|
def clustering(self):
|
|
"""Pass-through from the underlying slicer object."""
|
|
return self._s.clustering
|
|
|
|
@clustering.setter
|
|
def clustering(self, new_clustering):
|
|
self._s.clustering = new_clustering
|
|
|
|
# =================== Data model ===================
|
|
def __repr__(self) -> str:
|
|
"""Display some basic printable info, but not everything."""
|
|
out = f".values =\n{self.values!r}"
|
|
if self.base_values is not None:
|
|
out += f"\n\n.base_values =\n{self.base_values!r}"
|
|
if self.data is not None:
|
|
out += f"\n\n.data =\n{self.data!r}"
|
|
return out
|
|
|
|
def __getitem__(self, item) -> Explanation:
|
|
"""This adds support for OpChain indexing."""
|
|
new_self = None
|
|
if not isinstance(item, tuple):
|
|
item = (item,)
|
|
|
|
# convert any OpChains or magic strings
|
|
pos = -1
|
|
for t in item:
|
|
pos += 1
|
|
|
|
# skip over Ellipsis
|
|
if t is Ellipsis:
|
|
pos += len(self.shape) - len(item)
|
|
continue
|
|
|
|
orig_t = t
|
|
if isinstance(t, OpChain):
|
|
t = t.apply(self)
|
|
if isinstance(t, (np.int64, np.int32)): # because slicer does not like numpy indexes
|
|
t = int(t)
|
|
elif isinstance(t, np.ndarray):
|
|
t = [int(v) for v in t] # slicer wants lists not numpy arrays for indexing
|
|
elif isinstance(t, Explanation):
|
|
t = t.values
|
|
elif isinstance(t, str):
|
|
# work around for 2D output_names since they are not yet slicer supported
|
|
output_names_dims = []
|
|
if "output_names" in self._s._objects:
|
|
output_names_dims = self._s._objects["output_names"].dim
|
|
elif "output_names" in self._s._aliases:
|
|
output_names_dims = self._s._aliases["output_names"].dim
|
|
if pos != 0 and pos in output_names_dims:
|
|
if len(output_names_dims) == 1:
|
|
t = np.argwhere(np.array(self.output_names) == t)[0][0]
|
|
elif len(output_names_dims) == 2:
|
|
new_values = []
|
|
new_base_values = []
|
|
new_data = []
|
|
new_self = copy.deepcopy(self)
|
|
for i, v in enumerate(self.values):
|
|
for j, s in enumerate(self.output_names[i]):
|
|
if s == t:
|
|
new_values.append(np.array(v[:, j]))
|
|
new_data.append(np.array(self.data[i]))
|
|
new_base_values.append(self.base_values[i][j])
|
|
|
|
new_self = Explanation(
|
|
np.array(new_values),
|
|
base_values=np.array(new_base_values),
|
|
data=np.array(new_data),
|
|
display_data=self.display_data,
|
|
instance_names=self.instance_names,
|
|
feature_names=np.array(new_data), # FIXME: this is probably a bug
|
|
output_names=t,
|
|
output_indexes=self.output_indexes,
|
|
lower_bounds=self.lower_bounds,
|
|
upper_bounds=self.upper_bounds,
|
|
error_std=self.error_std,
|
|
main_effects=self.main_effects,
|
|
hierarchical_values=self.hierarchical_values,
|
|
clustering=self.clustering,
|
|
)
|
|
new_self.op_history = copy.copy(self.op_history)
|
|
# new_self = copy.deepcopy(self)
|
|
# new_self.values = np.array(new_values)
|
|
# new_self.base_values = np.array(new_base_values)
|
|
# new_self.data = np.array(new_data)
|
|
# new_self.output_names = t
|
|
# new_self.feature_names = np.array(new_data)
|
|
# new_self.clustering = None
|
|
|
|
# work around for 2D feature_names since they are not yet slicer supported
|
|
feature_names_dims = []
|
|
if "feature_names" in self._s._objects:
|
|
feature_names_dims = self._s._objects["feature_names"].dim
|
|
if pos != 0 and pos in feature_names_dims and len(feature_names_dims) == 2:
|
|
new_values = []
|
|
new_data = []
|
|
for i, val_i in enumerate(self.values):
|
|
for s, v, d in zip(self.feature_names[i], val_i, self.data[i]):
|
|
if s == t:
|
|
new_values.append(v)
|
|
new_data.append(d)
|
|
new_self = copy.deepcopy(self)
|
|
new_self.values = new_values
|
|
new_self.data = new_data
|
|
new_self.feature_names = t
|
|
new_self.clustering = None
|
|
# return new_self
|
|
|
|
if isinstance(t, (np.int8, np.int16, np.int32, np.int64)):
|
|
t = int(t)
|
|
|
|
if t is not orig_t:
|
|
tmp = list(item)
|
|
tmp[pos] = t
|
|
item = tuple(tmp)
|
|
|
|
# call slicer for the real work
|
|
item = tuple(v for v in item) # SML I cut out: `if not isinstance(v, str)`
|
|
if len(item) == 0:
|
|
return new_self # type: ignore
|
|
if new_self is None:
|
|
new_self = copy.copy(self)
|
|
new_self._s = new_self._s.__getitem__(item)
|
|
new_self.op_history.append(OpHistoryItem(name="__getitem__", args=(item,), prev_shape=self.shape))
|
|
|
|
return new_self
|
|
|
|
@property
|
|
def shape(self) -> tuple[int, ...]:
|
|
"""Compute the shape over potentially complex data nesting."""
|
|
shap_values_shape = _compute_shape(self._s.values)
|
|
# impl: `Explanation.values` always corresponds to the shap values, which is a numpy array, so the
|
|
# shape will always be of tuple[int, ...] type, not tuple[int|None, ...].
|
|
return cast("tuple[int, ...]", shap_values_shape)
|
|
|
|
def __len__(self) -> int:
|
|
return self.shape[0]
|
|
|
|
def __copy__(self) -> Explanation:
|
|
new_exp = Explanation(
|
|
self.values,
|
|
base_values=self.base_values,
|
|
data=self.data,
|
|
display_data=self.display_data,
|
|
instance_names=self.instance_names,
|
|
feature_names=self.feature_names,
|
|
output_names=self.output_names,
|
|
output_indexes=self.output_indexes,
|
|
lower_bounds=self.lower_bounds,
|
|
upper_bounds=self.upper_bounds,
|
|
error_std=self.error_std,
|
|
main_effects=self.main_effects,
|
|
hierarchical_values=self.hierarchical_values,
|
|
clustering=self.clustering,
|
|
)
|
|
new_exp.op_history = copy.copy(self.op_history)
|
|
return new_exp
|
|
|
|
# =================== Operations ===================
|
|
|
|
def _apply_binary_operator(
|
|
self,
|
|
other: Explanation | npt.NDArray[Any] | float | int,
|
|
binary_op: Callable[[Any, Any], Any],
|
|
op_name: str,
|
|
) -> Explanation:
|
|
new_exp = self.__copy__()
|
|
new_exp.op_history.append(OpHistoryItem(name=op_name, args=(other,), prev_shape=self.shape))
|
|
|
|
if isinstance(other, Explanation):
|
|
new_exp.values = binary_op(new_exp.values, other.values)
|
|
if new_exp.data is not None:
|
|
new_exp.data = binary_op(new_exp.data, other.data)
|
|
if new_exp.base_values is not None:
|
|
new_exp.base_values = binary_op(new_exp.base_values, other.base_values)
|
|
else:
|
|
new_exp.values = binary_op(new_exp.values, other)
|
|
if new_exp.data is not None:
|
|
new_exp.data = binary_op(new_exp.data, other)
|
|
if new_exp.base_values is not None:
|
|
new_exp.base_values = binary_op(new_exp.base_values, other)
|
|
return new_exp
|
|
|
|
def __add__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation:
|
|
return self._apply_binary_operator(other, operator.add, "__add__")
|
|
|
|
def __radd__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation: # type: ignore[misc]
|
|
return self._apply_binary_operator(other, operator.add, "__add__")
|
|
|
|
def __sub__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation:
|
|
return self._apply_binary_operator(other, operator.sub, "__sub__")
|
|
|
|
def __rsub__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation: # type: ignore[misc]
|
|
return self._apply_binary_operator(other, operator.sub, "__sub__")
|
|
|
|
def __mul__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation:
|
|
return self._apply_binary_operator(other, operator.mul, "__mul__")
|
|
|
|
def __rmul__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation: # type: ignore[misc]
|
|
return self._apply_binary_operator(other, operator.mul, "__mul__")
|
|
|
|
def __truediv__(self, other: Explanation | npt.NDArray[Any] | float | int) -> Explanation:
|
|
return self._apply_binary_operator(other, operator.truediv, "__truediv__")
|
|
|
|
def _numpy_func(self, fname: str, **kwargs: Any) -> Explanation:
|
|
"""Apply a numpy-style function to this Explanation."""
|
|
new_self = copy.copy(self)
|
|
axis = kwargs.get("axis", None)
|
|
|
|
# collapse the slicer to right shape
|
|
if axis in [0, 1, 2]:
|
|
new_self = new_self[axis]
|
|
new_self.op_history = new_self.op_history[:-1] # pop off the slicing operation we just used
|
|
|
|
if self.feature_names is not None and not is_1d(self.feature_names) and axis == 0:
|
|
new_values = self._flatten_feature_names()
|
|
new_self.feature_names = np.array(list(new_values.keys()))
|
|
new_self.values = np.array([getattr(np, fname)(v, 0) for v in new_values.values()])
|
|
new_self.clustering = None
|
|
else:
|
|
new_self.values = getattr(np, fname)(np.array(self.values), **kwargs)
|
|
if new_self.data is not None:
|
|
try:
|
|
new_self.data = getattr(np, fname)(np.array(self.data), **kwargs)
|
|
except Exception:
|
|
new_self.data = None
|
|
if new_self.base_values is not None and isinstance(axis, int) and len(self.base_values.shape) > axis:
|
|
new_self.base_values = getattr(np, fname)(self.base_values, **kwargs)
|
|
elif isinstance(axis, int):
|
|
new_self.base_values = None
|
|
|
|
if axis == 0 and self.clustering is not None and len(self.clustering.shape) == 3:
|
|
if self.clustering.std(0).sum() < 1e-8:
|
|
new_self.clustering = self.clustering[0]
|
|
else:
|
|
new_self.clustering = None
|
|
|
|
new_self.op_history.append(
|
|
OpHistoryItem(
|
|
name=fname,
|
|
kwargs=kwargs,
|
|
prev_shape=self.shape,
|
|
collapsed_instances=axis == 0,
|
|
),
|
|
)
|
|
|
|
return new_self
|
|
|
|
@property
|
|
def abs(self) -> Explanation:
|
|
return self._numpy_func("abs")
|
|
|
|
@property
|
|
def identity(self) -> Explanation:
|
|
return self
|
|
|
|
@property
|
|
def argsort(self) -> Explanation:
|
|
return self._numpy_func("argsort")
|
|
|
|
@property
|
|
def flip(self) -> Explanation:
|
|
return self._numpy_func("flip")
|
|
|
|
def mean(self, axis: int) -> Explanation:
|
|
"""Numpy-style mean function."""
|
|
return self._numpy_func("mean", axis=axis)
|
|
|
|
def max(self, axis: int) -> Explanation:
|
|
"""Numpy-style max function."""
|
|
return self._numpy_func("max", axis=axis)
|
|
|
|
def min(self, axis: int) -> Explanation:
|
|
"""Numpy-style min function."""
|
|
return self._numpy_func("min", axis=axis)
|
|
|
|
def sum(self, axis: int | None = None, grouping: dict[str, str] | None = None) -> Explanation:
|
|
"""Numpy-style sum function."""
|
|
if grouping is None:
|
|
return self._numpy_func("sum", axis=axis)
|
|
if axis == 1 or len(self.shape) == 1:
|
|
return group_features(self, grouping)
|
|
raise DimensionError("Only axis = 1 is supported for grouping right now...")
|
|
|
|
def percentile(self, q: float, axis: int | None = None) -> Explanation:
|
|
new_self = copy.deepcopy(self)
|
|
if self.feature_names is not None and not is_1d(self.feature_names) and axis == 0:
|
|
new_values = self._flatten_feature_names()
|
|
new_self.feature_names = np.array(list(new_values.keys()))
|
|
new_self.values = np.array([np.percentile(v, q) for v in new_values.values()])
|
|
new_self.clustering = None
|
|
else:
|
|
new_self.values = np.percentile(new_self.values, q, axis)
|
|
new_self.data = np.percentile(new_self.data, q, axis)
|
|
# new_self.data = None
|
|
new_self.op_history.append(
|
|
OpHistoryItem(
|
|
name="percentile",
|
|
args=(axis,),
|
|
prev_shape=self.shape,
|
|
collapsed_instances=axis == 0,
|
|
),
|
|
)
|
|
return new_self
|
|
|
|
def sample(self, max_samples: int, replace: bool = False, random_state: int = 0) -> Explanation:
|
|
"""Randomly samples the instances (rows) of the Explanation object.
|
|
|
|
Parameters
|
|
----------
|
|
max_samples : int
|
|
The number of rows to sample. Note that if ``replace=False``, then
|
|
fewer than max_samples will be drawn if ``len(explanation) < max_samples``.
|
|
|
|
replace : bool
|
|
Sample with or without replacement.
|
|
|
|
random_state : int
|
|
Random seed to use for sampling, defaults to 0.
|
|
|
|
"""
|
|
rng = np.random.RandomState(random_state)
|
|
length = self.shape[0]
|
|
assert length is not None
|
|
inds = rng.choice(length, size=min(max_samples, length), replace=replace)
|
|
prev_shape = self.shape
|
|
new_self = self[list(inds)]
|
|
# Replace the __getitem__ entry with "sample" so op_history
|
|
# reflects the high-level operation the user actually called.
|
|
new_self.op_history[-1] = OpHistoryItem(name="sample", args=(max_samples,), prev_shape=prev_shape)
|
|
return new_self
|
|
|
|
def hclust(self, metric: str = "sqeuclidean", axis: Literal[0, 1] = 0) -> npt.NDArray[np.int64]:
|
|
"""Computes an optimal leaf ordering sort order using hclustering.
|
|
|
|
hclust(metric="sqeuclidean")
|
|
|
|
Parameters
|
|
----------
|
|
metric : str
|
|
A metric supported by scipy clustering. Defaults to "sqeuclidean".
|
|
|
|
axis : int
|
|
The axis to cluster along.
|
|
|
|
"""
|
|
values = self.values
|
|
|
|
if len(values.shape) != 2:
|
|
raise DimensionError("The hclust order only supports 2D arrays right now!")
|
|
|
|
if axis == 1:
|
|
values = values.T
|
|
|
|
return hclust_ordering(X=values, metric=metric)
|
|
|
|
# =================== Utilities ===================
|
|
|
|
def hstack(self, other: Explanation) -> Explanation:
|
|
"""Stack two explanations column-wise.
|
|
|
|
Parameters
|
|
----------
|
|
other : shap.Explanation
|
|
The other Explanation object to stack with.
|
|
|
|
Returns
|
|
-------
|
|
exp : shap.Explanation
|
|
A new Explanation object representing the stacked explanations.
|
|
|
|
"""
|
|
assert self.shape[0] == other.shape[0], "Can't hstack explanations with different numbers of rows!"
|
|
if not np.allclose(self.base_values, other.base_values, atol=1e-6):
|
|
raise ValueError("Can't hstack explanations with different base values!")
|
|
|
|
new_exp = Explanation(
|
|
values=np.hstack([self.values, other.values]),
|
|
base_values=self.base_values,
|
|
data=self.data,
|
|
display_data=self.display_data,
|
|
instance_names=self.instance_names,
|
|
feature_names=self.feature_names,
|
|
output_names=self.output_names,
|
|
output_indexes=self.output_indexes,
|
|
lower_bounds=self.lower_bounds,
|
|
upper_bounds=self.upper_bounds,
|
|
error_std=self.error_std,
|
|
main_effects=self.main_effects,
|
|
hierarchical_values=self.hierarchical_values,
|
|
clustering=self.clustering,
|
|
)
|
|
return new_exp
|
|
|
|
def cohorts(self, cohorts: int | list[int] | tuple[int] | np.ndarray) -> Cohorts:
|
|
"""Split this explanation into several cohorts.
|
|
|
|
Parameters
|
|
----------
|
|
cohorts : int or array
|
|
If this is an integer then we auto build that many cohorts using a decision tree. If this is
|
|
an array then we treat that as an array of cohort names/ids for each instance.
|
|
|
|
Returns
|
|
-------
|
|
Cohorts object
|
|
|
|
"""
|
|
if self.values.ndim > 2:
|
|
raise ValueError(
|
|
"Cohorts cannot be calculated on multiple outputs at once. "
|
|
"Please make sure to specify the output index on which cohorts should be build, e.g. for a multi-class output "
|
|
"shap_values[..., cohort_class].cohorts(2)."
|
|
)
|
|
if isinstance(cohorts, int):
|
|
return _auto_cohorts(self, max_cohorts=cohorts)
|
|
if isinstance(cohorts, (list, tuple, np.ndarray)):
|
|
cohorts = np.array(cohorts)
|
|
return Cohorts(**{name: self[cohorts == name] for name in np.unique(cohorts)})
|
|
raise TypeError("The given set of cohort indicators is not recognized! Please give an array or int.")
|
|
|
|
def _flatten_feature_names(self) -> dict[Any, list[Any]]:
|
|
new_values: dict[Any, list[Any]] = {}
|
|
for i in range(len(self.values)):
|
|
for s, v in zip(self.feature_names[i], self.values[i]):
|
|
if s not in new_values:
|
|
new_values[s] = []
|
|
new_values[s].append(v)
|
|
return new_values
|
|
|
|
def _use_data_as_feature_names(self) -> dict[Any, list[Any]]:
|
|
new_values: dict[Any, list[Any]] = {}
|
|
for i in range(len(self.values)):
|
|
for s, v in zip(self.data[i], self.values[i]):
|
|
if s not in new_values:
|
|
new_values[s] = []
|
|
new_values[s].append(v)
|
|
return new_values
|
|
|
|
|
|
def group_features(shap_values: Explanation, feature_map: dict[str, str]) -> Explanation:
|
|
# TODO: support and deal with clusterings
|
|
reverse_map: dict[Any, list[Any]] = {}
|
|
for name in feature_map:
|
|
reverse_map[feature_map[name]] = reverse_map.get(feature_map[name], []) + [name]
|
|
|
|
curr_names = shap_values.feature_names
|
|
sv_new = copy.deepcopy(shap_values)
|
|
found = {}
|
|
i = 0
|
|
rank1 = len(shap_values.shape) == 1
|
|
for name in curr_names:
|
|
new_name = feature_map.get(name, name)
|
|
if new_name in found:
|
|
continue
|
|
found[new_name] = True
|
|
|
|
new_name = feature_map.get(name, name)
|
|
cols_to_sum = reverse_map.get(new_name, [new_name])
|
|
old_inds = [curr_names.index(v) for v in cols_to_sum]
|
|
|
|
if rank1:
|
|
sv_new.values[i] = shap_values.values[old_inds].sum()
|
|
sv_new.data[i] = shap_values.data[old_inds].sum()
|
|
else:
|
|
sv_new.values[:, i] = shap_values.values[:, old_inds].sum(1)
|
|
sv_new.data[:, i] = shap_values.data[:, old_inds].sum(1)
|
|
sv_new.feature_names[i] = new_name
|
|
i += 1
|
|
|
|
return Explanation(
|
|
sv_new.values[:i] if rank1 else sv_new.values[:, :i],
|
|
base_values=sv_new.base_values,
|
|
data=sv_new.data[:i] if rank1 else sv_new.data[:, :i],
|
|
display_data=None
|
|
if sv_new.display_data is None
|
|
else (sv_new.display_data[:, :i] if rank1 else sv_new.display_data[:, :i]),
|
|
instance_names=None,
|
|
feature_names=None if sv_new.feature_names is None else sv_new.feature_names[:i],
|
|
output_names=None,
|
|
output_indexes=None,
|
|
lower_bounds=None,
|
|
upper_bounds=None,
|
|
error_std=None,
|
|
main_effects=None,
|
|
hierarchical_values=None,
|
|
clustering=None,
|
|
)
|
|
|
|
|
|
def compute_output_dims(values, base_values, data, output_names) -> tuple[int, ...]:
|
|
"""Uses the passed data to infer which dimensions correspond to the model's output."""
|
|
values_shape = _compute_shape(values)
|
|
|
|
# input shape matches the data shape
|
|
if data is not None:
|
|
data_shape = _compute_shape(data)
|
|
|
|
# if we are not given any data we assume it would be the same shape as the given values
|
|
else:
|
|
data_shape = values_shape
|
|
|
|
# output shape is known from the base values or output names
|
|
if output_names is not None:
|
|
output_shape = _compute_shape(output_names)
|
|
|
|
# if our output_names are per sample then we need to drop the sample dimension here
|
|
if (
|
|
values_shape[-len(output_shape) :] != output_shape
|
|
and values_shape[-len(output_shape) + 1 :] == output_shape[1:]
|
|
and values_shape[0] == output_shape[0]
|
|
):
|
|
output_shape = output_shape[1:]
|
|
|
|
elif base_values is not None:
|
|
output_shape = _compute_shape(base_values)[1:]
|
|
else:
|
|
output_shape = tuple()
|
|
|
|
interaction_order = len(values_shape) - len(data_shape) - len(output_shape)
|
|
output_dims = range(len(data_shape) + interaction_order, len(values_shape))
|
|
return tuple(output_dims)
|
|
|
|
|
|
def is_1d(val: Sequence[Any] | npt.NDArray[Any]) -> bool:
|
|
return not (isinstance(val[0], (list, np.ndarray)))
|
|
|
|
|
|
def _compute_shape(x) -> tuple[int | None, ...]:
|
|
"""Computes the shape of a generic object ``x``."""
|
|
|
|
def _first_item(iterable):
|
|
for item in iterable:
|
|
return item
|
|
return None
|
|
|
|
if not hasattr(x, "__len__") or isinstance(x, str):
|
|
return tuple()
|
|
if not scipy.sparse.issparse(x) and len(x) > 0 and isinstance(_first_item(x), str):
|
|
return (None,)
|
|
if isinstance(x, dict):
|
|
return (len(x),) + _compute_shape(x[next(iter(x))])
|
|
|
|
# 2D arrays: we just take their shape as-is
|
|
if len(getattr(x, "shape", tuple())) > 1:
|
|
return x.shape
|
|
|
|
# 1D arrays: we need to look inside
|
|
if len(x) == 0:
|
|
return (0,)
|
|
if len(x) == 1:
|
|
return (1,) + _compute_shape(_first_item(x))
|
|
first_shape = _compute_shape(_first_item(x))
|
|
if first_shape == tuple():
|
|
return (len(x),)
|
|
|
|
# Else we have an array of arrays...
|
|
matches = np.ones(len(first_shape), dtype=bool)
|
|
for i in range(1, len(x)):
|
|
shape = _compute_shape(x[i])
|
|
assert len(shape) == len(first_shape), "Arrays in Explanation objects must have consistent inner dimensions!"
|
|
for j in range(len(shape)):
|
|
matches[j] &= shape[j] == first_shape[j]
|
|
return (len(x),) + tuple(first_shape[j] if match else None for j, match in enumerate(matches))
|
|
|
|
|
|
class Cohorts:
|
|
"""A collection of :class:`.Explanation` objects, typically each explaining a cluster of similar samples.
|
|
|
|
Examples
|
|
--------
|
|
A ``Cohorts`` object can be initialized in a variety of ways.
|
|
|
|
By explicitly specifying the cohorts:
|
|
|
|
>>> exp = Explanation(
|
|
... values=np.random.uniform(low=-1, high=1, size=(500, 5)),
|
|
... data=np.random.normal(loc=1, scale=3, size=(500, 5)),
|
|
... feature_names=list("abcde"),
|
|
... )
|
|
>>> cohorts = Cohorts(
|
|
... col_a_neg=exp[exp[:, "a"].data < 0],
|
|
... col_a_pos=exp[exp[:, "a"].data >= 0],
|
|
... )
|
|
>>> cohorts
|
|
<shap._explanation.Cohorts object with 2 cohorts of sizes: [(198, 5), (302, 5)]>
|
|
|
|
Or using the :meth:`.Explanation.cohorts` method:
|
|
|
|
>>> cohorts2 = exp.cohorts(3)
|
|
>>> cohorts2
|
|
<shap._explanation.Cohorts object with 3 cohorts of sizes: [(182, 5), (12, 5), (306, 5)]>
|
|
|
|
Most of the :class:`.Explanation` interface is also exposed in ``Cohorts``. For example, to retrieve the
|
|
SHAP values corresponding to column 'a' across all cohorts, you can use:
|
|
|
|
>>> cohorts[..., 'a'].values
|
|
<shap._explanation.Cohorts object with 2 cohorts of sizes: [(198,), (302,)]>
|
|
|
|
To actually retrieve the values of a particular :class:`.Explanation`, you'll need to access it via the
|
|
:meth:`.Cohorts.cohorts` property:
|
|
|
|
>>> cohorts.cohorts["col_a_neg"][..., 'a'].values
|
|
array([...]) # truncated
|
|
|
|
"""
|
|
|
|
def __init__(self, **kwargs: Explanation) -> None:
|
|
self.cohorts = kwargs
|
|
self._callables: dict[str, Callable] = {}
|
|
|
|
@property
|
|
def cohorts(self) -> dict[str, Explanation]:
|
|
"""Internal collection of cohorts, stored as a dictionary."""
|
|
return self._cohorts
|
|
|
|
@cohorts.setter
|
|
def cohorts(self, cval: dict[str, Explanation]) -> None:
|
|
if not isinstance(cval, dict):
|
|
emsg = "self.cohorts must be a dictionary!"
|
|
raise TypeError(emsg)
|
|
for exp in cval.values():
|
|
if not isinstance(exp, Explanation):
|
|
emsg = f"Arguments to a Cohorts set must be Explanation objects, but found {type(exp)}"
|
|
raise TypeError(emsg)
|
|
|
|
self._cohorts: dict[str, Explanation] = cval
|
|
|
|
def __getitem__(self, item) -> Cohorts:
|
|
new_cohorts = {}
|
|
for k in self._cohorts:
|
|
new_cohorts[k] = self._cohorts[k].__getitem__(item)
|
|
return Cohorts(**new_cohorts)
|
|
|
|
def __getattr__(self, name: str) -> Cohorts:
|
|
new_cohorts = Cohorts()
|
|
for k in self._cohorts:
|
|
result = getattr(self._cohorts[k], name)
|
|
if callable(result):
|
|
new_cohorts._callables[k] = result # bound methods like .mean, .sample
|
|
else:
|
|
new_cohorts._cohorts[k] = result
|
|
return new_cohorts
|
|
|
|
def __call__(self, *args: Any, **kwargs: Any) -> Cohorts:
|
|
"""Call the bound methods on the Explanation objects retrieved during attribute access.
|
|
|
|
For example,
|
|
``Cohorts(...).mean(axis=0)`` would first run ``__getattr__("mean")`` and return a bound method
|
|
``Explanation.mean`` for all the :class:`Explanation` objects inside the ``Cohorts``, returned as a
|
|
new ``Cohorts`` object. Then the ``(axis=0)`` call would be executed on that returned ``Cohorts``
|
|
object, which is why we need ``__call__`` defined.
|
|
"""
|
|
if not self._callables:
|
|
emsg = "No methods to __call__!"
|
|
raise ValueError(emsg)
|
|
|
|
new_cohorts: dict[str, Explanation] = {}
|
|
for k, bound_method in self._callables.items():
|
|
new_cohorts[k] = bound_method(*args, **kwargs)
|
|
return Cohorts(**new_cohorts)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<shap._explanation.Cohorts object with {len(self._cohorts)} cohorts of sizes: {[v.shape for v in self._cohorts.values()]}>"
|
|
|
|
|
|
def _auto_cohorts(shap_values: Explanation, max_cohorts: int) -> Cohorts:
|
|
"""This uses a DecisionTreeRegressor to build a group of cohorts with similar SHAP values."""
|
|
# fit a decision tree that well separates the SHAP values
|
|
m = sklearn.tree.DecisionTreeRegressor(max_leaf_nodes=max_cohorts)
|
|
m.fit(shap_values.data, shap_values.values)
|
|
|
|
# group instances by their decision paths
|
|
paths = m.decision_path(shap_values.data).toarray()
|
|
path_names = []
|
|
|
|
# mark each instance with a path name
|
|
for i in range(shap_values.shape[0]):
|
|
name = ""
|
|
for j in range(len(paths[i])):
|
|
if paths[i, j] > 0:
|
|
feature = m.tree_.feature[j]
|
|
threshold = m.tree_.threshold[j]
|
|
if feature >= 0:
|
|
val = shap_values.data[i, feature]
|
|
name += str(shap_values.feature_names[feature])
|
|
if val < threshold:
|
|
name += " < "
|
|
else:
|
|
name += " >= "
|
|
name += str(threshold) + " & "
|
|
path_names.append(name[:-3]) # the -3 strips off the last unneeded ' & '
|
|
path_names_arr = np.array(path_names)
|
|
|
|
# split the instances into cohorts by their path names
|
|
cohorts = {}
|
|
for name in np.unique(path_names_arr):
|
|
cohorts[name] = shap_values[path_names_arr == name]
|
|
|
|
return Cohorts(**cohorts)
|
|
|
|
|
|
def list_wrap(x: npt.NDArray[Any] | Any) -> list[npt.NDArray[Any]] | Any:
|
|
"""A helper to patch things since slicer doesn't handle arrays of arrays (it does handle lists of arrays)"""
|
|
if isinstance(x, np.ndarray) and len(x.shape) == 1 and isinstance(x[0], np.ndarray):
|
|
return [v for v in x]
|
|
else:
|
|
return x
|