Files
2026-07-13 13:22:52 +08:00

2573 lines
119 KiB
Python

from __future__ import annotations
import ast
import io
import json
import os
import time
import warnings
from typing import Any, Literal
import numpy as np
import numpy.typing as npt
import pandas as pd
import scipy.sparse
import scipy.special
from packaging import version
from .. import maskers
from .._explanation import Explanation
from ..utils import assert_import, record_import_error, safe_isinstance
from ..utils._exceptions import (
DimensionError,
ExplainerError,
InvalidFeaturePerturbationError,
InvalidMaskerError,
InvalidModelError,
)
from ..utils._legacy import DenseData
from ..utils._warnings import ExperimentalWarning
from ._explainer import Explainer
from .other._ubjson import decode_ubjson_buffer
try:
from .. import _cext # type: ignore
except ImportError as e:
record_import_error("cext", "C extension was not built during install!", e)
try:
import pyspark # noqa
except ImportError as e:
record_import_error("pyspark", "PySpark could not be imported!", e)
DEPRECATED_APPROX = object()
output_transform_codes = {
"identity": 0,
"logistic": 1,
"logistic_nlogloss": 2,
"squared_loss": 3,
}
feature_perturbation_codes = {
"interventional": 0,
"tree_path_dependent": 1,
"global_path_dependent": 2,
}
def _safe_check_tree_instance_experimental(tree_instance: Any) -> None:
"""
This function checks if a tree instance has an experimental integration with shap TreeExplainer class.
To add experimental message support for your library add package name and its versions
verified to be used with shap to the 'experimental' dictionary below.
Parameters
----------
tree_instance: object, tree instance from an external library
"""
experimental = {
"causalml": "0.15.3",
}
safe_instance = None
if hasattr(tree_instance, "__class__"):
if hasattr(tree_instance.__class__, "__module__"):
safe_instance = tree_instance
if safe_instance:
library = safe_instance.__class__.__module__.split(".")[0]
if experimental.get(library):
warnings.warn(
f"You are using experimental integration with {library}. "
f"The {library} support is verified for the following versions: {experimental.get(library)}. "
f"As experimental functionality, this integration may be removed or significantly changed in future releases without following semantic versioning. Use in production systems at your own risk.",
ExperimentalWarning,
)
else:
warnings.warn(
f"Unable to check experimental integration status for {tree_instance} object", ExperimentalWarning
)
def _check_xgboost_version(v: str) -> None:
if version.parse(v) < version.parse("1.6"): # pragma: no cover
raise RuntimeError(f"SHAP requires XGBoost >= v1.6 , but found version {v}. Please upgrade XGBoost.")
def _xgboost_n_iterations(tree_limit: int, num_stacked_models: int) -> int:
"""Convert number of trees to number of iterations for XGBoost models."""
if tree_limit == -1:
tree_limit = 0
n_iterations = tree_limit // num_stacked_models
return n_iterations
def _xgboost_cat_unsupported(model: TreeEnsemble) -> None:
if model.model_type == "xgboost" and (
model.cat_feature_indices is not None or getattr(model, "_xgb_enable_categorical", False)
):
raise NotImplementedError(
"Categorical split is not yet supported. You can still use"
" TreeExplainer with `feature_perturbation=tree_path_dependent`."
)
class TreeExplainer(Explainer):
"""Uses Tree SHAP algorithms to explain the output of ensemble tree models.
Tree SHAP is a fast and exact method to estimate SHAP values for tree models
and ensembles of trees, under several different possible assumptions about
feature dependence. It depends on fast C++ implementations either inside an
external model package or in the local compiled C extension.
Examples
--------
See `Tree explainer examples <https://shap.readthedocs.io/en/latest/api_examples/explainers/Tree.html>`_
"""
data: npt.NDArray[Any] | None
data_missing: npt.NDArray[np.bool_] | None
feature_perturbation: str
expected_value: Any
model: TreeEnsemble
model_output: str
data_feature_names: list[str]
def __init__(
self,
model: Any,
data: npt.NDArray[Any] | pd.DataFrame | None = None,
model_output: str = "raw",
feature_perturbation: Literal["auto", "interventional", "tree_path_dependent"] = "auto",
feature_names: list[str] | None = None,
approximate: Any = DEPRECATED_APPROX,
# FIXME: The `link` and `linearize_link` arguments are ignored. GH #3513
link: Any = None,
linearize_link: Any = None,
) -> None:
"""Build a new Tree explainer for the passed model.
Parameters
----------
model : model object
The tree based machine learning model that we want to explain.
XGBoost, LightGBM, CatBoost, Pyspark and most tree-based
scikit-learn models are supported.
data : numpy.array or pandas.DataFrame
The background dataset to use for integrating out features.
This argument is optional when
``feature_perturbation="tree_path_dependent"``, since in that case
we can use the number of training samples that went down each tree
path as our background dataset (this is recorded in the ``model``
object).
feature_perturbation : "auto" (default), "interventional" or "tree_path_dependent"
Since SHAP values rely on conditional expectations, we need to
decide how to handle correlated (or otherwise dependent) input
features.
- if ``"interventional"``, a background dataset ``data`` is required. The
dependencies between features are handled according to the rules dictated
by causal inference [1]_. The runtime scales linearly with the size of the
background dataset you use: anywhere from 100 to 1000 random background
samples are good sizes to use.
- if ``"tree_path_dependent"``, no background dataset is required and the
approach is to just follow the trees and use the number of training
examples that went down each leaf to represent the background
distribution.
- if ``"auto"``, the "interventional" approach will be used when a
background is provided, otherwise the "tree_path_dependent" approach will
be used.
.. versionadded:: 0.47
The `"auto"` option was added.
.. versionchanged:: 0.47
The default behaviour will change from `"interventional"` to `"auto"` in 0.47.
In the future, passing `feature_pertubation="interventional"` without providing
a background dataset will raise an error.
model_output : "raw", "probability", "log_loss", or model method name
What output of the model should be explained.
* If "raw", then we explain the raw output of the trees, which
varies by model. For regression models, "raw" is the standard
output. For binary classification in XGBoost, this is the log odds
ratio.
* If "probability", then we explain the output of the model
transformed into probability space (note that this means the SHAP
values now sum to the probability output of the model).
* If "log_loss", then we explain the natural logarithm of the model
loss function, so that the SHAP values sum up to the log loss of
the model for each sample. This is helpful for breaking down model
performance by feature.
* If ``model_output`` is the name of a supported prediction method
on the ``model`` object, then we explain the output of that model
method name. For example, ``model_output="predict_proba"``
explains the result of calling ``model.predict_proba``.
Currently the "probability" and "log_loss" options are only
supported when ``feature_perturbation="interventional"``.
approximate : bool
Deprecated, will be deprecated in v0.47.0 and removed in version v0.49.0.
Please use the ``approximate`` argument in the :meth:`.shap_values` or ``__call__`` methods instead.
References
----------
.. [1] Janzing, Dominik, Lenon Minorics, and Patrick Blöbaum.
"Feature relevance quantification in explainable AI: A causal problem."
International Conference on artificial intelligence and statistics. PMLR, 2020.
"""
if approximate is not DEPRECATED_APPROX:
warnings.warn(
"The approximate argument has been deprecated in version v0.47.0 and will be removed in version v0.48.0. "
"Please use the approximate argument in the shap_values or the __call__ method instead.",
DeprecationWarning,
)
if feature_names is not None:
self.data_feature_names = feature_names
elif isinstance(data, pd.DataFrame):
self.data_feature_names = list(data.columns)
masker = data
super().__init__(model, masker, feature_names=feature_names)
if type(self.masker) is maskers.Independent:
data = self.masker.data
elif masker is not None:
raise InvalidMaskerError(f"Unsupported masker type: {str(type(self.masker))}!")
if getattr(self.masker, "clustering", None) is not None:
raise ExplainerError(
"TreeExplainer does not support clustered data inputs! Please use shap.Explainer or pass an unclustered masker!"
)
if isinstance(data, pd.DataFrame):
self.data = data.values
elif isinstance(data, DenseData):
self.data = data.data
else:
self.data = data
if feature_perturbation == "auto":
feature_perturbation = "interventional" if self.data is not None else "tree_path_dependent"
elif feature_perturbation == "interventional":
if self.data is None:
# TODO: raise an error in 0.48
warnings.warn(
"In the future, passing feature_perturbation='interventional' without providing a background dataset "
"will raise an error. Please provide a background dataset to continue using the interventional "
"approach or set feature_perturbation='auto' to automatically switch approaches.",
FutureWarning,
)
feature_perturbation = "tree_path_dependent"
elif self.data.shape[0] > 1_000:
wmsg = (
f"Passing {self.data.shape[0]} background samples may lead to slow runtimes. Consider "
"using shap.sample(data, 100) to create a smaller background data set."
)
warnings.warn(wmsg)
elif feature_perturbation != "tree_path_dependent":
raise InvalidFeaturePerturbationError(
"feature_perturbation must be 'auto', 'interventional', or 'tree_path_dependent'. "
f"Got {feature_perturbation} instead."
)
_safe_check_tree_instance_experimental(model)
self.data_missing = None if self.data is None else pd.isna(self.data)
self.feature_perturbation = feature_perturbation
self.expected_value = None
if isinstance(model, TreeEnsemble):
# Allow passing a pre-built TreeEnsemble directly. This makes it possible
# to construct trees by hand (e.g. with categorical splits, which the
# third-party model parsers are needed for otherwise) and explain them
# without round-tripping through an external model object.
self.model = model
else:
self.model = TreeEnsemble(model, self.data, self.data_missing, model_output)
self.model_output = model_output
# self.model_output = self.model.model_output # this allows the TreeEnsemble to translate model outputs types by how it loads the model
# check for unsupported combinations of feature_perturbation and model_outputs
if feature_perturbation == "tree_path_dependent":
if self.model.model_output != "raw":
raise ValueError('Only model_output="raw" is supported for feature_perturbation="tree_path_dependent"')
elif data is None:
raise ValueError(
'A background dataset must be provided unless you are using feature_perturbation="tree_path_dependent"!'
)
if self.model.model_output != "raw":
if self.model.objective is None and self.model.tree_output is None:
emsg = (
"Model does not have a known objective or output type! When model_output is "
'not "raw" then we need to know the model\'s objective or link function.'
)
raise Exception(emsg)
# A change in the signature of `xgboost.Booster.predict()` method has been introduced in XGBoost v1.4:
# The introduced `iteration_range` parameter is used when obtaining SHAP (incl. interaction) values from XGBoost models.
if self.model.model_type == "xgboost":
import xgboost
_check_xgboost_version(xgboost.__version__)
# compute the expected value if we have a parsed tree for the cext
if self.model.model_output == "log_loss":
self.expected_value = self.__dynamic_expected_value
elif data is not None:
try:
self.expected_value = self.model.predict(self.data).mean(0) # type: ignore[union-attr]
except ValueError:
raise ExplainerError(
"Currently TreeExplainer can only handle models with categorical splits when "
'feature_perturbation="tree_path_dependent" and no background data is passed. Please try again using '
'shap.TreeExplainer(model, feature_perturbation="tree_path_dependent").'
)
if hasattr(self.expected_value, "__len__") and len(self.expected_value) == 1:
self.expected_value = self.expected_value[0]
elif hasattr(self.model, "node_sample_weight"):
self.expected_value = self.model.values[:, 0].sum(0)
if self.expected_value.size == 1:
self.expected_value = self.expected_value[0]
self.expected_value += self.model.base_offset
if self.model.model_output != "raw":
self.expected_value = None # we don't handle transforms in this case right now...
# if our output format requires binary classification to be represented as two outputs then we do that here
if self.model.model_output == "probability_doubled" and self.expected_value is not None:
self.expected_value = [1 - self.expected_value, self.expected_value]
def __dynamic_expected_value(self, y: npt.NDArray[Any]) -> npt.NDArray[Any]:
"""This computes the expected value conditioned on the given label value."""
return self.model.predict(self.data, np.ones(self.data.shape[0]) * y).mean(0) # type: ignore[union-attr]
def __call__( # type: ignore
self,
X: Any,
y: np.ndarray | pd.Series | None = None,
interactions: bool = False,
check_additivity: bool = True,
approximate: bool = False,
) -> Explanation:
"""Calculate the SHAP values for the model applied to the data.
Parameters
----------
X : Any
Can be a dataframe like object e.g. numpy.array, pandas.DataFrame or catboost.Pool (for catboost).
A matrix of samples (# samples x # features) on which to explain the model's output.
y : numpy.array, optional
An array of label values for each sample. Used when explaining loss functions.
approximate : bool
Run fast, but only roughly approximate the Tree SHAP values. This runs a method
previously proposed by Saabas which only considers a single feature ordering. Take care
since this does not have the consistency guarantees of Shapley values and places too
much weight on lower splits in the tree.
interactions: bool
Whether to compute the SHAP interaction values.
check_additivity: bool
Check if the sum of the SHAP values equals the output of the model.
Returns
-------
shap.Explanation object containing the given data and the SHAP values.
"""
start_time = time.time()
feature_names: Any
if isinstance(X, pd.DataFrame):
feature_names = list(X.columns)
else:
feature_names = getattr(self, "data_feature_names", None)
if not interactions:
v = self.shap_values(X, y=y, from_call=True, check_additivity=check_additivity, approximate=approximate)
if isinstance(v, list):
v = np.stack(v, axis=-1) # put outputs at the end
else:
if approximate:
raise NotImplementedError("Approximate computation not yet supported for interaction effects!")
v = self.shap_interaction_values(X)
# the Explanation object expects an `expected_value` for each row
if hasattr(self.expected_value, "__len__") and len(self.expected_value) > 1:
# `expected_value` is a list / array of numbers, length k, e.g. for multi-output scenarios
# we repeat it N times along the first axis, so ev_tiled.shape == (N, k)
if isinstance(v, list):
num_rows = v[0].shape[0]
else:
num_rows = v.shape[0]
ev_tiled = np.tile(self.expected_value, (num_rows, 1))
else:
# `expected_value` is a scalar / array of 1 number, so we simply repeat it for every row in `v`
# ev_tiled.shape == (N,)
ev_tiled = np.tile(self.expected_value, v.shape[0])
X_data: np.ndarray | None | scipy.sparse.csr_matrix
# cf. GH dsgibbons#66, this conversion to numpy array should be done AFTER
# calculation of shap values
if isinstance(X, pd.DataFrame):
X_data = X.values
elif safe_isinstance(X, "xgboost.core.DMatrix"):
import xgboost
if version.parse(xgboost.__version__) < version.parse("1.7.0"): # pragma: no cover
# cf. GH #3357
wmsg = (
"`shap.Explanation` does not support `xgboost.DMatrix` objects for xgboost < 1.7, "
"so the `data` attribute of the `Explanation` object will be set to None. If "
"you require the `data` attribute (e.g. using `shap.plots`), then either "
"update your xgboost to >=1.7.0 or explicitly set `Explanation.data = X`, where "
"`X` is a numpy or scipy array."
)
warnings.warn(wmsg)
X_data = None
else:
X_data = X.get_data()
else:
X_data = X
return Explanation(
v,
base_values=ev_tiled,
data=X_data,
feature_names=feature_names,
compute_time=time.time() - start_time,
)
def _validate_inputs(
self,
X: npt.NDArray[Any] | pd.Series | pd.DataFrame,
y: npt.NDArray[Any] | pd.Series | None,
tree_limit: int | None,
check_additivity: bool,
) -> tuple[npt.NDArray[Any], npt.NDArray[Any] | pd.Series | None, npt.NDArray[np.bool_], bool, int, bool]:
# see if we have a default tree_limit in place.
if tree_limit is None:
tree_limit = -1 if self.model.tree_limit is None else self.model.tree_limit
if tree_limit < 0 or tree_limit > self.model.values.shape[0]:
tree_limit = self.model.values.shape[0]
# convert dataframes (use to_numpy to handle pandas nullable dtypes like Int64/Float64)
if isinstance(X, (pd.Series, pd.DataFrame)):
X = X.to_numpy(dtype=self.model.input_dtype, na_value=np.nan)
flat_output = False
if len(X.shape) == 1:
flat_output = True
X = X.reshape(1, X.shape[0])
if X.dtype != self.model.input_dtype:
X = X.astype(self.model.input_dtype)
X_missing = np.isnan(X, dtype=bool)
assert isinstance(X, np.ndarray), "Unknown instance type: " + str(type(X))
assert len(X.shape) == 2, "Passed input data matrix X must have 1 or 2 dimensions!"
if self.model.model_output == "log_loss":
if y is None:
emsg = (
'Both samples and labels must be provided when model_output = "log_loss" '
"(i.e. `explainer.shap_values(X, y)`)!"
)
raise ExplainerError(emsg)
if X.shape[0] != len(y):
emsg = (
f"The number of labels ({len(y)}) does not match the number of samples to explain ({X.shape[0]})!"
)
raise DimensionError(emsg)
if self.feature_perturbation == "tree_path_dependent":
if not self.model.fully_defined_weighting:
emsg = (
"The background dataset you provided does "
"not cover all the leaves in the model, "
"so TreeExplainer cannot run with the "
'feature_perturbation="tree_path_dependent" option! '
"Try providing a larger background "
"dataset, no background dataset, or using "
'feature_perturbation="interventional".'
)
raise ExplainerError(emsg)
if check_additivity and self.model.model_type == "pyspark":
warnings.warn(
"check_additivity requires us to run predictions which is not supported with "
"spark, "
"ignoring."
" Set check_additivity=False to remove this warning"
)
check_additivity = False
return X, y, X_missing, flat_output, tree_limit, check_additivity # type: ignore[return-value]
def shap_values(
self,
X: Any,
y: npt.NDArray[Any] | pd.Series | None = None,
tree_limit: int | None = None,
approximate: bool = False,
check_additivity: bool = True,
from_call: bool = False,
) -> npt.NDArray[Any]:
"""Estimate the SHAP values for a set of samples.
Parameters
----------
X : Any
Can be a dataframe like object, e.g. numpy.array, pandas.DataFrame or catboost.Pool (for catboost).
A matrix of samples (# samples x # features) on which to explain the model's output.
y : numpy.array
An array of label values for each sample. Used when explaining loss functions.
tree_limit : None (default) or int
Limit the number of trees used by the model. By default, the limit of the original model
is used (``None``). ``-1`` means no limit.
approximate : bool
Run fast, but only roughly approximate the Tree SHAP values. This runs a method
previously proposed by Saabas which only considers a single feature ordering. Take care
since this does not have the consistency guarantees of Shapley values and places too
much weight on lower splits in the tree.
check_additivity : bool
Run a validation check that the sum of the SHAP values equals the output of the model. This
check takes only a small amount of time, and will catch potential unforeseen errors.
Note that this check only runs right now when explaining the margin of the model.
Returns
-------
np.array
Estimated SHAP values, usually of shape ``(# samples x # features)``.
For each output, the sum of the SHAP values plus the ``expected_value``
equals the model's output (in the specified output space):
* Single output: ``shap_values[i, :].sum() + expected_value = f(x)[i]``
* Multiple outputs: ``shap_values[i, :, j].sum() + expected_value[j] = f(x)[i, j]``
.. note::
The ``f(x)`` value is NOT necessarily what ``model.predict()``
or ``model.predict_proba()`` returns. For example, for an XGBoost Classifier with the default
``model_output="raw"``, the explainer returns log-odds (margins).
To compare this mathematically against ``predict_proba()`` probabilities,
a logistic inverse-transform (e.g., ``scipy.special.expit``) must be applied
to the sum.
Furthermore, the additivity formula requires SHAP values and model
predictions to be computed on the same samples in the same order.
The shape of the returned array depends on the number of model outputs:
* one output: array of shape ``(#num_samples, *X.shape[1:])``.
* multiple outputs: array of shape ``(#num_samples, *X.shape[1:],
#num_outputs)``.
**Examples:**
* **Regression:** A ``RandomForestRegressor`` returns SHAP values of shape
``(#num_samples, #num_features)`` with a scalar ``expected_value``.
* **Binary classification:** Output behavior varies by model:
- **Scikit-learn models** (e.g., ``RandomForestClassifier``) output
probabilities for both classes, resulting in SHAP values of shape
``(#num_samples, #num_features, 2)`` and ``expected_value`` as an
array of length 2.
- **XGBoost and LightGBM** (with default ``model_output="raw"``) output
a single value (raw margin/log-odds), resulting in SHAP values of shape
``(#num_samples, #num_features)`` and ``expected_value`` as a scalar.
.. versionchanged:: 0.45.0
Return type for models with multiple outputs changed from list to np.ndarray.
"""
# see if we have a default tree_limit in place.
if tree_limit is None:
tree_limit = -1 if self.model.tree_limit is None else self.model.tree_limit
# shortcut using the C++ version of Tree SHAP in XGBoost, LightGBM, and CatBoost
if (
self.feature_perturbation == "tree_path_dependent"
and self.model.model_type != "internal"
and self.data is None
):
model_output_vals = None
phi = None
if self.model.model_type == "xgboost":
import xgboost
n_iterations = _xgboost_n_iterations(tree_limit, self.model.num_stacked_models)
if not isinstance(X, xgboost.core.DMatrix):
# Retrieve any DMatrix properties if they have been set on the TreeEnsemble Class
dmatrix_props = getattr(self.model, "_xgb_dmatrix_props", {})
X = xgboost.DMatrix(X, **dmatrix_props)
phi = self.model.original_model.predict(
X,
iteration_range=(0, n_iterations),
pred_contribs=True,
approx_contribs=approximate,
validate_features=False,
)
if check_additivity and self.model.model_output == "raw":
model_output_vals = self.model.original_model.predict(
X, iteration_range=(0, n_iterations), output_margin=True, validate_features=False
)
elif self.model.model_type == "lightgbm":
assert not approximate, "approximate=True is not supported for LightGBM models!"
phi = self.model.original_model.predict(X, num_iteration=tree_limit, pred_contrib=True)
# Note: the data must be joined on the last axis
if (
"objective" in self.model.original_model.params
and self.model.original_model.params["objective"] == "binary"
):
if not from_call:
warnings.warn(
"LightGBM binary classifier with TreeExplainer shap values output has changed to a list of ndarray"
)
if phi.shape[1] != X.shape[1] + 1:
try:
phi = phi.reshape(X.shape[0], phi.shape[1] // (X.shape[1] + 1), X.shape[1] + 1)
except ValueError as e:
emsg = (
"This reshape error is often caused by passing a bad data matrix to SHAP. "
"See https://github.com/shap/shap/issues/580."
)
raise ValueError(emsg) from e
elif self.model.model_type == "catboost": # thanks to the CatBoost team for implementing this...
assert not approximate, "approximate=True is not supported for CatBoost models!"
assert tree_limit == -1, "tree_limit is not yet supported for CatBoost models!"
import catboost
if not isinstance(X, catboost.Pool):
X = catboost.Pool(X, cat_features=self.model.cat_feature_indices)
phi = self.model.original_model.get_feature_importance(data=X, fstr_type="ShapValues")
# note we pull off the last column and keep it as our expected_value
if phi is not None:
if len(phi.shape) == 3:
self.expected_value = [phi[0, i, -1] for i in range(phi.shape[1])]
out = [phi[:, i, :-1] for i in range(phi.shape[1])]
else:
self.expected_value = phi[0, -1]
out = phi[:, :-1]
if check_additivity and model_output_vals is not None:
self.assert_additivity(out, model_output_vals)
if isinstance(out, list):
out = np.stack(out, axis=-1) # type: ignore[assignment]
return out # type: ignore[return-value]
X, y, X_missing, flat_output, tree_limit, check_additivity = self._validate_inputs(
X, y, tree_limit, check_additivity
)
transform = self.model.get_transform()
_xgboost_cat_unsupported(self.model)
# run the core algorithm using the C extension
assert_import("cext")
phi = np.zeros((X.shape[0], X.shape[1] + 1, self.model.num_outputs))
if not approximate:
_cext.dense_tree_shap(
self.model.children_left,
self.model.children_right,
self.model.children_default,
self.model.features,
self.model.thresholds,
self.model.threshold_types,
self.model.values,
self.model.node_sample_weight,
self.model.max_depth,
X,
X_missing,
y,
self.data,
self.data_missing,
tree_limit,
self.model.base_offset,
phi,
feature_perturbation_codes[self.feature_perturbation],
output_transform_codes[transform],
False,
)
else:
_cext.dense_tree_saabas(
self.model.children_left,
self.model.children_right,
self.model.children_default,
self.model.features,
self.model.thresholds,
self.model.threshold_types,
self.model.values,
self.model.max_depth,
tree_limit,
self.model.base_offset,
output_transform_codes[transform],
X,
X_missing,
y,
phi,
)
out = self._get_shap_output(phi, flat_output)
if check_additivity and self.model.model_output == "raw":
self.assert_additivity(out, self.model.predict(X)) # type: ignore[arg-type]
# This statements handles the case of multiple outputs
# e.g. a multi-class classification problem, multi-target regression problem
# in this case the output shape corresponds to [num_samples, num_features, num_outputs]
if isinstance(out, list):
out = np.stack(out, axis=-1) # type: ignore[assignment]
return out # type: ignore[return-value]
def _get_shap_output(self, phi: npt.NDArray[Any], flat_output: bool) -> Any:
"""Pull off the last column of ``phi`` and keep it as our expected_value."""
if self.model.num_outputs == 1:
if self.expected_value is None and self.model.model_output != "log_loss":
self.expected_value = phi[0, -1, 0]
if flat_output:
out = phi[0, :-1, 0]
else:
out = phi[:, :-1, 0]
else:
if self.expected_value is None and self.model.model_output != "log_loss":
self.expected_value = [phi[0, -1, i] for i in range(phi.shape[2])]
if flat_output:
out = [phi[0, :-1, i] for i in range(self.model.num_outputs)] # type: ignore[assignment]
else:
out = [phi[:, :-1, i] for i in range(self.model.num_outputs)] # type: ignore[assignment]
# if our output format requires binary classification to be represented as two outputs then we do that here
if self.model.model_output == "probability_doubled":
out = [-out, out] # type: ignore[assignment]
return out
def shap_interaction_values(
self,
X: npt.NDArray[Any] | pd.DataFrame | Any,
y: npt.NDArray[Any] | pd.Series | None = None,
tree_limit: int | None = None,
) -> npt.NDArray[Any]:
"""Estimate the SHAP interaction values for a set of samples.
Parameters
----------
X : numpy.array, pandas.DataFrame or catboost.Pool (for catboost)
A matrix of samples (# samples x # features) on which to explain the model's output.
y : numpy.array
An array of label values for each sample. Used when explaining loss functions (not yet supported).
tree_limit : None (default) or int
Limit the number of trees used by the model. By default, the limit of the original model
is used (``None``). ``-1`` means no limit.
Returns
-------
np.array
Returns a matrix. The shape depends on the number of model outputs:
* one output: matrix of shape (#num_samples, #features, #features).
* multiple outputs: matrix of shape (#num_samples, #features, #features, #num_outputs).
The matrix (#num_samples, # features, # features) for each sample sums
to the difference between the model output for that sample and the expected value of the model output
(which is stored in the ``expected_value`` attribute of the explainer). Each row of this matrix sums to the
SHAP value for that feature for that sample. The diagonal entries of the matrix represent the
"main effect" of that feature on the prediction. The symmetric off-diagonal entries represent the
interaction effects between all pairs of features for that sample.
For models with vector outputs, this returns a list of tensors, one for each output.
.. versionchanged:: 0.45.0
Return type for models with multiple outputs changed from list to np.ndarray.
"""
assert self.model.model_output == "raw", (
'Only model_output = "raw" is supported for SHAP interaction values right now!'
)
# assert self.feature_perturbation == "tree_path_dependent", "Only feature_perturbation = \"tree_path_dependent\" is supported for SHAP interaction values right now!"
transform = "identity"
# see if we have a default tree_limit in place.
if tree_limit is None:
tree_limit = -1 if self.model.tree_limit is None else self.model.tree_limit
# shortcut using the C++ version of Tree SHAP in XGBoost
if self.model.model_type == "xgboost" and self.feature_perturbation == "tree_path_dependent":
import xgboost
if not isinstance(X, xgboost.core.DMatrix):
X = xgboost.DMatrix(X)
n_iterations = _xgboost_n_iterations(tree_limit, self.model.num_stacked_models)
phi = self.model.original_model.predict(
X, iteration_range=(0, n_iterations), pred_interactions=True, validate_features=False
)
# note we pull off the last column and keep it as our expected_value
# multi-outputs
if len(phi.shape) == 4:
self.expected_value = [phi[0, i, -1, -1] for i in range(phi.shape[1])]
# phi is given as [#num_observations, #num_classes, #features, #features]
# slice out the expected values, then move the classes to the last dimension
return np.swapaxes(phi[:, :, :-1, :-1], axis1=1, axis2=3)
# regression and binary classification case
else:
self.expected_value = phi[0, -1, -1]
return phi[:, :-1, :-1]
elif (self.model.model_type == "catboost") and (
self.feature_perturbation == "tree_path_dependent"
): # thanks again to the CatBoost team for implementing this...
assert tree_limit == -1, "tree_limit is not yet supported for CatBoost models!"
import catboost
if not isinstance(X, catboost.Pool):
X = catboost.Pool(X, cat_features=self.model.cat_feature_indices)
phi = self.model.original_model.get_feature_importance(data=X, fstr_type="ShapInteractionValues")
# note we pull off the last column and keep it as our expected_value
if len(phi.shape) == 4:
self.expected_value = getattr(self, "expected_value", [phi[0, i, -1, -1] for i in range(phi.shape[1])])
return [phi[:, i, :-1, :-1] for i in range(phi.shape[1])] # type: ignore[return-value]
else:
self.expected_value = getattr(self, "expected_value", phi[0, -1, -1])
return phi[:, :-1, :-1]
X, y, X_missing, flat_output, tree_limit, _ = self._validate_inputs(X, y, tree_limit, False)
# run the core algorithm using the C extension
assert_import("cext")
phi = np.zeros((X.shape[0], X.shape[1] + 1, X.shape[1] + 1, self.model.num_outputs))
_cext.dense_tree_shap(
self.model.children_left,
self.model.children_right,
self.model.children_default,
self.model.features,
self.model.thresholds,
self.model.threshold_types,
self.model.values,
self.model.node_sample_weight,
self.model.max_depth,
X,
X_missing,
y,
self.data,
self.data_missing,
tree_limit,
self.model.base_offset,
phi,
feature_perturbation_codes[self.feature_perturbation],
output_transform_codes[transform],
True,
)
return self._get_shap_interactions_output(phi, flat_output)
def _get_shap_interactions_output(self, phi: npt.NDArray[Any], flat_output: bool) -> npt.NDArray[Any]:
"""Pull off the last column and keep it as our expected_value"""
if self.model.num_outputs == 1:
# get expected value only if not already set
self.expected_value = getattr(self, "expected_value", phi[0, -1, -1, 0])
if flat_output:
out = phi[0, :-1, :-1, 0]
else:
out = phi[:, :-1, :-1, 0]
else:
self.expected_value = [phi[0, -1, -1, i] for i in range(phi.shape[3])]
if flat_output:
out = np.stack([phi[0, :-1, :-1, i] for i in range(self.model.num_outputs)], axis=-1)
else:
out = np.stack([phi[:, :-1, :-1, i] for i in range(self.model.num_outputs)], axis=-1)
return out
def assert_additivity(self, phi: npt.NDArray[Any] | list[npt.NDArray[Any]], model_output: npt.NDArray[Any]) -> None:
def check_sum(sum_val: npt.NDArray[Any], model_output: npt.NDArray[Any]) -> None:
diff = np.abs(sum_val - model_output)
# TODO: add arguments for passing custom 'atol' and 'rtol' values to 'np.allclose'
# would require change to interface i.e. '__call__' methods
if not np.allclose(sum_val, model_output, atol=1e-2, rtol=1e-2):
ind = np.argmax(diff)
err_msg = (
"Additivity check failed in TreeExplainer! Please ensure the data matrix you passed to the "
"explainer is the same shape that the model was trained on. If your data shape is correct "
"then please report this on GitHub."
)
if self.feature_perturbation != "interventional":
err_msg += " Consider retrying with the feature_perturbation='interventional' option."
err_msg += (
" This check failed because for one of the samples the sum of the SHAP values"
f" was {sum_val[ind]:f}, while the model output was {model_output[ind]:f}. If this"
" difference is acceptable you can set check_additivity=False to disable this check."
)
raise ExplainerError(err_msg)
if isinstance(phi, list):
for i in range(len(phi)):
check_sum(self.expected_value[i] + phi[i].sum(-1), model_output[:, i])
else:
check_sum(self.expected_value + phi.sum(-1), model_output)
@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 not isinstance(masker, (maskers.Independent)) and masker is not None:
return False
try:
TreeEnsemble(model)
except Exception:
return False
return True
class TreeEnsemble:
"""An ensemble of decision trees.
This object provides a common interface to many different types of models.
"""
model_type: str
trees: list[SingleTree] | None
base_offset: Any
model_output: str | None
objective: str | None
tree_output: str | None
internal_dtype: type[np.floating[Any]]
input_dtype: type[np.floating[Any]]
data: npt.NDArray[Any] | None
data_missing: npt.NDArray[np.bool_] | None
fully_defined_weighting: bool
tree_limit: int | None
num_stacked_models: int
cat_feature_indices: npt.NDArray[Any] | None
original_model: Any
children_left: npt.NDArray[np.int32]
children_right: npt.NDArray[np.int32]
children_default: npt.NDArray[np.int32]
features: npt.NDArray[np.int32]
thresholds: npt.NDArray[Any]
threshold_types: npt.NDArray[np.int32]
values: npt.NDArray[Any]
node_sample_weight: npt.NDArray[Any]
num_nodes: npt.NDArray[np.int32]
max_depth: int
_xgboost_n_outputs: int
_xgb_dmatrix_props: dict[str, Any]
def __init__(
self,
model: Any,
data: npt.NDArray[Any] | None = None,
data_missing: npt.NDArray[np.bool_] | None = None,
model_output: str | None = None,
) -> None:
self.model_type = "internal"
self.trees = None
self.base_offset = 0
self.model_output = model_output
self.objective = None # what we explain when explaining the loss of the model
self.tree_output = None # what are the units of the values in the leaves of the trees
self.internal_dtype = np.float64
self.input_dtype = (
np.float64
) # for sklearn we need to use np.float32 to always get exact matches to their predictions
self.data = data
self.data_missing = data_missing
self.fully_defined_weighting = (
True # does the background dataset land in every leaf (making it valid for the tree_path_dependent method)
)
self.tree_limit = None # used for limiting the number of trees we use by default (like from early stopping)
self.num_stacked_models = 1 # If this is greater than 1 it means we have multiple stacked models with the same number of trees in each model (XGBoost multi-output style)
self.cat_feature_indices = None # If this is set it tells us which features are treated categorically
self._xgb_enable_categorical = False
# we use names like keras
objective_name_map = {
"mse": "squared_error",
"variance": "squared_error",
"friedman_mse": "squared_error",
"reg:linear": "squared_error",
"reg:squarederror": "squared_error",
"regression": "squared_error",
"regression_l2": "squared_error",
"mae": "absolute_error",
"gini": "binary_crossentropy",
"entropy": "binary_crossentropy",
"reg:logistic": "binary_crossentropy",
"binary:logistic": "binary_crossentropy",
"binary_logloss": "binary_crossentropy",
"binary": "binary_crossentropy",
}
tree_output_name_map = {
"regression": "raw_value",
"regression_l2": "squared_error",
"reg:linear": "raw_value",
"reg:squarederror": "raw_value",
"reg:logistic": "log_odds",
"binary:logistic": "log_odds",
"binary_logloss": "log_odds",
"binary": "log_odds",
}
if isinstance(model, dict) and "trees" in model:
# This allows a dictionary to be passed that represents the model.
# this dictionary has several numerical parameters and also a list of trees
# where each tree is a dictionary describing that tree
if "internal_dtype" in model:
self.internal_dtype = model["internal_dtype"]
if "input_dtype" in model:
self.input_dtype = model["input_dtype"]
if "objective" in model:
self.objective = model["objective"]
if "tree_output" in model:
self.tree_output = model["tree_output"]
if "base_offset" in model:
self.base_offset = model["base_offset"]
self.trees = [SingleTree(t, data=data, data_missing=data_missing) for t in model["trees"]]
elif isinstance(model, list) and isinstance(model[0], SingleTree): # old-style direct-load format
self.trees = model
elif safe_isinstance(
model,
[
"sklearn.ensemble.RandomForestRegressor",
"sklearn.ensemble.forest.RandomForestRegressor",
"econml.grf._base_grf.BaseGRF",
"causalml.inference.tree.CausalRandomForestRegressor",
],
):
assert hasattr(model, "estimators_"), "Model has no `estimators_`! Have you called `model.fit`?"
self.internal_dtype = model.estimators_[0].tree_.value.dtype.type
self.input_dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
SingleTree(e.tree_, scaling=scaling, data=data, data_missing=data_missing) for e in model.estimators_
]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "raw_value"
elif safe_isinstance(
model,
[
"sklearn.ensemble.IsolationForest",
"sklearn.ensemble._iforest.IsolationForest",
],
):
self.dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
IsoTree(e.tree_, f, scaling=scaling, data=data, data_missing=data_missing)
for e, f in zip(model.estimators_, model.estimators_features_)
]
self.tree_output = "raw_value"
elif safe_isinstance(model, ["pyod.models.iforest.IForest"]):
self.dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
IsoTree(e.tree_, f, scaling=scaling, data=data, data_missing=data_missing)
for e, f in zip(model.detector_.estimators_, model.detector_.estimators_features_)
]
self.tree_output = "raw_value"
elif safe_isinstance(
model,
[
"sklearn.ensemble.ExtraTreesRegressor",
"sklearn.ensemble.forest.ExtraTreesRegressor",
"skopt.learning.forest.RandomForestRegressor",
"skopt.learning.forest.ExtraTreesRegressor",
],
):
assert hasattr(model, "estimators_"), "Model has no `estimators_`! Have you called `model.fit`?"
self.internal_dtype = model.estimators_[0].tree_.value.dtype.type
self.input_dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
SingleTree(e.tree_, scaling=scaling, data=data, data_missing=data_missing) for e in model.estimators_
]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "raw_value"
elif safe_isinstance(
model,
[
"sklearn.tree.DecisionTreeRegressor",
"sklearn.tree.tree.DecisionTreeRegressor",
"econml.grf._base_grftree.GRFTree",
"causalml.inference.tree.causal.causaltree.CausalTreeRegressor",
],
):
self.internal_dtype = model.tree_.value.dtype.type
self.input_dtype = np.float32
self.trees = [SingleTree(model.tree_, data=data, data_missing=data_missing)]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "raw_value"
elif safe_isinstance(
model,
[
"sklearn.tree.DecisionTreeClassifier",
"sklearn.tree.tree.DecisionTreeClassifier",
],
):
self.internal_dtype = model.tree_.value.dtype.type
self.input_dtype = np.float32
self.trees = [SingleTree(model.tree_, normalize=True, data=data, data_missing=data_missing)]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "probability"
elif safe_isinstance(
model,
[
"sklearn.ensemble.ExtraTreesClassifier",
"sklearn.ensemble.forest.ExtraTreesClassifier",
"sklearn.ensemble.RandomForestClassifier",
"sklearn.ensemble.forest.RandomForestClassifier",
],
):
assert hasattr(model, "estimators_"), "Model has no `estimators_`! Have you called `model.fit`?"
self.internal_dtype = model.estimators_[0].tree_.value.dtype.type
self.input_dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
SingleTree(e.tree_, normalize=True, scaling=scaling, data=data, data_missing=data_missing)
for e in model.estimators_
]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "probability"
elif safe_isinstance(
model,
[
"sklearn.ensemble.GradientBoostingRegressor",
"sklearn.ensemble.gradient_boosting.GradientBoostingRegressor",
],
):
self.input_dtype = np.float32
# currently we only support the mean and quantile estimators
if safe_isinstance(
model.init_,
[
"sklearn.ensemble.MeanEstimator",
"sklearn.ensemble.gradient_boosting.MeanEstimator",
],
):
self.base_offset = model.init_.mean
elif safe_isinstance(
model.init_,
[
"sklearn.ensemble.QuantileEstimator",
"sklearn.ensemble.gradient_boosting.QuantileEstimator",
],
):
self.base_offset = model.init_.quantile
elif safe_isinstance(model.init_, "sklearn.dummy.DummyRegressor"):
self.base_offset = model.init_.constant_[0]
else:
emsg = f"Unsupported init model type: {type(model.init_)}"
raise InvalidModelError(emsg)
self.trees = [
SingleTree(e.tree_, scaling=model.learning_rate, data=data, data_missing=data_missing)
for e in model.estimators_[:, 0]
]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "raw_value"
elif safe_isinstance(model, ["sklearn.ensemble.HistGradientBoostingRegressor"]):
# cf. GH #1028 for implementation notes
import sklearn
if self.model_output == "predict":
self.model_output = "raw"
self.input_dtype = sklearn.ensemble._hist_gradient_boosting.common.X_DTYPE
self.base_offset = model._baseline_prediction
self.trees = []
for p in model._predictors:
nodes = p[0].nodes
# each node has values: ('value', 'count', 'feature_idx', 'threshold', 'missing_go_to_left', 'left', 'right', 'gain', 'depth', 'is_leaf', 'bin_threshold')
tree = {
"children_left": np.array([-1 if n[9] else n[5] for n in nodes]),
"children_right": np.array([-1 if n[9] else n[6] for n in nodes]),
"children_default": np.array([-1 if n[9] else (n[5] if n[4] else n[6]) for n in nodes]),
"features": np.array([-2 if n[9] else n[2] for n in nodes]),
"thresholds": np.array([n[3] for n in nodes], dtype=np.float64),
"values": np.array([[n[0]] for n in nodes], dtype=np.float64),
"node_sample_weight": np.array([n[1] for n in nodes], dtype=np.float64),
}
self.trees.append(SingleTree(tree, data=data, data_missing=data_missing))
self.objective = objective_name_map.get(model.loss, None)
self.tree_output = "raw_value"
elif safe_isinstance(model, ["sklearn.ensemble.HistGradientBoostingClassifier"]):
# cf. GH #1028 for implementation notes
import sklearn
self.base_offset = model._baseline_prediction
has_len = hasattr(self.base_offset, "__len__")
# Note for newer sklearn versions, the base_offset is an array even for binary classification
if has_len and self.base_offset.shape == (1, 1):
self.base_offset = self.base_offset[0, 0]
has_len = False
if has_len and self.model_output != "raw":
emsg = (
"Multi-output HistGradientBoostingClassifier models are not yet supported unless "
'model_output="raw". See GitHub issue #1028.'
)
raise NotImplementedError(emsg)
self.input_dtype = sklearn.ensemble._hist_gradient_boosting.common.X_DTYPE
self.num_stacked_models = len(model._predictors[0])
if self.model_output == "predict_proba":
if self.num_stacked_models == 1:
self.model_output = (
"probability_doubled" # with predict_proba we need to double the outputs to match
)
else:
self.model_output = "probability"
self.trees = []
for p in model._predictors:
for i in range(self.num_stacked_models):
nodes = p[i].nodes
# each node has values: ('value', 'count', 'feature_idx', 'threshold', 'missing_go_to_left', 'left', 'right', 'gain', 'depth', 'is_leaf', 'bin_threshold')
tree = {
"children_left": np.array([-1 if n[9] else n[5] for n in nodes]),
"children_right": np.array([-1 if n[9] else n[6] for n in nodes]),
"children_default": np.array([-1 if n[9] else (n[5] if n[4] else n[6]) for n in nodes]),
"features": np.array([-2 if n[9] else n[2] for n in nodes]),
"thresholds": np.array([n[3] for n in nodes], dtype=np.float64),
"values": np.array([[n[0]] for n in nodes], dtype=np.float64),
"node_sample_weight": np.array([n[1] for n in nodes], dtype=np.float64),
}
self.trees.append(SingleTree(tree, data=data, data_missing=data_missing))
self.objective = objective_name_map.get(model.loss, None)
self.tree_output = "log_odds"
elif safe_isinstance(
model,
[
"sklearn.ensemble.GradientBoostingClassifier",
"sklearn.ensemble._gb.GradientBoostingClassifier",
"sklearn.ensemble.gradient_boosting.GradientBoostingClassifier",
],
):
self.input_dtype = np.float32
# TODO: deal with estimators for each class
if model.estimators_.shape[1] > 1:
emsg = "GradientBoostingClassifier is only supported for binary classification right now!"
raise InvalidModelError(emsg)
# currently we only support the logs odds estimator
if safe_isinstance(
model.init_,
[
"sklearn.ensemble.LogOddsEstimator",
"sklearn.ensemble.gradient_boosting.LogOddsEstimator",
],
):
self.base_offset = model.init_.prior
self.tree_output = "log_odds"
elif safe_isinstance(model.init_, "sklearn.dummy.DummyClassifier"):
self.base_offset = scipy.special.logit(
model.init_.class_prior_[1]
) # with two classes the trees only model the second class.
self.tree_output = "log_odds"
else:
emsg = f"Unsupported init model type: {type(model.init_)}"
raise InvalidModelError(emsg)
self.trees = [
SingleTree(e.tree_, scaling=model.learning_rate, data=data, data_missing=data_missing)
for e in model.estimators_[:, 0]
]
self.objective = objective_name_map.get(model.criterion, None)
elif "pyspark.ml" in str(type(model)):
assert_import("pyspark")
self.model_type = "pyspark"
# model._java_obj.getImpurity() can be gini, entropy or variance.
self.objective = objective_name_map.get(model._java_obj.getImpurity(), None)
if "Classification" in str(type(model)):
normalize = True
self.tree_output = "probability"
else:
normalize = False
self.tree_output = "raw_value"
# Spark Random forest, create 1 weighted (avg) tree per sub-model
if safe_isinstance(
model,
[
"pyspark.ml.classification.RandomForestClassificationModel",
"pyspark.ml.regression.RandomForestRegressionModel",
],
):
sum_weight = sum(model.treeWeights) # output is average of trees
self.trees = [
SingleTree(tree, normalize=normalize, scaling=model.treeWeights[i] / sum_weight)
for i, tree in enumerate(model.trees)
]
# Spark GBT, create 1 weighted (learning rate) tree per sub-model
elif safe_isinstance(
model,
[
"pyspark.ml.classification.GBTClassificationModel",
"pyspark.ml.regression.GBTRegressionModel",
],
):
self.objective = "squared_error" # GBT subtree use the variance
self.tree_output = "raw_value"
self.trees = [
SingleTree(tree, normalize=False, scaling=model.treeWeights[i])
for i, tree in enumerate(model.trees)
]
# Spark Basic model (single tree)
elif safe_isinstance(
model,
[
"pyspark.ml.classification.DecisionTreeClassificationModel",
"pyspark.ml.regression.DecisionTreeRegressionModel",
],
):
self.trees = [SingleTree(model, normalize=normalize, scaling=1)]
else:
emsg = f"Unsupported Spark model type: {type(model)}"
raise NotImplementedError(emsg)
elif safe_isinstance(model, "xgboost.core.Booster"):
self.original_model = model
self._set_xgboost_model_attributes(
data,
data_missing,
objective_name_map,
tree_output_name_map,
)
elif safe_isinstance(model, "xgboost.sklearn.XGBClassifier"):
self.input_dtype = np.float32
self.original_model = model.get_booster()
self._set_xgboost_model_attributes(
data,
data_missing,
objective_name_map,
tree_output_name_map,
)
if self.model_output == "predict_proba":
if self.num_stacked_models == 1:
# with predict_proba we need to double the outputs to match
self.model_output = "probability_doubled"
else:
self.model_output = "probability"
# Some properties of the sklearn API are passed to a DMatrix object in
# xgboost We need to make sure we do the same here - GH #3313
self._xgb_dmatrix_props = get_xgboost_dmatrix_properties(model)
self._xgb_enable_categorical = bool(self._xgb_dmatrix_props.get("enable_categorical", False))
elif safe_isinstance(model, ["xgboost.sklearn.XGBRegressor", "xgboost.sklearn.XGBRanker"]):
self.original_model = model.get_booster()
self._set_xgboost_model_attributes(
data,
data_missing,
objective_name_map,
tree_output_name_map,
)
# Some properties of the sklearn API are passed to a DMatrix object in
# xgboost We need to make sure we do the same here - GH #3313
self._xgb_dmatrix_props = get_xgboost_dmatrix_properties(model)
self._xgb_enable_categorical = bool(self._xgb_dmatrix_props.get("enable_categorical", False))
elif safe_isinstance(model, "lightgbm.basic.Booster"):
assert_import("lightgbm")
self.model_type = "lightgbm"
self.original_model = model
tree_info = self.original_model.dump_model()["tree_info"]
try:
self.trees = [SingleTree(e, data=data, data_missing=data_missing) for e in tree_info]
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
self.objective = objective_name_map.get(model.params.get("objective", "regression"), None)
self.tree_output = tree_output_name_map.get(model.params.get("objective", "regression"), None)
elif safe_isinstance(model, "gpboost.basic.Booster"):
assert_import("gpboost")
self.model_type = "gpboost"
self.original_model = model
tree_info = self.original_model.dump_model()["tree_info"]
try:
self.trees = [SingleTree(e, data=data, data_missing=data_missing) for e in tree_info]
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
self.objective = objective_name_map.get(model.params.get("objective", "regression"), None)
self.tree_output = tree_output_name_map.get(model.params.get("objective", "regression"), None)
elif safe_isinstance(model, "lightgbm.sklearn.LGBMRegressor"):
assert_import("lightgbm")
self.model_type = "lightgbm"
self.original_model = model.booster_
tree_info = self.original_model.dump_model()["tree_info"]
try:
self.trees = [SingleTree(e, data=data, data_missing=data_missing) for e in tree_info]
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
self.objective = objective_name_map.get(model.objective, None)
self.tree_output = tree_output_name_map.get(model.objective, None)
if model.objective is None:
self.objective = "squared_error"
self.tree_output = "raw_value"
elif safe_isinstance(model, "lightgbm.sklearn.LGBMRanker"):
assert_import("lightgbm")
self.model_type = "lightgbm"
self.original_model = model.booster_
tree_info = self.original_model.dump_model()["tree_info"]
try:
self.trees = [SingleTree(e, data=data, data_missing=data_missing) for e in tree_info]
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
# Note: for ranker, leaving tree_output and objective as None as they
# are not implemented in native code yet
elif safe_isinstance(model, "lightgbm.sklearn.LGBMClassifier"):
assert_import("lightgbm")
self.model_type = "lightgbm"
if model.n_classes_ > 2:
self.num_stacked_models = model.n_classes_
self.original_model = model.booster_
tree_info = self.original_model.dump_model()["tree_info"]
try:
self.trees = [SingleTree(e, data=data, data_missing=data_missing) for e in tree_info]
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
self.objective = objective_name_map.get(model.objective, None)
self.tree_output = tree_output_name_map.get(model.objective, None)
if model.objective is None:
self.objective = "binary_crossentropy"
self.tree_output = "log_odds"
elif safe_isinstance(model, "catboost.core.CatBoostRegressor"):
assert_import("catboost")
self.model_type = "catboost"
self.original_model = model
self.cat_feature_indices = model.get_cat_feature_indices()
try:
cb_loader = CatBoostTreeModelLoader(model)
self.trees = cb_loader.get_trees(data=data, data_missing=data_missing)
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
elif safe_isinstance(model, "catboost.core.CatBoostClassifier"):
assert_import("catboost")
self.model_type = "catboost"
self.original_model = model
self.input_dtype = np.float32
try:
cb_loader = CatBoostTreeModelLoader(model)
self.trees = cb_loader.get_trees(data=data, data_missing=data_missing)
except Exception:
self.trees = None # we get here because the cext can't handle categorical splits yet
self.tree_output = "log_odds"
self.objective = "binary_crossentropy"
self.cat_feature_indices = model.get_cat_feature_indices()
elif safe_isinstance(model, "catboost.core.CatBoost"):
assert_import("catboost")
self.model_type = "catboost"
self.original_model = model
self.cat_feature_indices = model.get_cat_feature_indices()
elif safe_isinstance(model, "imblearn.ensemble._forest.BalancedRandomForestClassifier"):
self.input_dtype = np.float32
scaling = 1.0 / len(model.estimators_) # output is average of trees
self.trees = [
SingleTree(e.tree_, normalize=True, scaling=scaling, data=data, data_missing=data_missing)
for e in model.estimators_
]
self.objective = objective_name_map.get(model.criterion, None)
self.tree_output = "probability"
elif safe_isinstance(
model,
[
"ngboost.ngboost.NGBoost",
"ngboost.api.NGBRegressor",
"ngboost.api.NGBClassifier",
],
):
assert model.base_models, "The NGBoost model has empty `base_models`! Have you called `model.fit`?"
if self.model_output == "raw":
param_idx = 0 # default to the first parameter of the output distribution
warnings.warn(
'Translating model_output="raw" to model_output=0 for the 0-th parameter in the distribution. Use model_output=0 directly to avoid this warning.'
)
elif isinstance(self.model_output, int):
param_idx = self.model_output
self.model_output = "raw" # note that after loading we have a new model_output type
assert safe_isinstance(
model.base_models[0][param_idx],
["sklearn.tree.DecisionTreeRegressor", "sklearn.tree.tree.DecisionTreeRegressor"],
), "You must use default_tree_learner!"
shap_trees = [trees[param_idx] for trees in model.base_models]
self.internal_dtype = shap_trees[0].tree_.value.dtype.type
self.input_dtype = np.float32
scaling = -model.learning_rate * np.array(model.scalings) # output is weighted average of trees
# ngboost reorders the features, so we need to map them back to the original order
missing_col_idxs = [[i for i in range(model.n_features) if i not in col_idx] for col_idx in model.col_idxs]
feature_mapping = [
{i: col_idx for i, col_idx in enumerate(list(col_idxs) + missing_col_idx)}
for col_idxs, missing_col_idx in zip(model.col_idxs, missing_col_idxs)
]
self.trees = []
for idx, shap_tree in enumerate(shap_trees):
tree_ = shap_tree.tree_
values = tree_.value.reshape(tree_.value.shape[0], tree_.value.shape[1] * tree_.value.shape[2])
values = values * scaling[idx] # type: ignore[index]
tree = {
"children_left": tree_.children_left.astype(np.int32),
"children_right": tree_.children_right.astype(np.int32),
"children_default": tree_.children_left,
"features": np.array([feature_mapping[idx].get(i, i) for i in tree_.feature]),
"thresholds": tree_.threshold.astype(np.float64),
"values": values,
"node_sample_weight": tree_.weighted_n_node_samples.astype(np.float64),
}
self.trees.append(SingleTree(tree, data=data, data_missing=data_missing))
self.objective = objective_name_map.get(shap_trees[0].criterion, None)
self.tree_output = "raw_value"
self.base_offset = model.init_params[param_idx]
else:
raise InvalidModelError("Model type not yet supported by TreeExplainer: " + str(type(model)))
# build a dense numpy version of all the tree objects
if self.trees is not None and self.trees:
max_nodes = np.max([len(t.values) for t in self.trees])
assert len(np.unique([t.values.shape[1] for t in self.trees])) == 1, (
"All trees in the ensemble must have the same output dimension!"
)
num_trees = len(self.trees)
# important to be -1 in unused sections!! This way we can tell which entries are valid.
self.children_left = -np.ones((num_trees, max_nodes), dtype=np.int32)
self.children_right = -np.ones((num_trees, max_nodes), dtype=np.int32)
self.children_default = -np.ones((num_trees, max_nodes), dtype=np.int32)
self.features = -np.ones((num_trees, max_nodes), dtype=np.int32)
self.thresholds = np.zeros((num_trees, max_nodes), dtype=self.internal_dtype)
self.threshold_types = np.zeros((num_trees, max_nodes), dtype=np.int32)
self.values = np.zeros((num_trees, max_nodes, self.num_outputs), dtype=self.internal_dtype)
self.node_sample_weight = np.zeros((num_trees, max_nodes), dtype=self.internal_dtype)
for i in range(num_trees):
self.children_left[i, : len(self.trees[i].children_left)] = self.trees[i].children_left
self.children_right[i, : len(self.trees[i].children_right)] = self.trees[i].children_right
self.children_default[i, : len(self.trees[i].children_default)] = self.trees[i].children_default
self.features[i, : len(self.trees[i].features)] = self.trees[i].features
self.thresholds[i, : len(self.trees[i].thresholds)] = self.trees[i].thresholds
self.threshold_types[i, : len(self.trees[i].threshold_types)] = self.trees[i].threshold_types
# XGBoost supports boosting forest, which is not compatible with the
# current assumption here that the number of stacked models represents
# the number of outputs.
if self.model_type == "xgboost":
n_stacks = self.num_outputs
else:
n_stacks = self.num_stacked_models
if n_stacks > 1:
stack_pos = i % n_stacks
self.values[i, : len(self.trees[i].values[:, 0]), stack_pos] = self.trees[i].values[:, 0]
else:
self.values[i, : len(self.trees[i].values)] = self.trees[i].values
self.node_sample_weight[i, : len(self.trees[i].node_sample_weight)] = self.trees[i].node_sample_weight
# ensure that the passed background dataset lands in every leaf
if np.min(self.trees[i].node_sample_weight) <= 0:
self.fully_defined_weighting = False
self.num_nodes = np.array([len(t.values) for t in self.trees], dtype=np.int32)
self.max_depth = np.max([t.max_depth for t in self.trees])
# make sure the base offset is a 1D array
if not hasattr(self.base_offset, "__len__") or len(self.base_offset) == 0:
self.base_offset = (np.ones(self.num_outputs) * self.base_offset).astype(self.internal_dtype)
self.base_offset = self.base_offset.flatten()
assert len(self.base_offset) == self.num_outputs
def _set_xgboost_model_attributes(
self,
data: npt.NDArray[Any] | None,
data_missing: npt.NDArray[np.bool_] | None,
objective_name_map: dict[str, str],
tree_output_name_map: dict[str, str],
) -> None:
self.model_type = "xgboost"
loader = XGBTreeModelLoader(self.original_model)
self.trees = loader.get_trees(data=data, data_missing=data_missing)
self.base_offset = loader.base_score
self.objective = objective_name_map.get(loader.name_obj, None)
self.tree_output = tree_output_name_map.get(loader.name_obj, None)
self.num_stacked_models = loader.n_trees_per_iter
self.cat_feature_indices = loader.cat_feature_indices
best_iteration = getattr(
self.original_model,
"best_iteration",
self.original_model.num_boosted_rounds() - 1,
)
self.tree_limit = (best_iteration + 1) * self.num_stacked_models
self._xgboost_n_outputs = loader.n_targets
@property
def num_outputs(self) -> int:
# Currently, XGBoost models derive the num_outputs attribute from the input
# models, which is set during model load.
if self.model_type == "xgboost":
assert hasattr(self, "_xgboost_n_outputs")
return self._xgboost_n_outputs
if self.num_stacked_models > 1:
if len(self.trees) % self.num_stacked_models != 0: # type: ignore[arg-type]
raise ValueError("Only stacked models with equal numbers of trees are supported!")
if self.trees[0].values.shape[1] != 1: # type: ignore[index]
raise ValueError("Only stacked models with single outputs per model are supported!")
return self.num_stacked_models
else:
return self.trees[0].values.shape[1] # type: ignore[index]
def get_transform(self) -> str:
"""A consistent interface to make predictions from this model."""
if self.model_output == "raw":
transform = "identity"
elif self.model_output in ("probability", "probability_doubled"):
if self.tree_output == "log_odds":
transform = "logistic"
elif self.tree_output == "probability":
transform = "identity"
else:
emsg = (
f'model_output = "probability" is not yet supported when model.tree_output = "{self.tree_output}"!'
)
raise NotImplementedError(emsg)
elif self.model_output == "log_loss":
if self.objective == "squared_error":
transform = "squared_loss"
elif self.objective == "binary_crossentropy":
transform = "logistic_nlogloss"
else:
emsg = f'model_output = "log_loss" is not yet supported when model.objective = "{self.objective}"!'
raise NotImplementedError(emsg)
else:
emsg = (
f"Unrecognized model_output parameter value: {str(self.model_output)}! "
f"If `model.{str(self.model_output)}` is a valid function, open a Github issue to ask "
"that this method be supported. If you want 'predict_proba' just use 'probability' for now."
)
raise ValueError(emsg)
return transform
def predict(
self,
X: npt.NDArray[Any] | pd.Series | pd.DataFrame,
y: npt.NDArray[Any] | None = None,
output: str | None = None,
tree_limit: int | None = None,
) -> npt.NDArray[Any] | float:
"""A consistent interface to make predictions from this model.
Parameters
----------
tree_limit : None (default) or int
Limit the number of trees used by the model. By default None means no use the limit of the
original model, and -1 means no limit.
"""
if output is None:
output = self.model_output
if self.model_type == "pyspark":
# import pyspark
# TODO: support predict for pyspark
raise NotImplementedError(
"Predict with pyspark isn't implemented. Don't run 'interventional' as feature_perturbation."
)
if self.model_type == "xgboost" and self.num_stacked_models != self.num_outputs:
# TODO: Support random forest in XGBoost.
raise NotImplementedError("XGBoost with boosted random forest is not yet supported.")
# see if we have a default tree_limit in place.
if tree_limit is None:
tree_limit = -1 if self.tree_limit is None else self.tree_limit
# convert dataframes (use to_numpy to handle pandas nullable dtypes like Int64/Float64)
if isinstance(X, (pd.Series, pd.DataFrame)):
X = X.to_numpy(dtype=self.input_dtype, na_value=np.nan)
flat_output = False
if len(X.shape) == 1:
flat_output = True
X = X.reshape(1, X.shape[0])
if X.dtype.type != self.input_dtype:
X = X.astype(self.input_dtype)
X_missing = np.isnan(X, dtype=bool)
assert isinstance(X, np.ndarray), "Unknown instance type: " + str(type(X))
assert len(X.shape) == 2, "Passed input data matrix X must have 1 or 2 dimensions!"
if tree_limit < 0 or tree_limit > self.values.shape[0]:
tree_limit = self.values.shape[0]
if output == "logloss":
if y is None:
raise ValueError(
"Both samples and labels must be provided when explaining the loss"
" (i.e. `explainer.shap_values(X, y)`)!"
)
if X.shape[0] != len(y):
raise ValueError(
f"The number of labels ({len(y)}) does not match the number of samples to explain ({X.shape[0]})!"
)
transform = self.get_transform()
assert_import("cext")
output_array: npt.NDArray[Any] = np.zeros((X.shape[0], self.num_outputs))
_cext.dense_tree_predict(
self.children_left,
self.children_right,
self.children_default,
self.features,
self.thresholds,
self.threshold_types,
self.values,
self.max_depth,
tree_limit,
self.base_offset,
output_transform_codes[transform],
X,
X_missing,
y,
output_array,
)
# drop dimensions we don't need
if flat_output:
if self.num_outputs == 1:
return output_array.flatten()[0]
else:
return output_array.reshape(-1, self.num_outputs)
else:
if self.num_outputs == 1:
return output_array.flatten()
else:
return output_array
class SingleTree:
"""A single decision tree.
The primary point of this object is to parse many different tree types into a common format.
Attributes
----------
children_left : numpy.array
A 1d array of length #nodes. The index ``i`` of this array contains the index of
the left-child of the ``i-th`` node in the tree. An index of -1 is used to
represent that the ``i-th`` node is a leaf/terminal node.
children_right : numpy.array
Same as ``children_left``, except it contains the index of the right child of
each ``i-th`` node in the tree.
children_default : numpy.array
A 1d numpy array of length #nodes. The index ``i`` of this array contains either
the index of the left-child / right-child of the ``i-th`` node in the tree,
depending on whether the default split (for handling missing values) is left /
right. An index of -1 is used to represent that the ``i-th`` node is a leaf
node.
features : numpy.array
A 1d numpy array of length #nodes. The value at the ``i-th`` position is the
index of the feature chosen for the split at node ``i``. Leaf nodes have no
splits, so is -1.
thresholds : numpy.array
A 1d numpy array of length #nodes. The value at the ``i-th`` position is the
threshold used for the split at node ``i``. Leaf nodes have no thresholds, so is
-1.
values : numpy.array
A 1d numpy array of length #nodes. The index ``i`` of this array contains the
raw predicted value that would be produced by node ``i`` if it were a leaf node.
node_sample_weight : numpy.array
A 1d numpy array of length #nodes. The index ``i`` contains the number of
records (usually from the training data) that falls into node ``i``.
max_depth : int
The max depth of the tree.
"""
children_left: npt.NDArray[np.int32]
children_right: npt.NDArray[np.int32]
children_default: npt.NDArray[np.int32]
features: npt.NDArray[np.int32]
thresholds: npt.NDArray[np.float64]
threshold_types: npt.NDArray[np.int32]
values: npt.NDArray[Any]
node_sample_weight: npt.NDArray[np.float64]
max_depth: int
def __init__(
self,
tree: Any,
normalize: bool = False,
scaling: float = 1.0,
data: npt.NDArray[Any] | None = None,
data_missing: npt.NDArray[np.bool_] | None = None,
) -> None:
assert_import("cext")
if safe_isinstance(
tree,
[
"sklearn.tree._tree.Tree",
"econml.tree._tree.Tree",
"causalml.inference.tree._tree._tree.Tree",
],
):
self.children_left = tree.children_left.astype(np.int32)
self.children_right = tree.children_right.astype(np.int32)
self.children_default = self.children_left
if hasattr(tree, "missing_go_to_left"):
self.children_default = np.where(tree.missing_go_to_left, self.children_left, self.children_right)
self.features = tree.feature.astype(np.int32)
self.thresholds = tree.threshold.astype(np.float64)
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = tree.value.reshape(tree.value.shape[0], tree.value.shape[1] * tree.value.shape[2])
if normalize:
self.values = (self.values.T / self.values.sum(1)).T
self.values = self.values * scaling
self.node_sample_weight = tree.weighted_n_node_samples.astype(np.float64)
elif isinstance(tree, dict) and "features" in tree:
self.children_left = tree["children_left"].astype(np.int32)
self.children_right = tree["children_right"].astype(np.int32)
self.children_default = tree["children_default"].astype(np.int32)
self.features = tree["features"].astype(np.int32)
self.thresholds = tree["thresholds"]
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = tree["values"] * scaling
self.node_sample_weight = tree["node_sample_weight"]
# deprecated dictionary support (with sklearn singular style "feature" and "value" names)
elif isinstance(tree, dict) and "children_left" in tree:
self.children_left = tree["children_left"].astype(np.int32)
self.children_right = tree["children_right"].astype(np.int32)
self.children_default = tree["children_default"].astype(np.int32)
self.features = tree["feature"].astype(np.int32)
self.thresholds = tree["threshold"]
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = tree["value"] * scaling
self.node_sample_weight = tree["node_sample_weight"]
elif safe_isinstance(
tree,
[
"pyspark.ml.classification.DecisionTreeClassificationModel",
"pyspark.ml.regression.DecisionTreeRegressionModel",
],
):
# model._java_obj.numNodes() doesn't give leaves, need to recompute the size
def getNumNodes(node, size):
size = size + 1
if node.subtreeDepth() == 0:
return size
else:
size = getNumNodes(node.leftChild(), size)
return getNumNodes(node.rightChild(), size)
num_nodes = getNumNodes(tree._java_obj.rootNode(), 0)
self.children_left = np.full(num_nodes, -2, dtype=np.int32)
self.children_right = np.full(num_nodes, -2, dtype=np.int32)
self.children_default = np.full(num_nodes, -2, dtype=np.int32)
self.features = np.full(num_nodes, -2, dtype=np.int32)
self.thresholds = np.full(num_nodes, -2, dtype=np.float64)
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = [-2] * num_nodes # type: ignore[assignment]
self.node_sample_weight = np.full(num_nodes, -2, dtype=np.float64)
def buildTree(index, node):
index = index + 1
if tree._java_obj.getImpurity() == "variance":
self.values[index] = [node.prediction()] # prediction for the node
else:
self.values[index] = [
e for e in node.impurityStats().stats()
] # for gini: NDarray(numLabel): 1 per label: number of item for each label which went through this node
self.node_sample_weight[index] = (
node.impurityStats().count()
) # weighted count of element through this node
if node.subtreeDepth() == 0:
return index
else:
self.features[index] = (
node.split().featureIndex()
) # index of the feature we split on, not available for leaf, int
if str(node.split().getClass()).endswith("tree.CategoricalSplit"):
# Categorical split isn't implemented, TODO: could fake it by creating a fake node to split on the exact value?
raise NotImplementedError("CategoricalSplit are not yet implemented")
self.thresholds[index] = (
node.split().threshold()
) # threshold for the feature, not available for leaf, float
self.children_left[index] = index + 1
idx = buildTree(index, node.leftChild())
self.children_right[index] = idx + 1
idx = buildTree(idx, node.rightChild())
return idx
buildTree(-1, tree._java_obj.rootNode())
# default Not supported with mlib? (TODO)
self.children_default = self.children_left
self.values = np.asarray(self.values)
if normalize:
self.values = (self.values.T / self.values.sum(1)).T
self.values = self.values * scaling
# dictionary output from LightGBM `.dump_model()`
elif isinstance(tree, dict) and "tree_structure" in tree:
start = tree["tree_structure"]
num_parents = tree["num_leaves"] - 1
num_nodes = 2 * num_parents + 1
self.children_left = np.empty(num_nodes, dtype=np.int32)
self.children_right = np.empty(num_nodes, dtype=np.int32)
self.children_default = np.empty(num_nodes, dtype=np.int32)
self.features = np.empty(num_nodes, dtype=np.int32)
self.thresholds = np.empty(num_nodes, dtype=np.float64)
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = [-2 for _ in range(num_nodes)] # type: ignore[assignment]
self.node_sample_weight = np.empty(num_nodes, dtype=np.float64)
# BFS traversal through the tree structure
visited, queue = [], [start]
while queue:
vertex = queue.pop(0) # TODO(perf): benchmark this against deque.popleft()
is_branch_node = "split_index" in vertex
if is_branch_node:
vsplit_idx: int = vertex["split_index"]
if vsplit_idx in visited:
continue
left_child: dict = vertex["left_child"]
right_child: dict = vertex["right_child"]
left_is_branch_node = "split_index" in left_child
if left_is_branch_node:
self.children_left[vsplit_idx] = left_child["split_index"]
else:
self.children_left[vsplit_idx] = left_child["leaf_index"] + num_parents
right_is_branch_node = "split_index" in right_child
if right_is_branch_node:
self.children_right[vsplit_idx] = right_child["split_index"]
else:
self.children_right[vsplit_idx] = right_child["leaf_index"] + num_parents
if vertex["default_left"]:
self.children_default[vsplit_idx] = self.children_left[vsplit_idx]
else:
self.children_default[vsplit_idx] = self.children_right[vsplit_idx]
self.features[vsplit_idx] = vertex["split_feature"]
if isinstance(vertex["threshold"], (int, float)):
self.thresholds[vsplit_idx] = vertex["threshold"]
self.threshold_types[vsplit_idx] = 0
elif isinstance(vertex["threshold"], str):
threshold = 0.0
categories = [int(x) for x in vertex["threshold"].split("||")]
for cat in categories:
threshold += 2 ** (cat - 1)
self.thresholds[vsplit_idx] = threshold
self.threshold_types[vsplit_idx] = 1 # Indicates that this is a categorical split
else:
raise TypeError(f"Threshold type {type(vertex['threshold'])} not supported")
self.values[vsplit_idx] = [vertex["internal_value"]]
self.node_sample_weight[vsplit_idx] = vertex["internal_count"]
visited.append(vsplit_idx)
queue.append(left_child)
queue.append(right_child)
else:
# NOTE: If "leaf_index" is not present as a key, it means we have a
# stump tree. I.e., num_nodes=1.
vleaf_idx: int = vertex.get("leaf_index", 0) + num_parents
self.children_left[vleaf_idx] = -1
self.children_right[vleaf_idx] = -1
self.children_default[vleaf_idx] = -1
self.features[vleaf_idx] = -1
self.children_left[vleaf_idx] = -1
self.children_right[vleaf_idx] = -1
self.children_default[vleaf_idx] = -1
self.features[vleaf_idx] = -1
self.thresholds[vleaf_idx] = -1
self.threshold_types[vleaf_idx] = -1
self.values[vleaf_idx] = [vertex["leaf_value"]]
# FIXME: "leaf_count" currently doesn't exist if we have a stump tree.
# We should be technically be assigning the number of samples used to
# train the model as the weight here, but unfortunately this info is
# currently unavailable in `tree`, so we set to 0 first.
# cf. https://github.com/lightgbm-org/LightGBM/issues/5962
self.node_sample_weight[vleaf_idx] = vertex.get("leaf_count", 0)
self.values = np.asarray(self.values)
self.values = np.multiply(self.values, scaling)
elif isinstance(tree, dict) and "nodeid" in tree:
""" Directly create tree given the JSON dump (with stats) of a XGBoost model.
"""
def max_id(node):
if "children" in node:
return max(node["nodeid"], *[max_id(n) for n in node["children"]])
else:
return node["nodeid"]
m = max_id(tree) + 1
self.children_left = -np.ones(m, dtype=np.int32)
self.children_right = -np.ones(m, dtype=np.int32)
self.children_default = -np.ones(m, dtype=np.int32)
self.features = -np.ones(m, dtype=np.int32)
self.thresholds = np.zeros(m, dtype=np.float64)
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = np.zeros((m, 1), dtype=np.float64)
self.node_sample_weight = np.empty(m, dtype=np.float64)
def extract_data(node, tree):
i = node["nodeid"]
tree.node_sample_weight[i] = node["cover"]
if "children" in node:
tree.children_left[i] = node["yes"]
tree.children_right[i] = node["no"]
tree.children_default[i] = node["missing"]
tree.features[i] = node["split"]
tree.thresholds[i] = node["split_condition"]
for n in node["children"]:
extract_data(n, tree)
elif "leaf" in node:
tree.values[i] = node["leaf"] * scaling
extract_data(tree, self)
elif isinstance(tree, str):
""" Build a tree from a text dump (with stats) of xgboost.
"""
nodes = [t.lstrip() for t in tree[:-1].split("\n")]
nodes_dict = {}
for n in nodes:
nodes_dict[int(n.split(":")[0])] = n.split(":")[1]
m = max(nodes_dict.keys()) + 1
children_left = -1 * np.ones(m, dtype="int32")
children_right = -1 * np.ones(m, dtype="int32")
children_default = -1 * np.ones(m, dtype="int32")
features = -2 * np.ones(m, dtype="int32")
thresholds = -1 * np.ones(m, dtype="float64")
values = 1 * np.ones(m, dtype="float64")
node_sample_weight = np.zeros(m, dtype="float64")
values_lst = list(nodes_dict.values())
keys_lst = list(nodes_dict.keys())
for i in range(len(keys_lst)):
value = values_lst[i]
key = keys_lst[i]
if "leaf" in value:
# Extract values
val = float(value.split("leaf=")[1].split(",")[0])
node_sample_weight_val = float(value.split("cover=")[1])
# Append to lists
values[key] = val
node_sample_weight[key] = node_sample_weight_val
else:
c_left = int(value.split("yes=")[1].split(",")[0])
c_right = int(value.split("no=")[1].split(",")[0])
c_default = int(value.split("missing=")[1].split(",")[0])
feat_thres = value.split(" ")[0]
if "<" in feat_thres:
feature = int(feat_thres.split("<")[0][2:])
threshold = float(feat_thres.split("<")[1][:-1])
if "=" in feat_thres:
feature = int(feat_thres.split("=")[0][2:])
threshold = float(feat_thres.split("=")[1][:-1])
node_sample_weight_val = float(value.split("cover=")[1].split(",")[0])
children_left[key] = c_left
children_right[key] = c_right
children_default[key] = c_default
features[key] = feature
thresholds[key] = threshold
node_sample_weight[key] = node_sample_weight_val
self.children_left = children_left
self.children_right = children_right
self.children_default = children_default
self.features = features
self.thresholds = thresholds # type: ignore[assignment]
self.threshold_types = np.zeros_like(self.thresholds, dtype=np.int32)
self.values = values[:, np.newaxis] * scaling
self.node_sample_weight = node_sample_weight
else:
raise TypeError("Unknown input to SingleTree constructor: " + str(tree))
# Re-compute the number of samples that pass through each node if we are given data
if data is not None and data_missing is not None:
self.node_sample_weight.fill(0.0)
_cext.dense_tree_update_weights(
self.children_left,
self.children_right,
self.children_default,
self.features,
self.thresholds,
self.threshold_types,
self.values,
1,
self.node_sample_weight,
data,
data_missing,
)
# Fix for zero-weight nodes causing NaN in path-dependent SHAP.
#
# Original issue: when a background dataset doesn't cover all
# leaves, the fully_defined_weighting check raises ExplainerError,
# blocking path-dependent mode entirely (#3574).
#
# Root cause: tree_shap_recursive's unwind_path() divides by
# zero_fraction. When a leaf has zero background coverage (w=0),
# samples routing through that subtree encounter zero_fraction=0
# on cold paths, producing NaN via 0/0. This also occurs with
# full training data as background due to floating-point
# threshold comparison mismatches in tree_update_weights vs the
# original model training.
#
# Fix: replace zero weights with epsilon (1e-6) so uncovered
# nodes have negligible but non-zero probability. Additivity
# holds to <1e-7 and values converge as background size grows.
zero_mask = self.node_sample_weight == 0.0
if zero_mask.any():
self.node_sample_weight[zero_mask] = 1e-6
# we compute the expectations to make sure they follow the SHAP logic
self.max_depth = _cext.compute_expectations(
self.children_left, self.children_right, self.node_sample_weight, self.values
)
class IsoTree(SingleTree):
"""In sklearn the tree of the Isolation Forest does not calculated in a good way."""
def __init__(
self,
tree: Any,
tree_features: npt.NDArray[Any],
normalize: bool = False,
scaling: float = 1.0,
data: npt.NDArray[Any] | None = None,
data_missing: npt.NDArray[np.bool_] | None = None,
) -> None:
super().__init__(tree, normalize, scaling, data, data_missing)
if safe_isinstance(tree, "sklearn.tree._tree.Tree"):
from sklearn.ensemble._iforest import _average_path_length
def _recalculate_value(tree, i, level):
if tree.children_left[i] == -1 and tree.children_right[i] == -1:
value = level + _average_path_length(np.array([tree.n_node_samples[i]]))[0]
self.values[i, 0] = value
return value * tree.n_node_samples[i]
else:
value_left = _recalculate_value(tree, tree.children_left[i], level + 1)
value_right = _recalculate_value(tree, tree.children_right[i], level + 1)
self.values[i, 0] = (value_left + value_right) / tree.n_node_samples[i]
return value_left + value_right
_recalculate_value(tree, 0, 0)
if normalize:
self.values = (self.values.T / self.values.sum(1)).T
self.values = self.values * scaling
# re-number the features if each tree gets a different set of features
self.features = np.where(self.features >= 0, tree_features[self.features], self.features)
def get_xgboost_dmatrix_properties(model: Any) -> dict[str, Any]:
"""Retrieves properties from an xgboost.sklearn.XGBModel instance that should be
passed to the xgboost.core.DMatrix object before calling predict on the model.
"""
properties_to_pass = ["missing", "n_jobs", "enable_categorical", "feature_types"]
dmatrix_attributes = {}
for attribute in properties_to_pass:
if hasattr(model, attribute):
dmatrix_attributes[attribute] = getattr(model, attribute)
# Convert sklearn n_jobs to xgboost nthread
if "n_jobs" in dmatrix_attributes:
dmatrix_attributes["nthread"] = dmatrix_attributes.pop("n_jobs")
return dmatrix_attributes
class XGBTreeModelLoader:
"""This loads an XGBoost model directly from a raw memory dump."""
n_trees_per_iter: int
n_targets: int
name_obj: str
name_gbm: str
base_score: float
num_feature: int
num_class: int
num_trees: int
node_parents: list[npt.NDArray[Any]]
node_cleft: list[npt.NDArray[np.int32]]
node_cright: list[npt.NDArray[np.int32]]
node_sindex: list[npt.NDArray[np.uint32]]
children_default: list[npt.NDArray[Any]]
sum_hess: list[npt.NDArray[np.float64]]
values: list[npt.NDArray[Any]]
thresholds: list[npt.NDArray[Any]]
threshold_types: list[npt.NDArray[np.int32]]
features: list[npt.NDArray[Any]]
split_types: list[npt.NDArray[Any]]
categories: list[list[list[int]]]
cat_feature_indices: npt.NDArray[Any] | None
def __init__(self, xgb_model: Any) -> None:
import xgboost as xgb
_check_xgboost_version(xgb.__version__)
model: xgb.Booster = xgb_model
raw = xgb_model.save_raw(raw_format="ubj")
with io.BytesIO(raw) as fd:
jmodel = decode_ubjson_buffer(fd)
learner = jmodel["learner"]
learner_model_param = learner["learner_model_param"]
objective = learner["objective"]
booster = learner["gradient_booster"]
n_classes = max(int(learner_model_param["num_class"]), 1)
n_targets = max(int(learner_model_param["num_target"]), 1)
n_targets = max(n_targets, n_classes)
# darts booster does not have the standard format.
# Therefore we need to unpack the gbtree key.
if "gbtree" in booster and "model" not in booster:
booster = booster["gbtree"]
# Check the input model doesn't have vector-leaf
if booster["model"].get("iteration_indptr", None) is not None:
# iteration_indptr was introduced in 2.0.
iteration_indptr = np.asarray(booster["model"]["iteration_indptr"], dtype=np.int32)
diff = np.diff(iteration_indptr)
else:
n_parallel_trees = int(booster["model"]["gbtree_model_param"]["num_parallel_tree"])
diff = np.repeat(n_targets * n_parallel_trees, model.num_boosted_rounds())
if np.any(diff != diff[0]):
raise ValueError("vector-leaf is not yet supported.:", diff)
# used to convert the number of iteration to the number of trees.
# Accounts for number of classes, targets, forest size.
self.n_trees_per_iter = int(diff[0])
self.n_targets = n_targets
assert self.n_trees_per_iter > 0
self.name_obj = objective["name"]
self.name_gbm = booster["name"]
# handle the link function.
base_score = learner_model_param["base_score"]
if isinstance(base_score, str):
try:
base_score = ast.literal_eval(base_score)
if not isinstance(base_score, (list, float, int, tuple, np.ndarray)):
raise ValueError
except ValueError as e:
emsg = f"Expected the base_score to contain a list or float, received {base_score}"
raise ValueError(emsg) from e
if isinstance(base_score, (list, tuple, np.ndarray)):
base_score = np.asarray(base_score, dtype=float)
if base_score.size == 1:
base_score = float(base_score[0])
else:
base_score = float(base_score)
self.base_score = base_score
if self.name_obj in ("binary:logistic", "reg:logistic"):
self.base_score = scipy.special.logit(base_score)
elif self.name_obj in (
"reg:gamma",
"reg:tweedie",
"count:poisson",
"survival:cox",
"survival:aft",
):
# exp family
self.base_score = np.log(self.base_score)
else:
self.base_score = base_score
self.num_feature = int(learner_model_param["num_feature"])
self.num_class = int(learner_model_param["num_class"])
trees = booster["model"]["trees"]
self.num_trees = len(trees)
self.node_parents = []
self.node_cleft = []
self.node_cright = []
self.node_sindex = []
self.children_default: list[np.ndarray] = []
self.sum_hess = []
self.values = []
self.thresholds = []
self.threshold_types = []
self.features = []
# Categorical features, not supported by the SHAP package yet.
self.split_types = []
self.categories = []
feature_types = model.feature_types
if feature_types is not None:
cat_feature_indices: np.ndarray = np.where(np.asarray(feature_types) == "c")[0]
if len(cat_feature_indices) == 0:
self.cat_feature_indices: np.ndarray | None = None
else:
self.cat_feature_indices = cat_feature_indices
else:
self.cat_feature_indices = None
def to_integers(data: list[int]) -> np.ndarray:
"""Handle u8 array from UBJSON."""
assert isinstance(data, list)
return np.asanyarray(data, dtype=np.uint8)
for i in range(self.num_trees):
tree = trees[i]
parents = np.asarray(tree["parents"])
self.node_parents.append(parents)
self.node_cleft.append(np.asarray(tree["left_children"], dtype=np.int32))
self.node_cright.append(np.asarray(tree["right_children"], dtype=np.int32))
self.node_sindex.append(np.asarray(tree["split_indices"], dtype=np.uint32))
base_weight = np.asarray(tree["base_weights"], dtype=np.float32)
if base_weight.size != self.node_cleft[-1].size:
raise ValueError("vector-leaf is not yet supported.")
default_left = to_integers(tree["default_left"])
default_child = np.where(default_left == 1, self.node_cleft[-1], self.node_cright[-1]).astype(np.int64)
self.children_default.append(default_child)
self.sum_hess.append(np.asarray(tree["sum_hessian"], dtype=np.float64))
is_leaf = self.node_cleft[-1] == -1
# XGBoost stores split condition and leaf weight in the same field.
split_cond = np.asarray(tree["split_conditions"], dtype=np.float32)
leaf_weight = np.where(is_leaf, split_cond, 0.0)
thresholds = np.where(is_leaf, 0.0, split_cond)
# Xgboost uses < for thresholds where shap uses <= Move the threshold down
# by the smallest possible increment
thresholds = np.where(is_leaf, 0.0, np.nextafter(thresholds, -np.float32(np.inf)))
threshold_types = np.zeros_like(thresholds, dtype=np.int32)
self.values.append(leaf_weight.reshape(leaf_weight.size, 1))
self.thresholds.append(thresholds)
self.threshold_types.append(threshold_types)
split_idx = np.asarray(tree["split_indices"], dtype=np.int64)
self.features.append(split_idx)
# - categorical features
# when ubjson is used, this is a byte array with each element as uint8
split_types = to_integers(tree["split_type"])
self.split_types.append(split_types)
# categories for each node is stored in a CSR style storage with segment as
# the begin ptr and the `categories' as values.
cat_segments: list[int] = tree["categories_segments"]
cat_sizes: list[int] = tree["categories_sizes"]
# node index for categorical nodes
cat_nodes: list[int] = tree["categories_nodes"]
assert len(cat_segments) == len(cat_sizes) == len(cat_nodes)
cats = tree["categories"]
tree_categories = self.parse_categories(cat_nodes, cat_segments, cat_sizes, cats, self.node_cleft[-1])
self.categories.append(tree_categories)
@staticmethod
def parse_categories(
cat_nodes: list[int],
cat_segments: list[int],
cat_sizes: list[int],
cats: list[int],
left_children: npt.NDArray[Any],
) -> list[list[int]]:
"""Parse the JSON model to extract partitions of categories for each
node. Returns a list, in which each element is a list of categories for tree
split. For a numerical split, the list is empty.
This is not used yet, only implemented for future reference.
"""
# The storage for categories is only defined for categorical nodes to prevent
# unnecessary overhead for numerical splits, we track the categorical node that
# are processed using a counter.
cat_cnt = 0
if cat_nodes:
last_cat_node = cat_nodes[cat_cnt]
else:
last_cat_node = -1
node_categories: list[list[int]] = []
for node_id in range(len(left_children)):
if node_id == last_cat_node:
beg = cat_segments[cat_cnt]
size = cat_sizes[cat_cnt]
end = beg + size
# categories for this node
node_cats = cats[beg:end]
# categories are unique for each node
assert len(set(node_cats)) == len(node_cats)
cat_cnt += 1
if cat_cnt == len(cat_nodes):
last_cat_node = -1 # continue to process the rest of the nodes
else:
last_cat_node = cat_nodes[cat_cnt]
assert node_cats
node_categories.append(node_cats)
else:
# append an empty node, it's either a numerical node or a leaf.
node_categories.append([])
return node_categories
def get_trees(
self,
data: npt.NDArray[Any] | None = None,
data_missing: npt.NDArray[np.bool_] | None = None,
) -> list[SingleTree]:
trees = []
for i in range(self.num_trees):
info = {
"children_left": self.node_cleft[i],
"children_right": self.node_cright[i],
"children_default": self.children_default[i],
"feature": self.features[i],
"threshold": self.thresholds[i],
"threshold_type": self.threshold_types[i],
"value": self.values[i],
"node_sample_weight": self.sum_hess[i],
}
trees.append(SingleTree(info, data=data, data_missing=data_missing))
return trees
def print_info(self) -> None:
print("--- global parameters ---")
print("base_score =", self.base_score)
print("num_feature =", self.num_feature)
print("num_class =", self.num_class)
print("name_obj =", self.name_obj)
print("name_gbm =", self.name_gbm)
print()
print("--- gbtree specific parameters ---")
print("num_feature =", self.num_feature)
class CatBoostTreeModelLoader:
loaded_cb_model: dict[str, Any]
num_trees: int
max_depth: int
def __init__(self, cb_model: Any) -> None:
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_file = os.path.join(tmp_dir, "model.json")
cb_model.save_model(tmp_file, format="json")
with open(tmp_file, encoding="utf-8") as fh:
self.loaded_cb_model = json.load(fh)
# load the CatBoost oblivious trees specific parameters
self.num_trees = len(self.loaded_cb_model["oblivious_trees"])
self.max_depth = self.loaded_cb_model["model_info"]["params"]["tree_learner_options"]["depth"]
def get_trees(
self,
data: npt.NDArray[Any] | None = None,
data_missing: npt.NDArray[np.bool_] | None = None,
) -> list[SingleTree]:
# load each tree
trees = []
for tree_index in range(self.num_trees):
# load the per-tree params
# depth = len(self.loaded_cb_model['oblivious_trees'][tree_index]['splits'])
# load the nodes
# Re-compute the number of samples that pass through each node if we are given data
leaf_weights = self.loaded_cb_model["oblivious_trees"][tree_index]["leaf_weights"]
leaf_weights_unraveled = [0] * (len(leaf_weights) - 1) + leaf_weights
leaf_weights_unraveled[0] = sum(leaf_weights)
for index in range(len(leaf_weights) - 2, 0, -1):
leaf_weights_unraveled[index] = (
leaf_weights_unraveled[2 * index + 1] + leaf_weights_unraveled[2 * index + 2]
)
leaf_values = self.loaded_cb_model["oblivious_trees"][tree_index]["leaf_values"]
leaf_values_unraveled = [0] * (len(leaf_values) - 1) + leaf_values
children_left = [i * 2 + 1 for i in range(len(leaf_values) - 1)]
children_left += [-1] * len(leaf_values)
children_right = [i * 2 for i in range(1, len(leaf_values))]
children_right += [-1] * len(leaf_values)
children_default = [i * 2 + 1 for i in range(len(leaf_values) - 1)]
children_default += [-1] * len(leaf_values)
# load the split features and borders
# split features and borders go from leafs to the root
split_features_index = []
borders = []
# split features and borders go from leafs to the root
for elem in self.loaded_cb_model["oblivious_trees"][tree_index]["splits"]:
split_type = elem.get("split_type")
if split_type == "FloatFeature":
split_feature_index = elem.get("float_feature_index")
borders.append(elem["border"])
elif split_type == "OneHotFeature":
split_feature_index = elem.get("cat_feature_index")
borders.append(elem["value"])
else:
split_feature_index = elem.get("ctr_target_border_idx")
borders.append(elem["border"])
split_features_index.append(split_feature_index)
split_features_index_unraveled = []
for counter, feature_index in enumerate(split_features_index[::-1]):
split_features_index_unraveled += [feature_index] * (2**counter)
split_features_index_unraveled += [0] * len(leaf_values)
borders_unraveled = []
for counter, border in enumerate(borders[::-1]):
borders_unraveled += [border] * (2**counter)
borders_unraveled += [0] * len(leaf_values)
trees.append(
SingleTree(
{
"children_left": np.array(children_left),
"children_right": np.array(children_right),
"children_default": np.array(children_default),
"feature": np.array(split_features_index_unraveled),
"threshold": np.array(borders_unraveled),
"value": np.array(leaf_values_unraveled).reshape((-1, 1)),
"node_sample_weight": np.array(leaf_weights_unraveled),
},
data=data,
data_missing=data_missing,
)
)
return trees