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

860 lines
33 KiB
Python

from __future__ import annotations
import typing
import warnings
from typing import Any, Literal
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.markers import MarkerStyle
from .._explanation import Explanation
from ..utils import approximate_interactions, convert_name
from ..utils._exceptions import DimensionError
from ..utils._general import encode_array_if_needed
from . import colors
from ._labels import labels
from ._utils import AxisLimitSpec, parse_axis_limit
# TODO: Make the color bar a one-sided beeswarm plot so we can see the density along the color axis
def scatter(
shap_values: Explanation,
color: str | Explanation | None = "#1E88E5",
hist: bool = True,
axis_color="#333333",
cmap=colors.red_blue,
dot_size=16,
x_jitter: float | Literal["auto"] = "auto",
alpha: float = 1.0,
title: str | None = None,
xmin: AxisLimitSpec = None,
xmax: AxisLimitSpec = None,
ymin: AxisLimitSpec = None,
ymax: AxisLimitSpec = None,
overlay: dict[str, Any] | None = None,
ax: plt.Axes | None = None,
ylabel: str = "SHAP value",
show: bool = True,
):
"""Create a SHAP dependence scatter plot, optionally colored by an interaction feature.
Plots the value of the feature on the x-axis and the SHAP value of the same feature
on the y-axis. This shows how the model depends on the given feature, and is like a
richer extension of classical partial dependence plots. Vertical dispersion of the
data points represents interaction effects. Grey ticks along the y-axis are data
points where the feature's value was NaN.
Note that if you want to change the data being displayed, you can update the
``shap_values.display_features`` attribute and it will then be used for plotting instead of
``shap_values.data``.
Parameters
----------
shap_values : shap.Explanation
Typically a single column of an :class:`.Explanation` object
(i.e. ``shap_values[:, "Feature A"]``).
Alternatively, pass multiple columns to create several subplots
(i.e. ``shap_values[:, ["Feature A", "Feature B"]]``).
color : string or shap.Explanation, optional
How to color the scatter plot points. This can be a fixed color string, or an
:class:`.Explanation` object.
If it is an :class:`.Explanation` object, then the scatter plot points are
colored by the feature that seems to have the strongest interaction effect with
the feature given by the ``shap_values`` argument. This is calculated using
:func:`shap.utils.approximate_interactions`.
If only a single column of an :class:`.Explanation` object is passed, then that
feature column will be used to color the data points.
hist : bool
Whether to show a light histogram along the x-axis to show the density of the
data. Note that the histogram is normalized such that if all the points were in
a single bin, then that bin would span the full height of the plot. Defaults to
``True``.
x_jitter : 'auto' or float
Adds random jitter to feature values by specifying a float between 0 to 1. May
increase plot readability when a feature is discrete. By default, ``x_jitter``
is chosen based on auto-detection of categorical features.
title: str, optional
Plot title.
alpha : float
The transparency of the data points (between 0 and 1). This can be useful to
show the density of the data points when using a large dataset.
xmin, xmax, ymin, ymax : float, string, aggregated Explanation or None
Desired axis limits. Can be a float to specify a fixed limit.
It can be a string of the format ``"percentile(float)"`` to denote that
percentile of the feature's value.
It can also be an aggregated column of a single column of an :class:`.Explanation`,
such as ``explanation[:, "feature_name"].percentile(20)``.
overlay: dict, optional
Optional dictionary of up to three additional curves to overlay as line plots.
The dictionary maps a curve name to a list of (xvalues, yvalues) pairs, where
there is one pair for each feature to be plotted.
ax : matplotlib Axes, optional
Optionally specify an existing :external+mpl:class:`matplotlib.axes.Axes` object, into which
the plot will be placed.
Only supported when plotting a single feature.
show : bool
Whether :external+mpl:func:`matplotlib.pyplot.show()` is called before returning.
Setting this to ``False`` allows the plot to be customized further after it
has been created.
Returns
-------
ax : matplotlib Axes object
Returns the :external+mpl:class:`~matplotlib.axes.Axes` object with the plot drawn onto it. Only
returned if ``show=False``.
Examples
--------
See `scatter plot examples <https://shap.readthedocs.io/en/latest/example_notebooks/api_examples/plots/scatter.html>`_.
"""
if not isinstance(shap_values, Explanation):
raise TypeError("The shap_values parameter must be a shap.Explanation object!")
# see if we are plotting multiple columns
if not isinstance(shap_values.feature_names, str) and len(shap_values.feature_names) > 0:
if ax is not None:
raise ValueError("The ax parameter is not supported when plotting multiple features")
# Define order of columns (features) to plot based on average shap value
inds = np.argsort(np.abs(shap_values.values).mean(0))
ymin = parse_axis_limit(ymin, shap_values.values, is_shap_axis=True)
ymax = parse_axis_limit(ymax, shap_values.values, is_shap_axis=True)
ymin, ymax = _suggest_buffered_limits(ymin, ymax, shap_values.values)
_ = plt.subplots(1, len(inds), figsize=(min(6 * len(inds), 15), 5))
for i in inds:
ax = plt.subplot(1, len(inds), i + 1)
scatter(shap_values[:, i], color=color, show=False, ax=ax, ymin=ymin, ymax=ymax)
if overlay is not None:
line_styles = ["solid", "dotted", "dashed"]
for j, name in enumerate(overlay):
vals = overlay[name]
if isinstance(vals[i][0][0], (float, int)):
plt.plot(vals[i][0], vals[i][1], color="#000000", linestyle=line_styles[j], label=name)
if i == 0:
ax.set_ylabel(ylabel)
else:
ax.set_ylabel("")
ax.set_yticks([])
ax.spines["left"].set_visible(False)
if overlay is not None:
plt.legend()
if show:
plt.show()
return
if len(shap_values.shape) != 1:
raise DimensionError(
"The passed Explanation object has multiple columns. Please pass a single feature column to "
"shap.plots.scatter like: shap_values[:, column]"
)
# this unpacks the explanation object for the code that was written earlier
feature_names = [shap_values.feature_names]
ind: int = 0
shap_values_arr = shap_values.values.reshape(-1, 1)
features = shap_values.data.reshape(-1, 1)
if shap_values.display_data is None:
display_features = features
else:
display_features = shap_values.display_data.reshape(-1, 1)
interaction_index: str | int | None = None
# wrap np.arrays as Explanations
if isinstance(color, np.ndarray):
color = Explanation(values=color, base_values=None, data=color)
# TODO: This stacking could be avoided if we use the new shap.utils.potential_interactions function
if isinstance(color, Explanation):
shap_values2 = color
if issubclass(type(shap_values2.feature_names), (str, int)):
feature_names.append(shap_values2.feature_names)
shap_values_arr = np.hstack([shap_values_arr, shap_values2.values.reshape(-1, len(feature_names) - 1)])
features = np.hstack([features, shap_values2.data.reshape(-1, len(feature_names) - 1)])
if shap_values2.display_data is None:
display_features = np.hstack([display_features, shap_values2.data.reshape(-1, len(feature_names) - 1)])
else:
display_features = np.hstack(
[display_features, shap_values2.display_data.reshape(-1, len(feature_names) - 1)]
)
else:
feature_names2 = np.array(shap_values2.feature_names)
mask = ~(feature_names[0] == feature_names2)
feature_names.extend(feature_names2[mask])
shap_values_arr = np.hstack([shap_values_arr, shap_values2.values[:, mask]])
features = np.hstack([features, shap_values2.data[:, mask]])
if shap_values2.display_data is None:
display_features = np.hstack([display_features, shap_values2.data[:, mask]])
else:
display_features = np.hstack([display_features, shap_values2.display_data[:, mask]])
color = None
interaction_index = "auto"
if isinstance(shap_values_arr, list):
raise TypeError(
"The passed shap_values_arr are a list not an array! If you have a list of explanations try "
"passing shap_values_arr[0] instead to explain the first output class of a multi-output model."
)
# convert from DataFrames if we got any
if isinstance(features, pd.DataFrame):
if feature_names is None:
feature_names = features.columns
features = features.values
if feature_names is None:
feature_names = [labels["FEATURE"] % str(i) for i in range(shap_values_arr.shape[1])]
# allow vectors to be passed
if len(shap_values_arr.shape) == 1:
shap_values_arr = np.reshape(shap_values_arr, (len(shap_values_arr), 1))
if len(features.shape) == 1:
features = np.reshape(features, (len(features), 1))
# pick jitter for categorical features
if x_jitter == "auto":
x_jitter = _suggest_x_jitter(features[:, ind])
# guess what other feature as the strongest interaction with the plotted feature
if interaction_index == "auto":
interaction_index = approximate_interactions(ind, shap_values_arr, features)[0]
interaction_index = convert_name(interaction_index, shap_values_arr, feature_names)
categorical_interaction = False
# create a matplotlib figure, if `ax` hasn't been specified.
if ax is None:
figsize = (7.5, 5) if interaction_index != ind and interaction_index is not None else (6, 5)
_, ax = plt.subplots(figsize=figsize)
assert shap_values_arr.shape[0] == features.shape[0], (
"'shap_values_arr' and 'features' values must have the same number of rows!"
)
assert shap_values_arr.shape[1] == features.shape[1], (
"'shap_values_arr' must have the same number of columns as 'features'!"
)
# get both the raw and display feature values
oinds = np.arange(
shap_values_arr.shape[0]
) # we randomize the ordering so plotting overlaps are not related to data ordering
np.random.shuffle(oinds)
xv = encode_array_if_needed(features[oinds, ind])
xd = display_features[oinds, ind]
s = shap_values_arr[oinds, ind]
if isinstance(xd[0], str):
name_map = {}
for i in range(len(xv)):
name_map[xd[i]] = xv[i]
xnames = list(name_map.keys())
# allow a single feature name to be passed alone
if isinstance(feature_names, str):
feature_names = [feature_names]
name = feature_names[ind]
# get both the raw and display color values
color_norm = None
if interaction_index is not None:
interaction_feature_values = encode_array_if_needed(features[:, interaction_index])
cv = interaction_feature_values
cd = display_features[:, interaction_index]
clow = np.nanpercentile(cv.astype(float), 5)
chigh = np.nanpercentile(cv.astype(float), 95)
if clow == chigh:
clow = np.nanmin(cv.astype(float))
chigh = np.nanmax(cv.astype(float))
if isinstance(cd[0], str):
cname_map = {}
for i in range(len(cv)):
cname_map[cd[i]] = cv[i]
cnames = list(cname_map.keys())
categorical_interaction = True
elif clow % 1 == 0 and chigh % 1 == 0 and chigh - clow < 10:
categorical_interaction = True
# discritize colors for categorical features
if categorical_interaction and clow != chigh:
clow = np.nanmin(cv.astype(float))
chigh = np.nanmax(cv.astype(float))
bounds = np.linspace(clow, chigh, min(int(chigh - clow + 2), cmap.N - 1))
color_norm = matplotlib.colors.BoundaryNorm(bounds, cmap.N - 1)
# optionally add jitter to feature values
xv_no_jitter = xv.copy()
if x_jitter > 0:
if x_jitter > 1:
x_jitter = 1
xvals = xv.copy()
if isinstance(xvals[0], float):
xvals = xvals.astype(float)
xvals = xvals[~np.isnan(xvals)]
xvals = np.unique(xvals) # returns a sorted array
if len(xvals) >= 2:
smallest_diff = np.min(np.diff(xvals))
jitter_amount = x_jitter * smallest_diff
xv += (np.random.random_sample(size=len(xv)) * jitter_amount) - (jitter_amount / 2)
# the actual scatter plot, TODO: adapt the dot_size to the number of data points?
xv_nan = np.isnan(xv)
xv_notnan = np.invert(xv_nan)
if interaction_index is not None:
# plot the nan values in the interaction feature as grey
cvals = encode_array_if_needed(features[oinds, interaction_index]).astype(np.float64)
cvals_imp = cvals.copy()
cvals_imp[np.isnan(cvals)] = (clow + chigh) / 2.0
cvals[cvals_imp > chigh] = chigh
cvals[cvals_imp < clow] = clow
if color_norm is None:
vmin = clow
vmax = chigh
else:
vmin = vmax = None
ax.axhline(0, color="#888888", lw=0.5, dashes=(1, 5), zorder=-1)
p = ax.scatter(
xv[xv_notnan],
s[xv_notnan],
s=dot_size,
linewidth=0,
c=cvals[xv_notnan],
cmap=cmap,
alpha=alpha,
vmin=vmin,
vmax=vmax,
norm=color_norm,
rasterized=len(xv) > 500,
)
p.set_array(cvals[xv_notnan])
else:
p = ax.scatter(xv, s, s=dot_size, linewidth=0, color=color, alpha=alpha, rasterized=len(xv) > 500)
if interaction_index != ind and interaction_index is not None:
# draw the color bar
if isinstance(cd[0], str):
tick_positions = np.array([cname_map[n] for n in cnames])
tick_positions *= 1 - 1 / len(cnames)
tick_positions += 0.5 * (chigh - clow) / (chigh - clow + 1)
cb = plt.colorbar(p, ticks=tick_positions, ax=ax, aspect=80)
cb.set_ticklabels(cnames)
else:
cb = plt.colorbar(p, ax=ax, aspect=80)
# Type narrowing for mypy
assert isinstance(interaction_index, (int, np.integer)), f"Unexpected {type(interaction_index)=}"
cb.set_label(feature_names[interaction_index], size=13)
cb.ax.tick_params(labelsize=11)
if categorical_interaction:
cb.ax.tick_params(length=0)
cb.set_alpha(1)
cb.outline.set_visible(False) # type: ignore
# bbox = cb.ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
# cb.ax.set_aspect((bbox.height - 0.7) * 20)
xmin = parse_axis_limit(xmin, xv, is_shap_axis=False)
xmax = parse_axis_limit(xmax, xv, is_shap_axis=False)
ymin = parse_axis_limit(ymin, s, is_shap_axis=True)
ymax = parse_axis_limit(ymax, s, is_shap_axis=True)
if xmin is not None or xmax is not None:
ax.set_xlim(*_suggest_buffered_limits(xmin, xmax, xv))
if ymin is not None or ymax is not None:
ax.set_ylim(*_suggest_buffered_limits(ymin, ymax, s))
# plot any nan feature values as tick marks along the y-axis
xlim = ax.get_xlim()
if interaction_index is not None:
p = ax.scatter(
xlim[0] * np.ones(xv_nan.sum()),
s[xv_nan],
marker=MarkerStyle(1),
linewidth=2,
c=cvals_imp[xv_nan],
cmap=cmap,
alpha=alpha,
vmin=clow,
vmax=chigh,
)
p.set_array(cvals[xv_nan])
else:
ax.scatter(
xlim[0] * np.ones(xv_nan.sum()), s[xv_nan], marker=MarkerStyle(1), linewidth=2, color=color, alpha=alpha
)
ax.set_xlim(xlim)
# the histogram of the data
if hist:
_plot_histogram(ax, xv, xv_no_jitter)
plt.sca(ax)
# make the plot more readable
ax.set_xlabel(name, color=axis_color, fontsize=13)
ax.set_ylabel(labels["VALUE_FOR"] % name, color=axis_color, fontsize=13)
if title is not None:
ax.set_title(title, color=axis_color, fontsize=13)
ax.xaxis.set_ticks_position("bottom")
ax.yaxis.set_ticks_position("left")
ax.spines["right"].set_visible(False)
ax.spines["top"].set_visible(False)
ax.tick_params(color=axis_color, labelcolor=axis_color, labelsize=11)
for spine in ax.spines.values():
spine.set_edgecolor(axis_color)
if isinstance(xd[0], str):
ax.set_xticks([name_map[n] for n in xnames])
ax.set_xticklabels(xnames, fontdict=dict(rotation="vertical", fontsize=11))
if show:
with warnings.catch_warnings(): # ignore expected matplotlib warnings
warnings.simplefilter("ignore", RuntimeWarning)
plt.show()
else:
return ax
def _suggest_buffered_limits(ax_min: float | None, ax_max: float | None, values: np.ndarray) -> tuple[float, float]:
"""If either limit is None, suggest suitable value including a buffer either side"""
nan_max = np.nanmax(values) if ax_max is None else ax_max
nan_min = np.nanmin(values) if ax_min is None else ax_min
buffer = (nan_max - nan_min) / 20
if ax_min is None:
ax_min = float(nan_min - buffer)
if ax_max is None:
ax_max = float(nan_max + buffer)
return ax_min, ax_max
def _suggest_x_jitter(values: np.ndarray) -> float:
"""Suggest a suitable x_jitter value based on the unique values in the feature"""
unique_vals = np.sort(np.unique(values))
if len(unique_vals) < 2:
# If there is only one unique value, no jitter is needed
return 0.0
try:
# Identify the smallest difference between unique values
diffs = np.diff(unique_vals)
min_dist = np.min(diffs[diffs > 1e-8])
except (TypeError, ValueError):
# If unique_vals contains non-numeric values or all differences are to small, set arbitrarily at 1
min_dist = 1
num_points_per_value = len(values) / len(unique_vals)
if num_points_per_value < 10:
# categorical = False
x_jitter = 0
elif num_points_per_value < 100:
# categorical = True
x_jitter = min_dist * 0.1
else:
# categorical = True
x_jitter = min_dist * 0.2
return x_jitter
def _plot_histogram(ax: plt.Axes, xv, xv_no_jitter):
"""Add a histogram of the data on a matching secondary axes"""
ax2 = typing.cast("plt.Axes", ax.twinx())
xlim = ax.get_xlim()
xvals = np.unique(xv_no_jitter)
# Determine suitable bins and limits
bins: list[float] | int # Hint for mypy
if len(xvals) / len(xv_no_jitter) < 0.2 and len(xvals) < 75 and np.max(xvals) < 75 and np.min(xvals) >= 0:
np.sort(xvals)
bins = []
for i in range(int(np.max(xvals) + 1)):
bins.append(i - 0.5)
bins.append(int(np.max(xvals)) + 0.5)
lim = np.floor(np.min(xvals) - 0.5) + 0.5, np.ceil(np.max(xvals) + 0.5) - 0.5
ax.set_xlim(lim)
else:
if len(xv_no_jitter) >= 500:
bins = 50
elif len(xv_no_jitter) >= 200:
bins = 20
elif len(xv_no_jitter) >= 100:
bins = 10
else:
bins = 5
# Plot the histogram
ax2.hist(
xv[~np.isnan(xv)],
bins,
density=False,
facecolor="#000000",
alpha=0.1,
range=(xlim[0], xlim[1]),
zorder=-1,
)
ax2.set_ylim(0, len(xv))
ax2.xaxis.set_ticks_position("bottom")
ax2.yaxis.set_ticks_position("left")
ax2.yaxis.set_ticks([])
ax2.spines["right"].set_visible(False)
ax2.spines["top"].set_visible(False)
ax2.spines["left"].set_visible(False)
ax2.spines["bottom"].set_visible(False)
def dependence_legacy(
ind,
shap_values=None,
features=None,
feature_names=None,
display_features=None,
interaction_index="auto",
color="#1E88E5",
axis_color="#333333",
cmap=None,
dot_size=16,
x_jitter=0,
alpha=1,
title=None,
xmin=None,
xmax=None,
ax=None,
show=True,
ymin=None,
ymax=None,
):
"""Create a SHAP dependence plot, colored by an interaction feature.
Plots the value of the feature on the x-axis and the SHAP value of the same feature
on the y-axis. This shows how the model depends on the given feature, and is like a
richer extension of the classical partial dependence plots. Vertical dispersion of the
data points represents interaction effects. Grey ticks along the y-axis are data
points where the feature's value was NaN.
Parameters
----------
ind : int or string
If this is an int it is the index of the feature to plot. If this is a string it is
either the name of the feature to plot, or it can have the form "rank(int)" to specify
the feature with that rank (ordered by mean absolute SHAP value over all the samples).
shap_values : numpy.array
Matrix of SHAP values (# samples x # features).
features : numpy.array or pandas.DataFrame
Matrix of feature values (# samples x # features).
feature_names : list
Names of the features (length # features).
display_features : numpy.array or pandas.DataFrame
Matrix of feature values for visual display (such as strings instead of coded values).
interaction_index : "auto", None, int, or string
The index of the feature used to color the plot. The name of a feature can also be passed
as a string. If "auto" then shap.common.approximate_interactions is used to pick what
seems to be the strongest interaction (note that to find to true strongest interaction you
need to compute the SHAP interaction values).
x_jitter : float (0 - 1)
Adds random jitter to feature values. May increase plot readability when feature
is discrete.
alpha : float
The transparency of the data points (between 0 and 1). This can be useful to the
show density of the data points when using a large dataset.
xmin : float or string
Represents the lower bound of the plot's x-axis. It can be a string of the format
"percentile(float)" to denote that percentile of the feature's value used on the x-axis.
xmax : float or string
Represents the upper bound of the plot's x-axis. It can be a string of the format
"percentile(float)" to denote that percentile of the feature's value used on the x-axis.
ax : matplotlib Axes object
Optionally specify an existing matplotlib Axes object, into which the plot will be placed.
In this case we do not create a Figure, otherwise we do.
ymin : float
Represents the lower bound of the plot's y-axis.
ymax : float
Represents the upper bound of the plot's y-axis.
"""
if cmap is None:
cmap = colors.red_blue
if isinstance(shap_values, list):
raise TypeError(
"The passed shap_values are a list not an array! If you have a list of explanations try "
"passing shap_values[0] instead to explain the first output class of a multi-output model."
)
# convert from DataFrames if we got any
if isinstance(features, pd.DataFrame):
if feature_names is None:
feature_names = features.columns
features = features.values
if isinstance(display_features, pd.DataFrame):
if feature_names is None:
feature_names = display_features.columns
display_features = display_features.values
elif display_features is None:
display_features = features
if feature_names is None:
feature_names = [labels["FEATURE"] % str(i) for i in range(shap_values.shape[1])]
# allow vectors to be passed
if len(shap_values.shape) == 1:
shap_values = np.reshape(shap_values, (len(shap_values), 1))
if len(features.shape) == 1:
features = np.reshape(features, (len(features), 1))
ind = convert_name(ind, shap_values, feature_names)
# guess what other feature as the strongest interaction with the plotted feature
if not hasattr(ind, "__len__"):
if interaction_index == "auto":
interaction_index = approximate_interactions(ind, shap_values, features)[0]
interaction_index = convert_name(interaction_index, shap_values, feature_names)
categorical_interaction = False
# create a matplotlib figure, if `ax` hasn't been specified.
if not ax:
figsize = (7.5, 5) if interaction_index != ind and interaction_index is not None else (6, 5)
fig = plt.figure(figsize=figsize)
ax = fig.gca()
else:
fig = ax.get_figure()
# plotting SHAP interaction values
if len(shap_values.shape) == 3 and hasattr(ind, "__len__") and len(ind) == 2:
ind1 = convert_name(ind[0], shap_values, feature_names)
ind2 = convert_name(ind[1], shap_values, feature_names)
if ind1 == ind2:
proj_shap_values = shap_values[:, ind2, :]
else:
proj_shap_values = shap_values[:, ind2, :] * 2 # off-diag values are split in half
# there is no interaction coloring for the main effect
if ind1 == ind2:
fig.set_size_inches(6, 5, forward=True)
# TODO: remove recursion; generally the functions should be shorter for more maintainable code
dependence_legacy(
ind1,
proj_shap_values,
features,
feature_names=feature_names,
interaction_index=(None if ind1 == ind2 else ind2),
display_features=display_features,
ax=ax,
show=False,
xmin=xmin,
xmax=xmax,
x_jitter=x_jitter,
alpha=alpha,
)
if ind1 == ind2:
ax.set_ylabel(labels["MAIN_EFFECT"] % feature_names[ind1])
else:
ax.set_ylabel(labels["INTERACTION_EFFECT"] % (feature_names[ind1], feature_names[ind2]))
if show:
plt.show()
return
assert shap_values.shape[0] == features.shape[0], (
"'shap_values' and 'features' values must have the same number of rows!"
)
assert shap_values.shape[1] == features.shape[1], (
"'shap_values' must have the same number of columns as 'features'!"
)
# get both the raw and display feature values
oinds = np.arange(
shap_values.shape[0]
) # we randomize the ordering so plotting overlaps are not related to data ordering
np.random.shuffle(oinds)
xv = encode_array_if_needed(features[oinds, ind])
xd = display_features[oinds, ind]
s = shap_values[oinds, ind]
if isinstance(xd[0], str):
name_map = {}
for i in range(len(xv)):
name_map[xd[i]] = xv[i]
xnames = list(name_map.keys())
# allow a single feature name to be passed alone
if isinstance(feature_names, str):
feature_names = [feature_names]
name = feature_names[ind]
# get both the raw and display color values
color_norm = None
if interaction_index is not None:
interaction_feature_values = encode_array_if_needed(features[:, interaction_index])
cv = interaction_feature_values
cd = display_features[:, interaction_index]
clow = np.nanpercentile(cv.astype(float), 5)
chigh = np.nanpercentile(cv.astype(float), 95)
if clow == chigh:
clow = np.nanmin(cv.astype(float))
chigh = np.nanmax(cv.astype(float))
if isinstance(cd[0], str):
cname_map = {}
for i in range(len(cv)):
cname_map[cd[i]] = cv[i]
cnames = list(cname_map.keys())
categorical_interaction = True
elif clow % 1 == 0 and chigh % 1 == 0 and chigh - clow < 10:
categorical_interaction = True
# discritize colors for categorical features
if categorical_interaction and clow != chigh:
clow = np.nanmin(cv.astype(float))
chigh = np.nanmax(cv.astype(float))
bounds = np.linspace(clow, chigh, min(int(chigh - clow + 2), cmap.N - 1))
color_norm = matplotlib.colors.BoundaryNorm(bounds, cmap.N - 1)
# optionally add jitter to feature values
if x_jitter > 0:
if x_jitter > 1:
x_jitter = 1
xvals = xv.copy()
if isinstance(xvals[0], float):
xvals = xvals.astype(float)
xvals = xvals[~np.isnan(xvals)]
xvals = np.unique(xvals) # returns a sorted array
if len(xvals) >= 2:
smallest_diff = np.min(np.diff(xvals))
jitter_amount = x_jitter * smallest_diff
xv += (np.random.random_sample(size=len(xv)) * jitter_amount) - (jitter_amount / 2)
# the actual scatter plot, TODO: adapt the dot_size to the number of data points?
xv_nan = np.isnan(xv)
xv_notnan = np.invert(xv_nan)
if interaction_index is not None:
# plot the nan values in the interaction feature as grey
cvals = interaction_feature_values[oinds].astype(np.float64)
cvals_imp = cvals.copy()
cvals_imp[np.isnan(cvals)] = (clow + chigh) / 2.0
cvals[cvals_imp > chigh] = chigh
cvals[cvals_imp < clow] = clow
p = ax.scatter(
xv[xv_notnan],
s[xv_notnan],
s=dot_size,
linewidth=0,
c=cvals[xv_notnan],
cmap=cmap,
alpha=alpha,
norm=color_norm,
rasterized=len(xv) > 500,
)
p.set_array(cvals[xv_notnan])
else:
p = ax.scatter(xv, s, s=dot_size, linewidth=0, color=color, alpha=alpha, rasterized=len(xv) > 500)
if interaction_index != ind and interaction_index is not None:
# draw the color bar
if isinstance(cd[0], str):
tick_positions = [cname_map[n] for n in cnames]
if len(tick_positions) == 2:
tick_positions[0] -= 0.25
tick_positions[1] += 0.25
cb = plt.colorbar(p, ticks=tick_positions, ax=ax, aspect=80)
cb.set_ticklabels(cnames)
else:
cb = plt.colorbar(p, ax=ax, aspect=80)
cb.set_label(feature_names[interaction_index], size=13)
cb.ax.tick_params(labelsize=11)
if categorical_interaction:
cb.ax.tick_params(length=0)
cb.set_alpha(1)
cb.outline.set_visible(False) # type: ignore
# bbox = cb.ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
# cb.ax.set_aspect((bbox.height - 0.7) * 20)
# handles any setting of xmax and xmin
# note that we handle None,float, or "percentile(float)" formats
if xmin is not None or xmax is not None:
if isinstance(xmin, str) and xmin.startswith("percentile"):
xmin = np.nanpercentile(xv, float(xmin[11:-1]))
if isinstance(xmax, str) and xmax.startswith("percentile"):
xmax = np.nanpercentile(xv, float(xmax[11:-1]))
if xmin is None or xmin == np.nanmin(xv):
xmin = np.nanmin(xv) - (xmax - np.nanmin(xv)) / 20
if xmax is None or xmax == np.nanmax(xv):
xmax = np.nanmax(xv) + (np.nanmax(xv) - xmin) / 20
ax.set_xlim(xmin, xmax)
# plot any nan feature values as tick marks along the y-axis
xlim = ax.get_xlim()
if interaction_index is not None:
p = ax.scatter(
xlim[0] * np.ones(xv_nan.sum()),
s[xv_nan],
marker=1,
linewidth=2,
c=cvals_imp[xv_nan],
cmap=cmap,
alpha=alpha,
vmin=clow,
vmax=chigh,
)
p.set_array(cvals[xv_nan])
else:
ax.scatter(xlim[0] * np.ones(xv_nan.sum()), s[xv_nan], marker=1, linewidth=2, color=color, alpha=alpha)
ax.set_xlim(xlim)
# make the plot more readable
ax.set_xlabel(name, color=axis_color, fontsize=13)
ax.set_ylabel(labels["VALUE_FOR"] % name, color=axis_color, fontsize=13)
if (ymin is not None) or (ymax is not None):
if ymin is None:
ymin = -ymax
if ymax is None:
ymax = -ymin
ax.set_ylim(ymin, ymax)
if title is not None:
ax.set_title(title, color=axis_color, fontsize=13)
ax.xaxis.set_ticks_position("bottom")
ax.yaxis.set_ticks_position("left")
ax.spines["right"].set_visible(False)
ax.spines["top"].set_visible(False)
ax.tick_params(color=axis_color, labelcolor=axis_color, labelsize=11)
for spine in ax.spines.values():
spine.set_edgecolor(axis_color)
if isinstance(xd[0], str):
ax.set_xticks([name_map[n] for n in xnames])
ax.set_xticklabels(xnames, fontdict=dict(rotation="vertical", fontsize=11))
if show:
with warnings.catch_warnings(): # ignore expected matplotlib warnings
warnings.simplefilter("ignore", RuntimeWarning)
plt.show()