"""Summary plots of SHAP values across a whole dataset.""" from __future__ import annotations import warnings from typing import Literal import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd import scipy.cluster import scipy.sparse import scipy.spatial from matplotlib.figure import Figure from packaging import version from scipy.stats import gaussian_kde from .. import Explanation from ..utils import safe_isinstance from ..utils._exceptions import DimensionError from . import colors from ._labels import labels from ._utils import ( convert_color, convert_ordering, get_sort_order, merge_nodes, sort_inds, ) # TODO: simplify this when we drop support for matplotlib 3.9 if version.parse(matplotlib.__version__) >= version.parse("3.10"): ORIENTATION_KWARG = dict(orientation="horizontal") else: ORIENTATION_KWARG = dict(vert=False) # type: ignore[dict-item] # TODO: Add support for hclustering based explanations where we sort the leaf order by magnitude and then show the dendrogram to the left def beeswarm( shap_values: Explanation, max_display: int | None = 10, order=Explanation.abs.mean(0), # type: ignore clustering=None, cluster_threshold=0.5, color=None, axis_color="#333333", alpha: float = 1.0, ax: plt.Axes | None = None, show: bool = True, log_scale: bool = False, color_bar: bool = True, s: float = 16, plot_size: Literal["auto"] | float | tuple[float, float] | None = "auto", color_bar_label: str = labels["FEATURE_VALUE"], group_remaining_features: bool = True, ): """Create a SHAP beeswarm plot, colored by feature values when they are provided. Parameters ---------- shap_values : Explanation This is an :class:`.Explanation` object containing a matrix of SHAP values (# samples x # features). max_display : int How many top features to include in the plot (default is 10, or 7 for interaction plots). ax: matplotlib Axes Axes object to draw the plot onto, otherwise uses the current Axes. 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, returning the current axis via :external+mpl:func:`matplotlib.pyplot.gca()`. color_bar : bool Whether to draw the color bar (legend). s : float What size to make the markers. For further information, see ``s`` in :external+mpl:func:`matplotlib.pyplot.scatter`. plot_size : "auto" (default), float, (float, float), or None What size to make the plot. By default, the size is auto-scaled based on the number of features that are being displayed. Passing a single float will cause each row to be that many inches high. Passing a pair of floats will scale the plot by that number of inches. If ``None`` is passed, then the size of the current figure will be left unchanged. If ``ax`` is not ``None``, then passing ``plot_size`` will raise a :exc:`ValueError`. group_remaining_features: bool If there are more features than ``max_display``, then plot a row representing the sum of SHAP values of all remaining features. Default True. Returns ------- ax: matplotlib Axes Returns the :external+mpl:class:`~matplotlib.axes.Axes` object with the plot drawn onto it. Only returned if ``show=False``. Examples -------- See `beeswarm plot examples `_. """ if not isinstance(shap_values, Explanation): emsg = "The beeswarm plot requires an `Explanation` object as the `shap_values` argument." raise TypeError(emsg) sv_shape = shap_values.shape if len(sv_shape) == 1: emsg = ( "The beeswarm plot does not support plotting a single instance, please pass " "an explanation matrix with many instances!" ) raise ValueError(emsg) elif len(sv_shape) > 2: emsg = ( "The beeswarm plot does not support plotting explanations with instances that have more than one dimension!" ) raise ValueError(emsg) if ax and plot_size: emsg = ( "The beeswarm plot does not support passing an axis and adjusting the plot size. " "To adjust the size of the plot, set plot_size to None and adjust the size on the original figure the axes was part of" ) raise ValueError(emsg) shap_exp = shap_values # we make a copy here, because later there are places that might modify this array values = np.copy(shap_exp.values) features = shap_exp.data if scipy.sparse.issparse(features): features = features.toarray() feature_names = shap_exp.feature_names # if out_names is None: # TODO: waiting for slicer support # out_names = shap_exp.output_names order = convert_ordering(order, values) # multi_class = False # if isinstance(values, list): # multi_class = True # if plot_type is None: # plot_type = "bar" # default for multi-output explanations # assert plot_type == "bar", "Only plot_type = 'bar' is supported for multi-output explanations!" # else: # if plot_type is None: # plot_type = "dot" # default for single output explanations # assert len(values.shape) != 1, "Summary plots need a matrix of values, not a vector." # default color: if color is None: if features is not None: color = colors.red_blue else: color = colors.blue_rgb color = convert_color(color) idx2cat = None # convert from a DataFrame or other types if isinstance(features, pd.DataFrame): if feature_names is None: feature_names = features.columns # feature index to category flag idx2cat = features.dtypes.astype(str).isin(["object", "category"]).tolist() features = features.values elif isinstance(features, list): if feature_names is None: feature_names = features features = None elif (features is not None) and len(features.shape) == 1 and feature_names is None: feature_names = features features = None num_features = values.shape[1] if features is not None: shape_msg = "The shape of the shap_values matrix does not match the shape of the provided data matrix." if num_features - 1 == features.shape[1]: shape_msg += ( " Perhaps the extra column in the shap_values matrix is the " "constant offset? If so, just pass shap_values[:,:-1]." ) raise DimensionError(shape_msg) if num_features != features.shape[1]: raise DimensionError(shape_msg) if feature_names is None: feature_names = np.array([labels["FEATURE"] % str(i) for i in range(num_features)]) if ax is None: ax = plt.gca() fig = ax.get_figure() assert isinstance(fig, Figure) # type narrowing for mypy if log_scale: ax.set_xscale("symlog") if clustering is None: partition_tree = getattr(shap_values, "clustering", None) if partition_tree is not None and partition_tree.var(0).sum() == 0: partition_tree = partition_tree[0] else: partition_tree = None elif clustering is False: partition_tree = None else: partition_tree = clustering if partition_tree is not None: if partition_tree.shape[1] != 4: emsg = ( "The clustering provided by the Explanation object does not seem to " "be a partition tree (which is all shap.plots.bar supports)!" ) raise ValueError(emsg) # FIXME: introduce beeswarm interaction values as a separate function `beeswarm_interaction()` (?) # In the meantime, users can use the `shap.summary_plot()` function. # # # plotting SHAP interaction values # if len(values.shape) == 3: # # if plot_type == "compact_dot": # new_values = values.reshape(values.shape[0], -1) # new_features = np.tile(features, (1, 1, features.shape[1])).reshape(features.shape[0], -1) # # new_feature_names = [] # for c1 in feature_names: # for c2 in feature_names: # if c1 == c2: # new_feature_names.append(c1) # else: # new_feature_names.append(c1 + "* - " + c2) # # return beeswarm( # new_values, new_features, new_feature_names, # max_display=max_display, plot_type="dot", color=color, axis_color=axis_color, # title=title, alpha=alpha, show=show, sort=sort, # color_bar=color_bar, plot_size=plot_size, class_names=class_names, # color_bar_label="*" + color_bar_label # ) # # if max_display is None: # max_display = 7 # else: # max_display = min(len(feature_names), max_display) # # interaction_sort_inds = order#np.argsort(-np.abs(values.sum(1)).sum(0)) # # # get plotting limits # delta = 1.0 / (values.shape[1] ** 2) # slow = np.nanpercentile(values, delta) # shigh = np.nanpercentile(values, 100 - delta) # v = max(abs(slow), abs(shigh)) # slow = -v # shigh = v # # plt.figure(figsize=(1.5 * max_display + 1, 0.8 * max_display + 1)) # plt.subplot(1, max_display, 1) # proj_values = values[:, interaction_sort_inds[0], interaction_sort_inds] # proj_values[:, 1:] *= 2 # because off diag effects are split in half # beeswarm( # proj_values, features[:, interaction_sort_inds] if features is not None else None, # feature_names=feature_names[interaction_sort_inds], # sort=False, show=False, color_bar=False, # plot_size=None, # max_display=max_display # ) # plt.xlim((slow, shigh)) # plt.xlabel("") # title_length_limit = 11 # plt.title(shorten_text(feature_names[interaction_sort_inds[0]], title_length_limit)) # for i in range(1, min(len(interaction_sort_inds), max_display)): # ind = interaction_sort_inds[i] # plt.subplot(1, max_display, i + 1) # proj_values = values[:, ind, interaction_sort_inds] # proj_values *= 2 # proj_values[:, i] /= 2 # because only off diag effects are split in half # summary( # proj_values, features[:, interaction_sort_inds] if features is not None else None, # sort=False, # feature_names=["" for i in range(len(feature_names))], # show=False, # color_bar=False, # plot_size=None, # max_display=max_display # ) # plt.xlim((slow, shigh)) # plt.xlabel("") # if i == min(len(interaction_sort_inds), max_display) // 2: # plt.xlabel(labels['INTERACTION_VALUE']) # plt.title(shorten_text(feature_names[ind], title_length_limit)) # plt.tight_layout(pad=0, w_pad=0, h_pad=0.0) # plt.subplots_adjust(hspace=0, wspace=0.1) # if show: # plt.show() # return # determine how many top features we will plot if max_display is None: max_display = len(feature_names) num_features = min(max_display, len(feature_names)) # iteratively merge nodes until we can cut off the smallest feature values to stay within # num_features without breaking a cluster tree orig_inds = [[i] for i in range(len(feature_names))] orig_values = values.copy() while True: feature_order = convert_ordering(order, Explanation(np.abs(values))) if partition_tree is not None: # compute the leaf order if we were to show (and so have the ordering respect) the whole partition tree clust_order = sort_inds(partition_tree, np.abs(values)) # now relax the requirement to match the partition tree ordering for connections above cluster_threshold dist = scipy.spatial.distance.squareform(scipy.cluster.hierarchy.cophenet(partition_tree)) feature_order = get_sort_order(dist, clust_order, cluster_threshold, feature_order) # if the last feature we can display is connected in a tree the next feature then we can't just cut # off the feature ordering, so we need to merge some tree nodes and then try again. if ( max_display < len(feature_order) and dist[feature_order[max_display - 1], feature_order[max_display - 2]] <= cluster_threshold ): # values, partition_tree, orig_inds = merge_nodes(values, partition_tree, orig_inds) partition_tree, ind1, ind2 = merge_nodes(np.abs(values), partition_tree) for _ in range(len(values)): values[:, ind1] += values[:, ind2] values = np.delete(values, ind2, 1) orig_inds[ind1] += orig_inds[ind2] del orig_inds[ind2] else: break else: break # here we build our feature names, accounting for the fact that some features might be merged together feature_inds = feature_order[:max_display] feature_names_new = [] for inds in orig_inds: if len(inds) == 1: feature_names_new.append(feature_names[inds[0]]) elif len(inds) <= 2: feature_names_new.append(" + ".join([feature_names[i] for i in inds])) else: max_ind = np.argmax(np.abs(orig_values).mean(0)[inds]) feature_names_new.append(f"{feature_names[inds[max_ind]]} + {len(inds) - 1} other features") feature_names = feature_names_new # see how many individual (vs. grouped at the end) features we are plotting include_grouped_remaining = num_features < len(values[0]) and group_remaining_features if include_grouped_remaining: num_cut = np.sum([len(orig_inds[feature_order[i]]) for i in range(num_features - 1, len(values[0]))]) values[:, feature_order[num_features - 1]] = np.sum( [values[:, feature_order[i]] for i in range(num_features - 1, len(values[0]))], 0 ) # build our y-tick labels yticklabels = [feature_names[i] for i in feature_inds] if include_grouped_remaining: yticklabels[-1] = f"Sum of {num_cut} other features" row_height = 0.4 if plot_size == "auto": fig.set_size_inches(8, min(len(feature_order), max_display) * row_height + 1.5) elif isinstance(plot_size, (list, tuple)): fig.set_size_inches(plot_size[0], plot_size[1]) elif plot_size is not None: fig.set_size_inches(8, min(len(feature_order), max_display) * plot_size + 1.5) ax.axvline(x=0, color="#999999", zorder=-1) # make the beeswarm dots for pos, i in enumerate(reversed(feature_inds)): ax.axhline(y=pos, color="#cccccc", lw=0.5, dashes=(1, 5), zorder=-1) shaps = values[:, i] fvalues = None if features is None else features[:, i] f_inds = np.arange(len(shaps)) np.random.shuffle(f_inds) if fvalues is not None: fvalues = fvalues[f_inds] shaps = shaps[f_inds] colored_feature = True try: if idx2cat is not None and idx2cat[i]: # check categorical feature colored_feature = False else: fvalues = np.array(fvalues, dtype=np.float64) # make sure this can be numeric except Exception: colored_feature = False N = len(shaps) # hspacing = (np.max(shaps) - np.min(shaps)) / 200 # curr_bin = [] nbins = 100 quant = np.round(nbins * (shaps - np.min(shaps)) / (np.max(shaps) - np.min(shaps) + 1e-8)) inds_ = np.argsort(quant + np.random.randn(N) * 1e-6) layer = 0 last_bin = -1 ys = np.zeros(N) for ind in inds_: if quant[ind] != last_bin: layer = 0 ys[ind] = np.ceil(layer / 2) * ((layer % 2) * 2 - 1) layer += 1 last_bin = quant[ind] ys *= 0.9 * (row_height / np.max(ys + 1)) if safe_isinstance(color, "matplotlib.colors.Colormap") and fvalues is not None and colored_feature is True: # trim the color range, but prevent the color range from collapsing vmin = np.nanpercentile(fvalues, 5) vmax = np.nanpercentile(fvalues, 95) if vmin == vmax: vmin = np.nanpercentile(fvalues, 1) vmax = np.nanpercentile(fvalues, 99) if vmin == vmax: vmin = np.min(fvalues) vmax = np.max(fvalues) if vmin > vmax: # fixes rare numerical precision issues vmin = vmax if features is not None and features.shape[0] != len(shaps): emsg = "Feature and SHAP matrices must have the same number of rows!" raise DimensionError(emsg) # plot the nan fvalues in the interaction feature as grey nan_mask = np.isnan(fvalues) ax.scatter( shaps[nan_mask], pos + ys[nan_mask], color="#777777", s=s, alpha=alpha, linewidth=0, zorder=3, rasterized=len(shaps) > 500, ) # plot the non-nan fvalues colored by the trimmed feature value cvals = fvalues[np.invert(nan_mask)].astype(np.float64) cvals_imp = cvals.copy() cvals_imp[np.isnan(cvals)] = (vmin + vmax) / 2.0 cvals[cvals_imp > vmax] = vmax cvals[cvals_imp < vmin] = vmin ax.scatter( shaps[np.invert(nan_mask)], pos + ys[np.invert(nan_mask)], cmap=color, vmin=vmin, vmax=vmax, s=s, c=cvals, alpha=alpha, linewidth=0, zorder=3, rasterized=len(shaps) > 500, ) else: if safe_isinstance(color, "matplotlib.colors.Colormap") and hasattr(color, "colors"): color = color.colors ax.scatter( shaps, pos + ys, s=s, alpha=alpha, linewidth=0, zorder=3, color=color if colored_feature else "#777777", rasterized=len(shaps) > 500, ) # draw the color bar if safe_isinstance(color, "matplotlib.colors.Colormap") and color_bar and features is not None: import matplotlib.cm as cm m = cm.ScalarMappable(cmap=color) m.set_array([0, 1]) cb = fig.colorbar(m, ax=ax, ticks=[0, 1], aspect=80) cb.set_ticklabels([labels["FEATURE_VALUE_LOW"], labels["FEATURE_VALUE_HIGH"]]) cb.set_label(color_bar_label, size=12, labelpad=0) cb.ax.tick_params(labelsize=11, length=0) cb.set_alpha(1) cb.outline.set_visible(False) # type: ignore # bbox = cb.ax.get_window_extent().transformed(plt.gcf().dpi_scale_trans.inverted()) # cb.ax.set_aspect((bbox.height - 0.9) * 20) # cb.draw_all() ax.xaxis.set_ticks_position("bottom") ax.yaxis.set_ticks_position("none") ax.spines["right"].set_visible(False) ax.spines["top"].set_visible(False) ax.spines["left"].set_visible(False) ax.tick_params(color=axis_color, labelcolor=axis_color) ax.set_yticks(range(len(feature_inds)), list(reversed(yticklabels)), fontsize=13) ax.tick_params("y", length=20, width=0.5, which="major") ax.tick_params("x", labelsize=11) ax.set_ylim(-1, len(feature_inds)) ax.set_xlabel(labels["VALUE"], fontsize=13) if show: plt.show() else: return ax def shorten_text(text, length_limit): if len(text) > length_limit: return text[: length_limit - 3] + "..." else: return text def is_color_map(color): return safe_isinstance(color, "matplotlib.colors.Colormap") # TODO: remove unused title argument / use title argument # TODO: Add support for hclustering based explanations where we sort the leaf order by magnitude and then show the dendrogram to the left def summary_legacy( shap_values, features=None, feature_names=None, max_display=None, plot_type=None, color=None, axis_color="#333333", title=None, alpha=1, show=True, sort=True, color_bar=True, plot_size="auto", layered_violin_max_num_bins=20, class_names=None, class_inds=None, color_bar_label=labels["FEATURE_VALUE"], cmap=colors.red_blue, show_values_in_legend: bool = False, use_log_scale: bool = False, rng: np.random.Generator | None = None, ): """Create a SHAP beeswarm plot, colored by feature values when they are provided. Parameters ---------- shap_values : numpy.array For single output explanations this is a matrix of SHAP values (# samples x # features). For multi-output explanations this is a list of such matrices of SHAP values. features : numpy.array or pandas.DataFrame or list Matrix of feature values (# samples x # features) or a feature_names list as shorthand feature_names : list Names of the features (length # features) max_display : int How many top features to include in the plot (default is 20, or 7 for interaction plots) plot_type : "dot" (default for single output), "bar" (default for multi-output), "violin", or "compact_dot". What type of summary plot to produce. Note that "compact_dot" is only used for SHAP interaction values. plot_size : "auto" (default), float, (float, float), or None What size to make the plot. By default the size is auto-scaled based on the number of features that are being displayed. Passing a single float will cause each row to be that many inches high. Passing a pair of floats will scale the plot by that number of inches. If None is passed then the size of the current figure will be left unchanged. show_values_in_legend: bool Flag to print the mean of the SHAP values in the multi-output bar plot. Set to False by default. rng : `numpy.random.Generator`, optional Pseudorandom number generator state. When `rng` is None, the legacy behavior of using global NumPy random state will be used. Types other than `numpy.random.Generator` are passed to `numpy.random.default_rng` to instantiate a ``Generator``. """ # handle randomization machinery in conformance with SPEC 7 if rng is not None: rng = np.random.default_rng(rng) else: global_seed_set = np.random.mtrand._rand._bit_generator._seed_seq is None # type: ignore if global_seed_set: msg = ( "The NumPy global RNG was seeded by calling `np.random.seed`. " "In a future version this function will no longer use the global RNG. " "Pass `rng` explicitly to opt-in to the new behaviour and silence this warning." ) warnings.warn(msg, FutureWarning, stacklevel=2) # support passing an explanation object if str(type(shap_values)).endswith("Explanation'>"): shap_exp = shap_values shap_values = shap_exp.values if features is None: features = shap_exp.data if feature_names is None: feature_names = shap_exp.feature_names # Revert back to list for multi-output explanations. if len(shap_exp.base_values.shape) == 2 and shap_exp.base_values.shape[1] > 2: shap_values = [shap_values[:, :, i] for i in range(shap_exp.base_values.shape[1])] # if out_names is None: # TODO: waiting for slicer support of this # out_names = shap_exp.output_names multi_class = False if isinstance(shap_values, list): multi_class = True if plot_type is None: plot_type = "bar" # default for multi-output explanations assert plot_type == "bar", "Only plot_type = 'bar' is supported for multi-output explanations!" else: if plot_type is None: plot_type = "dot" # default for single output explanations assert len(shap_values.shape) != 1, "Summary plots need a matrix of shap_values, not a vector." # revert the shape of the shap_values matrix for multi-output explanations to list of matrices if len(shap_values.shape) == 3 and shap_values.shape[2] > 2 and plot_type == "bar": shap_values = [shap_values[:, :, i] for i in range(shap_values.shape[2])] multi_class = True # default color: if color is None: if plot_type == "layered_violin": color = "coolwarm" elif multi_class: def color(i): return colors.red_blue_circle(i / len(shap_values)) else: color = colors.blue_rgb idx2cat = None # convert from a DataFrame or other types if isinstance(features, pd.DataFrame): if feature_names is None: feature_names = features.columns # feature index to category flag idx2cat = features.dtypes.astype(str).isin(["object", "category"]).tolist() features = features.values elif isinstance(features, list): if feature_names is None: feature_names = features features = None elif (features is not None) and len(features.shape) == 1 and feature_names is None: feature_names = features features = None num_features = shap_values[0].shape[1] if multi_class else shap_values.shape[1] if features is not None: shape_msg = "The shape of the shap_values matrix does not match the shape of the provided data matrix." if num_features - 1 == features.shape[1]: raise ValueError( shape_msg + " Perhaps the extra column in the shap_values matrix is the " "constant offset? Of so just pass shap_values[:,:-1]." ) else: assert num_features == features.shape[1], shape_msg if feature_names is None: feature_names = np.array([labels["FEATURE"] % str(i) for i in range(num_features)]) if use_log_scale: plt.xscale("symlog") # plotting SHAP interaction values if not multi_class and len(shap_values.shape) == 3: if plot_type == "compact_dot": new_shap_values = shap_values.reshape(shap_values.shape[0], -1) new_features = np.tile(features, (1, 1, features.shape[1])).reshape(features.shape[0], -1) new_feature_names = [] for c1 in feature_names: for c2 in feature_names: if c1 == c2: new_feature_names.append(c1) else: new_feature_names.append(c1 + "* - " + c2) return summary_legacy( new_shap_values, new_features, new_feature_names, max_display=max_display, plot_type="dot", color=color, axis_color=axis_color, title=title, alpha=alpha, show=show, sort=sort, color_bar=color_bar, plot_size=plot_size, class_names=class_names, color_bar_label="*" + color_bar_label, ) if max_display is None: max_display = 7 else: max_display = min(len(feature_names), max_display) sort_inds = np.argsort(-np.abs(shap_values.sum(1)).sum(0)) # get plotting limits delta = 1.0 / (shap_values.shape[1] ** 2) slow = np.nanpercentile(shap_values, delta) shigh = np.nanpercentile(shap_values, 100 - delta) v = max(abs(slow), abs(shigh)) slow = -v shigh = v plt.figure(figsize=(1.5 * max_display + 1, 0.8 * max_display + 1)) plt.subplot(1, max_display, 1) proj_shap_values = shap_values[:, sort_inds[0], sort_inds] proj_shap_values[:, 1:] *= 2 # because off diag effects are split in half summary_legacy( proj_shap_values, features[:, sort_inds] if features is not None else None, feature_names=np.array(feature_names)[sort_inds].tolist(), sort=False, show=False, color_bar=False, plot_size=None, max_display=max_display, ) plt.xlim((slow, shigh)) plt.xlabel("") title_length_limit = 11 plt.title(shorten_text(feature_names[sort_inds[0]], title_length_limit)) for i in range(1, min(len(sort_inds), max_display)): ind = sort_inds[i] plt.subplot(1, max_display, i + 1) proj_shap_values = shap_values[:, ind, sort_inds] proj_shap_values *= 2 proj_shap_values[:, i] /= 2 # because only off diag effects are split in half summary_legacy( proj_shap_values, features[:, sort_inds] if features is not None else None, sort=False, feature_names=["" for i in range(len(feature_names))], show=False, color_bar=False, plot_size=None, max_display=max_display, ) plt.xlim((slow, shigh)) plt.xlabel("") if i == min(len(sort_inds), max_display) // 2: plt.xlabel(labels["INTERACTION_VALUE"]) plt.title(shorten_text(feature_names[ind], title_length_limit)) plt.tight_layout(pad=0, w_pad=0, h_pad=0.0) plt.subplots_adjust(hspace=0, wspace=0.1) if show: plt.show() return if max_display is None: max_display = 20 if sort: # order features by the sum of their effect magnitudes if multi_class: feature_order = np.argsort(np.sum(np.mean(np.abs(shap_values), axis=1), axis=0)) else: feature_order = np.argsort(np.sum(np.abs(shap_values), axis=0)) feature_order = feature_order[-min(max_display, len(feature_order)) :] else: feature_order = np.flip(np.arange(min(max_display, num_features)), 0) row_height = 0.4 if plot_size == "auto": plt.gcf().set_size_inches(8, len(feature_order) * row_height + 1.5) elif type(plot_size) in (list, tuple): plt.gcf().set_size_inches(plot_size[0], plot_size[1]) elif plot_size is not None: plt.gcf().set_size_inches(8, len(feature_order) * plot_size + 1.5) plt.axvline(x=0, color="#999999", zorder=-1) if plot_type == "dot": for pos, i in enumerate(feature_order): plt.axhline(y=pos, color="#cccccc", lw=0.5, dashes=(1, 5), zorder=-1) shaps = shap_values[:, i] values = None if features is None else features[:, i] inds = np.arange(len(shaps)) if rng is None: np.random.shuffle(inds) else: rng.shuffle(inds) if values is not None: values = values[inds] shaps = shaps[inds] colored_feature = True try: if idx2cat is not None and idx2cat[i]: # check categorical feature colored_feature = False else: values = np.array(values, dtype=np.float64) # make sure this can be numeric except Exception: colored_feature = False N = len(shaps) # hspacing = (np.max(shaps) - np.min(shaps)) / 200 # curr_bin = [] nbins = 100 quant = np.round(nbins * (shaps - np.min(shaps)) / (np.max(shaps) - np.min(shaps) + 1e-8)) if rng is None: tmp_x = np.random.randn(N) else: tmp_x = rng.standard_normal(N) inds = np.argsort(quant + tmp_x * 1e-6) layer = 0 last_bin = -1 ys = np.zeros(N) for ind in inds: if quant[ind] != last_bin: layer = 0 ys[ind] = np.ceil(layer / 2) * ((layer % 2) * 2 - 1) layer += 1 last_bin = quant[ind] ys *= 0.9 * (row_height / np.max(ys + 1)) if features is not None and colored_feature: # trim the color range, but prevent the color range from collapsing vmin = np.nanpercentile(values, 5) vmax = np.nanpercentile(values, 95) if vmin == vmax: vmin = np.nanpercentile(values, 1) vmax = np.nanpercentile(values, 99) if vmin == vmax: vmin = np.min(values) vmax = np.max(values) if vmin > vmax: # fixes rare numerical precision issues vmin = vmax assert features.shape[0] == len(shaps), "Feature and SHAP matrices must have the same number of rows!" # plot the nan values in the interaction feature as grey nan_mask = np.isnan(values) plt.scatter( shaps[nan_mask], pos + ys[nan_mask], color="#777777", s=16, alpha=alpha, linewidth=0, zorder=3, rasterized=len(shaps) > 500, ) # plot the non-nan values colored by the trimmed feature value cvals = values[np.invert(nan_mask)].astype(np.float64) cvals_imp = cvals.copy() cvals_imp[np.isnan(cvals)] = (vmin + vmax) / 2.0 cvals[cvals_imp > vmax] = vmax cvals[cvals_imp < vmin] = vmin plt.scatter( shaps[np.invert(nan_mask)], pos + ys[np.invert(nan_mask)], cmap=cmap, vmin=vmin, vmax=vmax, s=16, c=cvals, alpha=alpha, linewidth=0, zorder=3, rasterized=len(shaps) > 500, ) else: plt.scatter( shaps, pos + ys, s=16, alpha=alpha, linewidth=0, zorder=3, color=color if colored_feature else "#777777", rasterized=len(shaps) > 500, ) elif plot_type == "violin": for pos in range(len(feature_order)): plt.axhline(y=pos, color="#cccccc", lw=0.5, dashes=(1, 5), zorder=-1) if features is not None: global_low = np.nanpercentile(shap_values[:, : len(feature_names)].flatten(), 1) global_high = np.nanpercentile(shap_values[:, : len(feature_names)].flatten(), 99) for pos, i in enumerate(feature_order): shaps = shap_values[:, i] shap_min, shap_max = np.min(shaps), np.max(shaps) shap_max_min = shap_max - shap_min xs = np.linspace(np.min(shaps) - shap_max_min * 0.2, np.max(shaps) + shap_max_min * 0.2, 100) if np.std(shaps) < (global_high - global_low) / 100: if rng is None: tmp_y = np.random.randn(len(shaps)) else: tmp_y = rng.standard_normal(len(shaps)) ds = gaussian_kde(shaps + tmp_y * (global_high - global_low) / 100)(xs) else: ds = gaussian_kde(shaps)(xs) ds /= np.max(ds) * 3 values = features[:, i] # window_size = max(10, len(values) // 20) smooth_values = np.zeros(len(xs) - 1) sort_inds = np.argsort(shaps) trailing_pos = 0 leading_pos = 0 running_sum = 0 back_fill = 0 for j in range(len(xs) - 1): while leading_pos < len(shaps) and xs[j] >= shaps[sort_inds[leading_pos]]: running_sum += values[sort_inds[leading_pos]] leading_pos += 1 if leading_pos - trailing_pos > 20: running_sum -= values[sort_inds[trailing_pos]] trailing_pos += 1 if leading_pos - trailing_pos > 0: smooth_values[j] = running_sum / (leading_pos - trailing_pos) for k in range(back_fill): smooth_values[j - k - 1] = smooth_values[j] else: back_fill += 1 vmin = np.nanpercentile(values, 5) vmax = np.nanpercentile(values, 95) if vmin == vmax: vmin = np.nanpercentile(values, 1) vmax = np.nanpercentile(values, 99) if vmin == vmax: vmin = np.min(values) vmax = np.max(values) # plot the nan values in the interaction feature as grey nan_mask = np.isnan(values) plt.scatter( shaps[nan_mask], np.ones(shap_values[nan_mask].shape[0]) * pos, color="#777777", s=9, alpha=alpha, linewidth=0, zorder=1, ) # plot the non-nan values colored by the trimmed feature value cvals = values[np.invert(nan_mask)].astype(np.float64) cvals_imp = cvals.copy() cvals_imp[np.isnan(cvals)] = (vmin + vmax) / 2.0 cvals[cvals_imp > vmax] = vmax cvals[cvals_imp < vmin] = vmin plt.scatter( shaps[np.invert(nan_mask)], np.ones(shap_values[np.invert(nan_mask)].shape[0]) * pos, cmap=cmap, vmin=vmin, vmax=vmax, s=9, c=cvals, alpha=alpha, linewidth=0, zorder=1, ) # smooth_values -= nxp.nanpercentile(smooth_values, 5) # smooth_values /= np.nanpercentile(smooth_values, 95) smooth_values -= vmin if vmax - vmin > 0: smooth_values /= vmax - vmin for i in range(len(xs) - 1): if ds[i] > 0.05 or ds[i + 1] > 0.05: plt.fill_between( [xs[i], xs[i + 1]], [pos + ds[i], pos + ds[i + 1]], [pos - ds[i], pos - ds[i + 1]], color=colors.red_blue_no_bounds(smooth_values[i]), zorder=2, ) else: parts = plt.violinplot( shap_values[:, feature_order], range(len(feature_order)), points=200, **ORIENTATION_KWARG, # type: ignore[arg-type] widths=0.7, showmeans=False, showextrema=False, showmedians=False, ) for pc in parts["bodies"]: # type:ignore pc.set_facecolor(color) pc.set_edgecolor("none") pc.set_alpha(alpha) elif plot_type == "layered_violin": # courtesy of @kodonnell num_x_points = 200 bins = ( np.linspace(0, features.shape[0], layered_violin_max_num_bins + 1).round(0).astype("int") ) # the indices of the feature data corresponding to each bin shap_min, shap_max = np.min(shap_values), np.max(shap_values) x_points = np.linspace(shap_min, shap_max, num_x_points) # loop through each feature and plot: for pos, ind in enumerate(feature_order): # decide how to handle: if #unique < layered_violin_max_num_bins then split by unique value, otherwise use bins/percentiles. # to keep simpler code, in the case of uniques, we just adjust the bins to align with the unique counts. feature = features[:, ind] unique, counts = np.unique(feature, return_counts=True) if unique.shape[0] <= layered_violin_max_num_bins: order = np.argsort(unique) thesebins = np.cumsum(counts[order]) thesebins = np.insert(thesebins, 0, 0) else: thesebins = bins nbins = thesebins.shape[0] - 1 # order the feature data so we can apply percentiling order = np.argsort(feature) # x axis is located at y0 = pos, with pos being there for offset # y0 = np.ones(num_x_points) * pos # calculate kdes: ys = np.zeros((nbins, num_x_points)) for i in range(nbins): # get shap values in this bin: shaps = shap_values[order[thesebins[i] : thesebins[i + 1]], ind] # if there's only one element, then we can't if shaps.shape[0] == 1: warnings.warn( f"Not enough data in bin #{i} for feature {feature_names[ind]}, so it'll be ignored." " Try increasing the number of records to plot." ) # to ignore it, just set it to the previous y-values (so the area between them will be zero). Not ys is already 0, so there's # nothing to do if i == 0 if i > 0: ys[i, :] = ys[i - 1, :] continue # save kde of them: note that we add a tiny bit of gaussian noise to avoid singular matrix errors if rng is None: tmp_z = np.random.normal(loc=0, scale=0.001, size=shaps.shape[0]) else: tmp_z = rng.normal(loc=0, scale=0.001, size=shaps.shape[0]) ys[i, :] = gaussian_kde(shaps + tmp_z)(x_points) # scale it up so that the 'size' of each y represents the size of the bin. For continuous data this will # do nothing, but when we've gone with the unqique option, this will matter - e.g. if 99% are male and 1% # female, we want the 1% to appear a lot smaller. size = thesebins[i + 1] - thesebins[i] bin_size_if_even = features.shape[0] / nbins relative_bin_size = size / bin_size_if_even ys[i, :] *= relative_bin_size # now plot 'em. We don't plot the individual strips, as this can leave whitespace between them. # instead, we plot the full kde, then remove outer strip and plot over it, etc., to ensure no # whitespace ys = np.cumsum(ys, axis=0) width = 0.8 scale = ys.max() * 2 / width # 2 is here as we plot both sides of x axis for i in range(nbins - 1, -1, -1): y = ys[i, :] / scale c = ( plt.get_cmap(color)(i / (nbins - 1)) if color in plt.colormaps else color ) # if color is a cmap, use it, otherwise use a color plt.fill_between(x_points, pos - y, pos + y, facecolor=c, edgecolor="face") plt.xlim(shap_min, shap_max) elif not multi_class and plot_type == "bar": feature_inds = feature_order[:max_display] y_pos = np.arange(len(feature_inds)) global_shap_values = np.abs(shap_values).mean(0) plt.barh(y_pos, global_shap_values[feature_inds], 0.7, align="center", color=color) plt.yticks(y_pos, fontsize=13) plt.gca().set_yticklabels([feature_names[i] for i in feature_inds]) elif multi_class and plot_type == "bar": if class_names is None: class_names = ["Class " + str(i) for i in range(len(shap_values))] feature_inds = feature_order[:max_display] y_pos = np.arange(len(feature_inds)) left_pos = np.zeros(len(feature_inds)) if class_inds is None: class_inds = np.argsort([-np.abs(shap_values[i]).mean() for i in range(len(shap_values))]) elif class_inds == "original": class_inds = range(len(shap_values)) if show_values_in_legend: # Get the smallest decimal place of the first significant digit # to print on the legend. The legend will print ('n_decimal'+1) # decimal places. # Set to 1 if the smallest number is bigger than 1. smallest_shap = np.min(np.abs(shap_values).mean((1, 2))) if smallest_shap > 1: n_decimals = 1 else: n_decimals = int(-np.floor(np.log10(smallest_shap))) for i, ind in enumerate(class_inds): global_shap_values = np.abs(shap_values[ind]).mean(0) if show_values_in_legend: label = f"{class_names[ind]} ({np.round(np.mean(global_shap_values), (n_decimals + 1))})" else: label = class_names[ind] plt.barh( y_pos, global_shap_values[feature_inds], 0.7, left=left_pos, align="center", color=color(i), label=label ) left_pos += global_shap_values[feature_inds] plt.yticks(y_pos, fontsize=13) plt.gca().set_yticklabels([feature_names[i] for i in feature_inds]) plt.legend(frameon=False, fontsize=12) # draw the color bar if ( color_bar and features is not None and plot_type != "bar" and (plot_type != "layered_violin" or color in plt.colormaps) ): import matplotlib.cm as cm m = cm.ScalarMappable(cmap=cmap if plot_type != "layered_violin" else plt.get_cmap(color)) m.set_array([0, 1]) cb = plt.colorbar(m, ax=plt.gca(), ticks=[0, 1], aspect=80) cb.set_ticklabels([labels["FEATURE_VALUE_LOW"], labels["FEATURE_VALUE_HIGH"]]) cb.set_label(color_bar_label, size=12, labelpad=0) cb.ax.tick_params(labelsize=11, length=0) cb.set_alpha(1) cb.outline.set_visible(False) # type: ignore # bbox = cb.ax.get_window_extent().transformed(plt.gcf().dpi_scale_trans.inverted()) # cb.ax.set_aspect((bbox.height - 0.9) * 20) # cb.draw_all() plt.gca().xaxis.set_ticks_position("bottom") plt.gca().yaxis.set_ticks_position("none") plt.gca().spines["right"].set_visible(False) plt.gca().spines["top"].set_visible(False) plt.gca().spines["left"].set_visible(False) plt.gca().tick_params(color=axis_color, labelcolor=axis_color) plt.yticks(range(len(feature_order)), [feature_names[i] for i in feature_order], fontsize=13) if plot_type != "bar": plt.gca().tick_params("y", length=20, width=0.5, which="major") plt.gca().tick_params("x", labelsize=11) plt.ylim(-1, len(feature_order)) if plot_type == "bar": plt.xlabel(labels["GLOBAL_VALUE"], fontsize=13) else: plt.xlabel(labels["VALUE"], fontsize=13) plt.tight_layout() if show: plt.show()