365 lines
12 KiB
Python
365 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
import pandas as pd
|
|
import scipy.special
|
|
import sklearn
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
from ._types import _ArrayT
|
|
|
|
import_errors: dict[str, tuple[str, Exception]] = {}
|
|
|
|
|
|
def assert_import(package_name: str) -> None:
|
|
global import_errors
|
|
if package_name in import_errors:
|
|
msg, e = import_errors[package_name]
|
|
print(msg)
|
|
raise e
|
|
|
|
|
|
def record_import_error(package_name: str, msg: str, e: ImportError) -> None:
|
|
global import_errors
|
|
import_errors[package_name] = (msg, e)
|
|
|
|
|
|
def shapley_coefficients(n: int) -> npt.NDArray[Any]:
|
|
out = np.zeros(n)
|
|
for i in range(n):
|
|
out[i] = 1 / (n * scipy.special.comb(n - 1, i))
|
|
return out
|
|
|
|
|
|
def convert_name(
|
|
ind: str | int | None,
|
|
shap_values: npt.NDArray[Any] | None,
|
|
input_names: list[str] | npt.NDArray[Any],
|
|
) -> int | str | None:
|
|
if ind is None:
|
|
return None
|
|
if isinstance(ind, str):
|
|
nzinds = np.where(np.array(input_names) == ind)[0]
|
|
if len(nzinds) == 0:
|
|
# we allow rank based indexing using the format "rank(int)"
|
|
if ind.startswith("rank("):
|
|
if shap_values is None:
|
|
raise ValueError("shap_values must be provided for rank-based indexing")
|
|
return np.argsort(-np.abs(shap_values).mean(0))[int(ind[5:-1])]
|
|
|
|
# we allow the sum of all the SHAP values to be specified with "sum()"
|
|
# assuming here that the calling method can deal with this case
|
|
elif ind == "sum()":
|
|
return "sum()"
|
|
else:
|
|
raise ValueError("Could not find feature named: " + ind)
|
|
else:
|
|
return nzinds[0]
|
|
else:
|
|
return ind
|
|
|
|
|
|
def potential_interactions(shap_values_column: Any, shap_values_matrix: Any) -> npt.NDArray[Any]:
|
|
"""Order other features by how much interaction they seem to have with the feature at the given index.
|
|
|
|
This just bins the SHAP values for a feature along that feature's value. For true Shapley interaction
|
|
index values for SHAP see the interaction_contribs option implemented in XGBoost.
|
|
"""
|
|
# ignore inds that are identical to the column
|
|
ignore_inds = np.where((shap_values_matrix.values.T - shap_values_column.values).T.std(0) < 1e-8)
|
|
|
|
X = shap_values_matrix.data
|
|
|
|
if X.shape[0] > 10000:
|
|
a = np.arange(X.shape[0])
|
|
np.random.shuffle(a)
|
|
inds = a[:10000]
|
|
else:
|
|
inds = np.arange(X.shape[0])
|
|
|
|
x = shap_values_column.data[inds]
|
|
srt = np.argsort(x)
|
|
shap_ref = shap_values_column.values[inds]
|
|
shap_ref = shap_ref[srt]
|
|
inc = max(min(int(len(x) / 10.0), 50), 1)
|
|
interactions = []
|
|
for i in range(X.shape[1]):
|
|
encoded_val_other = encode_array_if_needed(X[inds, i][srt], dtype=float)
|
|
|
|
val_other = encoded_val_other
|
|
v = 0.0
|
|
if not (i in ignore_inds or np.sum(np.abs(val_other)) < 1e-8):
|
|
for j in range(0, len(x), inc):
|
|
if np.std(val_other[j : j + inc]) > 0 and np.std(shap_ref[j : j + inc]) > 0:
|
|
v += abs(np.corrcoef(shap_ref[j : j + inc], val_other[j : j + inc])[0, 1])
|
|
val_v = v
|
|
|
|
val_other = np.isnan(encoded_val_other)
|
|
v = 0.0
|
|
if not (i in ignore_inds or np.sum(np.abs(val_other)) < 1e-8):
|
|
for j in range(0, len(x), inc):
|
|
if np.std(val_other[j : j + inc]) > 0 and np.std(shap_ref[j : j + inc]) > 0:
|
|
v += abs(np.corrcoef(shap_ref[j : j + inc], val_other[j : j + inc])[0, 1])
|
|
nan_v = v
|
|
|
|
interactions.append(max(val_v, nan_v))
|
|
|
|
return np.argsort(-np.abs(interactions))
|
|
|
|
|
|
def approximate_interactions(
|
|
index: str | int,
|
|
shap_values: npt.NDArray[Any],
|
|
X: npt.NDArray[Any] | pd.DataFrame,
|
|
feature_names: list[str] | npt.NDArray[Any] | pd.Index | None = None,
|
|
) -> npt.NDArray[Any]:
|
|
"""Order other features by how much interaction they seem to have with the feature at the given index.
|
|
|
|
This just bins the SHAP values for a feature along that feature's value. For true Shapley interaction
|
|
index values for SHAP see the interaction_contribs option implemented in XGBoost.
|
|
"""
|
|
# convert from DataFrames if we got any
|
|
if isinstance(X, pd.DataFrame):
|
|
if feature_names is None:
|
|
feature_names = X.columns
|
|
X = X.values
|
|
|
|
index = convert_name(index, shap_values, feature_names) # type: ignore[arg-type, assignment]
|
|
|
|
if X.shape[0] > 10000:
|
|
a = np.arange(X.shape[0])
|
|
np.random.shuffle(a)
|
|
inds = a[:10000]
|
|
else:
|
|
inds = np.arange(X.shape[0])
|
|
|
|
x = X[inds, index] # type: ignore[index]
|
|
srt = np.argsort(x)
|
|
shap_ref = shap_values[inds, index] # type: ignore[index]
|
|
shap_ref = shap_ref[srt]
|
|
inc = max(min(int(len(x) / 10.0), 50), 1)
|
|
interactions = []
|
|
for i in range(X.shape[1]):
|
|
encoded_val_other = encode_array_if_needed(X[inds, i][srt], dtype=float)
|
|
|
|
val_other = encoded_val_other
|
|
v = 0.0
|
|
if not (i == index or np.sum(np.abs(val_other)) < 1e-8):
|
|
for j in range(0, len(x), inc):
|
|
if np.std(val_other[j : j + inc]) > 0 and np.std(shap_ref[j : j + inc]) > 0:
|
|
v += abs(np.corrcoef(shap_ref[j : j + inc], val_other[j : j + inc])[0, 1])
|
|
val_v = v
|
|
|
|
val_other = np.isnan(encoded_val_other)
|
|
v = 0.0
|
|
if not (i == index or np.sum(np.abs(val_other)) < 1e-8):
|
|
for j in range(0, len(x), inc):
|
|
if np.std(val_other[j : j + inc]) > 0 and np.std(shap_ref[j : j + inc]) > 0:
|
|
v += abs(np.corrcoef(shap_ref[j : j + inc], val_other[j : j + inc])[0, 1])
|
|
nan_v = v
|
|
|
|
interactions.append(max(val_v, nan_v))
|
|
|
|
return np.argsort(-np.abs(interactions))
|
|
|
|
|
|
def encode_array_if_needed(arr: npt.NDArray[Any], dtype: type[Any] = np.float64) -> npt.NDArray[Any]:
|
|
try:
|
|
return arr.astype(dtype)
|
|
except ValueError:
|
|
unique_values = np.unique(arr)
|
|
encoding_dict = {string: index for index, string in enumerate(unique_values)}
|
|
encoded_array = np.array([encoding_dict[string] for string in arr], dtype=dtype)
|
|
return encoded_array
|
|
|
|
|
|
def sample(X: _ArrayT, nsamples: int = 100, random_state: int = 0) -> _ArrayT:
|
|
"""Performs sampling without replacement of the input data ``X``.
|
|
|
|
This is a simple wrapper over scikit-learn's ``shuffle`` function.
|
|
It is used mainly to downsample ``X`` for use as a background
|
|
dataset in SHAP :class:`.Explainer` and its subclasses.
|
|
|
|
.. versionchanged :: 0.42
|
|
The behaviour of ``sample`` was changed from sampling *with* replacement to sampling
|
|
*without* replacement.
|
|
Note that reproducibility might be broken when using this function pre- and post-0.42,
|
|
even with the specification of ``random_state``.
|
|
|
|
Parameters
|
|
----------
|
|
X : array-like
|
|
Data to sample from. Input data can be arrays, lists, dataframes
|
|
or scipy sparse matrices with a consistent first dimension.
|
|
|
|
nsamples : int
|
|
Number of samples to generate from ``X``.
|
|
|
|
random_state :
|
|
Determines random number generation for shuffling the data. Use this to
|
|
ensure reproducibility across multiple function calls.
|
|
|
|
"""
|
|
if hasattr(X, "shape"):
|
|
over_count = nsamples >= X.shape[0]
|
|
else:
|
|
over_count = nsamples >= len(X)
|
|
|
|
if over_count:
|
|
return X
|
|
return sklearn.utils.shuffle(X, n_samples=nsamples, random_state=random_state)
|
|
|
|
|
|
def safe_isinstance(obj: Any, class_path_str: str | list[str]) -> bool:
|
|
"""Acts as a safe version of isinstance without having to explicitly
|
|
import packages which may not exist in the users environment.
|
|
|
|
Checks if obj is an instance of type specified by class_path_str.
|
|
|
|
Parameters
|
|
----------
|
|
obj: Any
|
|
Some object you want to test against
|
|
class_path_str: str or list
|
|
A string or list of strings specifying full class paths
|
|
Example: `sklearn.ensemble.RandomForestRegressor`
|
|
|
|
Returns
|
|
-------
|
|
bool: True if isinstance is true and the package exists, False otherwise
|
|
|
|
"""
|
|
if isinstance(class_path_str, str):
|
|
class_path_strs = [class_path_str]
|
|
elif isinstance(class_path_str, (list, tuple)):
|
|
class_path_strs = class_path_str
|
|
else:
|
|
class_path_strs = [""]
|
|
|
|
# try each module path in order
|
|
for class_path_str in class_path_strs:
|
|
if "." not in class_path_str:
|
|
raise ValueError(
|
|
"class_path_str must be a string or list of strings specifying a full \
|
|
module path to a class. Eg, 'sklearn.ensemble.RandomForestRegressor'"
|
|
)
|
|
|
|
# Splits on last occurrence of "."
|
|
module_name, class_name = class_path_str.rsplit(".", 1)
|
|
|
|
# here we don't check further if the model is not imported, since we shouldn't have
|
|
# an object of that types passed to us if the model the type is from has never been
|
|
# imported. (and we don't want to import lots of new modules for no reason)
|
|
if module_name not in sys.modules:
|
|
continue
|
|
|
|
module = sys.modules[module_name]
|
|
|
|
# Get class
|
|
_class = getattr(module, class_name, None)
|
|
|
|
if _class is None:
|
|
continue
|
|
|
|
if isinstance(obj, _class):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def format_value(s: Any, format_str: str) -> str:
|
|
"""Strips trailing zeros and uses a unicode minus sign."""
|
|
if not issubclass(type(s), str):
|
|
s = format_str % s
|
|
s = re.sub(r"\.?0+$", "", s)
|
|
if len(s) > 0 and s[0] == "-":
|
|
s = "\u2212" + s[1:]
|
|
return s
|
|
|
|
|
|
# From: https://groups.google.com/forum/m/#!topic/openrefine/G7_PSdUeno0
|
|
def ordinal_str(n: int) -> str:
|
|
"""Converts a number to and ordinal string."""
|
|
return str(n) + {1: "st", 2: "nd", 3: "rd"}.get(4 if 10 <= n % 100 < 20 else n % 10, "th")
|
|
|
|
|
|
class OpChain:
|
|
"""A way to represent a set of dot chained operations on an object without actually running them."""
|
|
|
|
_ops: list[list[Any]]
|
|
_root_name: str
|
|
|
|
def __init__(self, root_name: str = "") -> None:
|
|
self._ops = []
|
|
self._root_name = root_name
|
|
|
|
def apply(self, obj: Any) -> Any:
|
|
"""Applies all our ops to the given object, usually an :class:`.Explanation` instance."""
|
|
for o in self._ops:
|
|
op, args, kwargs = o
|
|
if args is not None:
|
|
obj = getattr(obj, op)(*args, **kwargs)
|
|
else:
|
|
obj = getattr(obj, op)
|
|
return obj
|
|
|
|
def __call__(self, *args: Any, **kwargs: Any) -> OpChain:
|
|
"""Update the args for the previous operation."""
|
|
new_self = OpChain(self._root_name)
|
|
new_self._ops = copy.copy(self._ops)
|
|
new_self._ops[-1][1] = args
|
|
new_self._ops[-1][2] = kwargs
|
|
return new_self
|
|
|
|
def __getitem__(self, item: Any) -> OpChain:
|
|
new_self = OpChain(self._root_name)
|
|
new_self._ops = copy.copy(self._ops)
|
|
new_self._ops.append(["__getitem__", [item], {}])
|
|
return new_self
|
|
|
|
def __getattr__(self, name: str) -> OpChain:
|
|
# Don't chain special attributes
|
|
if name.startswith("__") and name.endswith("__"):
|
|
return None # type: ignore
|
|
new_self = OpChain(self._root_name)
|
|
new_self._ops = copy.copy(self._ops)
|
|
new_self._ops.append([name, None, None])
|
|
return new_self
|
|
|
|
def __repr__(self) -> str:
|
|
out = self._root_name
|
|
for op in self._ops:
|
|
op_name, args, kwargs = op
|
|
args = args or tuple()
|
|
kwargs = kwargs or {}
|
|
|
|
out += f".{op_name}"
|
|
has_args = len(args) > 0
|
|
has_kwargs = len(kwargs) > 0
|
|
if has_args or has_kwargs:
|
|
out += "(" + ", ".join([repr(v) for v in args] + [f"{k}={v!r}" for k, v in kwargs.items()]) + ")"
|
|
return out
|
|
|
|
|
|
# https://thesmithfam.org/blog/2012/10/25/temporarily-suppress-console-output-in-python/
|
|
@contextmanager
|
|
def suppress_stderr() -> Iterator[None]:
|
|
with open(os.devnull, "w") as devnull:
|
|
old_stderr = sys.stderr
|
|
sys.stderr = devnull
|
|
try:
|
|
yield
|
|
finally:
|
|
sys.stderr = old_stderr
|