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

314 lines
12 KiB
Python

from __future__ import annotations
import itertools as it
import warnings
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
import numpy.typing as npt
import pandas as pd
import scipy.cluster
import scipy.spatial
import sklearn
from numba import njit
from ..utils._exceptions import DimensionError
from ._show_progress import show_progress
if TYPE_CHECKING:
from ._types import _ArrayLike
def partition_tree(X: pd.DataFrame, metric: str = "correlation") -> npt.NDArray[Any]:
X_full_rank = X + np.random.randn(*X.shape) * 1e-8
D = scipy.spatial.distance.pdist(X_full_rank.fillna(X_full_rank.mean()).T, metric=metric)
return scipy.cluster.hierarchy.complete(D)
def partition_tree_shuffle(
indexes: npt.NDArray[Any],
index_mask: npt.NDArray[np.bool_],
partition_tree: npt.NDArray[Any],
) -> None:
"""Randomly shuffle the indexes in a way that is consistent with the given partition tree.
Parameters
----------
indexes: np.array
The output location of the indexes we want shuffled. Note that len(indexes) should equal index_mask.sum().
index_mask: np.array
A bool mask of which indexes we want to include in the shuffled list.
partition_tree: np.array
The partition tree we should follow.
"""
M = len(index_mask)
# switch = np.random.randn(M) < 0
_pt_shuffle_rec(partition_tree.shape[0] - 1, indexes, index_mask, partition_tree, M, 0)
@njit
def _pt_shuffle_rec(
i: int,
indexes: npt.NDArray[Any],
index_mask: npt.NDArray[np.bool_],
partition_tree: npt.NDArray[Any],
M: int,
pos: int,
) -> int: # type: ignore[misc]
if i < 0:
# see if we should include this index in the ordering
if index_mask[i + M]:
indexes[pos] = i + M
return pos + 1
else:
return pos
left = int(partition_tree[i, 0] - M)
right = int(partition_tree[i, 1] - M)
if np.random.randn() < 0:
pos = _pt_shuffle_rec(left, indexes, index_mask, partition_tree, M, pos)
pos = _pt_shuffle_rec(right, indexes, index_mask, partition_tree, M, pos)
else:
pos = _pt_shuffle_rec(right, indexes, index_mask, partition_tree, M, pos)
pos = _pt_shuffle_rec(left, indexes, index_mask, partition_tree, M, pos)
return pos
@njit
def delta_minimization_order(
all_masks: npt.NDArray[Any],
max_swap_size: int = 100,
num_passes: int = 2,
) -> npt.NDArray[Any]: # type: ignore[misc]
order = np.arange(len(all_masks))
for _ in range(num_passes):
for length in list(range(2, max_swap_size)):
for i in range(1, len(order) - length):
if _reverse_window_score_gain(all_masks, order, i, length) > 0:
_reverse_window(order, i, length)
return order
@njit
def _reverse_window(order: npt.NDArray[Any], start: int, length: int) -> None: # type: ignore[misc]
for i in range(length // 2):
tmp = order[start + i]
order[start + i] = order[start + length - i - 1]
order[start + length - i - 1] = tmp
@njit
def _reverse_window_score_gain(
masks: npt.NDArray[Any],
order: npt.NDArray[Any],
start: int,
length: int,
) -> int: # type: ignore[misc]
forward_score = _mask_delta_score(masks[order[start - 1]], masks[order[start]]) + _mask_delta_score(
masks[order[start + length - 1]], masks[order[start + length]]
)
reverse_score = _mask_delta_score(masks[order[start - 1]], masks[order[start + length - 1]]) + _mask_delta_score(
masks[order[start]], masks[order[start + length]]
)
return forward_score - reverse_score
@njit
def _mask_delta_score(m1: npt.NDArray[Any], m2: npt.NDArray[Any]) -> int: # type: ignore[misc]
return (m1 ^ m2).sum()
def hclust_ordering(
X: npt.NDArray[Any],
metric: str = "sqeuclidean",
anchor_first: bool = False,
) -> npt.NDArray[Any]:
"""A leaf ordering is under-defined, this picks the ordering that keeps nearby samples similar."""
# compute a hierarchical clustering and return the optimal leaf ordering
D = scipy.spatial.distance.pdist(X, metric)
cluster_matrix = scipy.cluster.hierarchy.complete(D)
return scipy.cluster.hierarchy.leaves_list(scipy.cluster.hierarchy.optimal_leaf_ordering(cluster_matrix, D))
def xgboost_distances_r2(
X: npt.NDArray[Any],
y: npt.NDArray[Any],
learning_rate: float = 0.6,
early_stopping_rounds: int | None = 2,
subsample: float | None = 1.0,
max_estimators: int | None = 10_000,
random_state: int | np.random.RandomState = 0,
) -> npt.NDArray[Any]:
"""Compute redundancy distances scaled from 0-1 among all the features in X relative to the label y.
Distances are measured by training univariate XGBoost models of y for all the features, and then
predicting the output of these models using univariate XGBoost models of other features. If one
feature can effectively predict the output of another feature's univariate XGBoost model of y,
then the second feature is redundant with the first with respect to y. A distance of 1 corresponds
to no redundancy while a distance of 0 corresponds to perfect redundancy (measured using the
proportion of variance explained). Note these distances are not symmetric.
Returns
-------
np.ndarray
A square matrix of shape (n_features, n_features) containing the pairwise
redundancy distances between features. Each element [i, j] represents the
redundancy distance from feature i to feature j with respect to y.
"""
import xgboost
# pick our train/text split
X_train, X_test, y_train, y_test = sklearn.model_selection.train_test_split(X, y, random_state=random_state)
# fit an XGBoost model on each of the features
num_features = X.shape[1]
train_preds_list: list[npt.NDArray[Any]] = []
test_preds_list: list[npt.NDArray[Any]] = []
for i in range(num_features):
model = xgboost.XGBRegressor(
subsample=subsample,
n_estimators=max_estimators,
learning_rate=learning_rate,
max_depth=1,
early_stopping_rounds=early_stopping_rounds,
)
model.fit(X_train[:, i : i + 1], y_train, eval_set=[(X_test[:, i : i + 1], y_test)], verbose=False)
train_preds_list.append(model.predict(X_train[:, i : i + 1]))
test_preds_list.append(model.predict(X_test[:, i : i + 1]))
train_preds: npt.NDArray[Any] = np.vstack(train_preds_list).T
test_preds: npt.NDArray[Any] = np.vstack(test_preds_list).T
# fit XGBoost models to predict the outputs of other XGBoost models to see how redundant features are
dist: npt.NDArray[Any] = np.zeros((num_features, num_features))
for i, j in show_progress(
it.product(range(num_features), range(num_features)),
total=num_features * num_features,
):
if i == j:
continue
# skip features that have no variance in their predictions (likely because the feature is a constant)
preds_var: float = np.var(test_preds[:, i])
if preds_var < 1e-4:
warnings.warn(
f"No/low signal found from feature {i} (this is typically caused by constant or "
"near-constant features)! Cluster distances can't be computed for it (so setting "
"all redundancy distances to 1)."
)
r2: float = 0
# fit the model
else:
model = xgboost.XGBRegressor(
subsample=subsample,
n_estimators=max_estimators,
learning_rate=learning_rate,
max_depth=1,
early_stopping_rounds=early_stopping_rounds,
)
model.fit(
X_train[:, j : j + 1],
train_preds[:, i],
eval_set=[(X_test[:, j : j + 1], test_preds[:, i])],
verbose=False,
)
r2 = max(0, 1 - np.mean((test_preds[:, i] - model.predict(X_test[:, j : j + 1])) ** 2) / preds_var)
dist[i, j] = 1 - r2
return dist
def hclust(
X: _ArrayLike,
y: _ArrayLike | None = None,
linkage: Literal["single", "complete", "average"] = "single",
metric: str = "auto",
random_state: int | np.random.RandomState = 0,
) -> npt.NDArray[Any]:
"""Fit a hierarchical clustering model for features X relative to target variable y.
For more information on clustering methods, see :external+scipy:func:`scipy.cluster.hierarchy.linkage`.
For more information on scipy distance metrics, see :external+scipy:func:`scipy.spatial.distance.pdist`.
Parameters
----------
X: 2d-array-like
Features to cluster
y: array-like or None
Target variable
linkage: str
Defines the method to calculate the distance between clusters. Must be
one of "single", "complete" or "average".
metric: str
Scipy distance metric or "xgboost_distances_r2".
* If ``xgboost_distances_r2``, estimate redundancy distances between
features X with respect to target variable y using
:func:`shap.utils.xgboost_distances_r2`.
* Otherwise, calculate distances between features using the given
distance metric.
* If ``auto`` (default), use ``xgboost_distances_r2`` if target variable
is provided, or else ``cosine`` distance metric.
random_state: int or np.random.RandomState
Numpy random state, defaults to 0.
Returns
-------
clustering: np.array
The hierarchical clustering encoded as a linkage matrix.
"""
if isinstance(X, pd.DataFrame):
X_arr: npt.NDArray[Any] = X.values
else:
X_arr = np.array(X)
if len(X_arr.shape) != 2:
raise DimensionError("X needs to be a 2-dimensional array-like object")
known_linkages: tuple[str, str, str] = ("single", "complete", "average")
if linkage not in known_linkages:
raise ValueError(f"Unknown linkage type: {linkage}")
if metric == "auto":
if y is not None:
metric = "xgboost_distances_r2"
else:
metric = "cosine"
# build the distance matrix
if metric == "xgboost_distances_r2":
dist_full: npt.NDArray[Any] = xgboost_distances_r2(X_arr, y, random_state=random_state) # type: ignore[arg-type]
# build a condensed upper triangular version by taking the max distance from either direction
dist_list: list[float] = []
for i, j in it.combinations(range(len(dist_full)), 2):
if linkage == "single":
dist_list.append(min(dist_full[i, j], dist_full[j, i]))
elif linkage == "complete":
dist_list.append(max(dist_full[i, j], dist_full[j, i]))
elif linkage == "average":
dist_list.append((dist_full[i, j] + dist_full[j, i]) / 2)
dist: npt.NDArray[Any] = np.array(dist_list)
else:
if y is not None:
warnings.warn(
"Ignoring the y argument passed to shap.utils.hclust since the given clustering metric is "
"not based on label fitting!"
)
bg_no_nan: npt.NDArray[Any] = X_arr.copy()
for i in range(bg_no_nan.shape[1]):
np.nan_to_num(bg_no_nan[:, i], nan=np.nanmean(bg_no_nan[:, i]), copy=False)
dist = scipy.spatial.distance.pdist(bg_no_nan.T + np.random.randn(*bg_no_nan.T.shape) * 1e-8, metric=metric)
# build linkage
if linkage == "single":
return scipy.cluster.hierarchy.single(dist)
elif linkage == "complete":
return scipy.cluster.hierarchy.complete(dist)
elif linkage == "average":
return scipy.cluster.hierarchy.average(dist)