hscredit.core.models.explainability.plots 源代码

"""模型解释的中文 Matplotlib 可视化。"""

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import dendrogram, linkage
from scipy.spatial.distance import squareform

from hscredit.exceptions import ValidationError


def _validate_positive_int(name, value):
    """校验绘图展示数量参数。"""
    if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
        raise ValidationError(f"{name} 必须是正整数")


def _finish(fig, show):
    fig.tight_layout()
    if show:
        plt.show()
    return fig


def _feature_index(result, feature):
    if isinstance(feature, int):
        if 0 <= feature < len(result.feature_names):
            return feature
    elif feature in result.feature_names:
        return result.feature_names.index(feature)
    raise ValidationError(f"解释特征不存在: {feature}")


def plot_decision(result, *, explainer=None, sample_id=None, position=0, max_display=15, figsize=(9, 6), show=True):
    """绘制单样本贡献决策路径。"""
    _validate_positive_int("max_display", max_display)
    if sample_id is not None:
        position = result.position_for(sample_id)
    if not isinstance(position, int) or isinstance(position, bool) or not 0 <= position < len(result.data):
        raise ValidationError("样本位置超出范围")
    values = result.values[position]
    order = np.argsort(np.abs(values))[::-1][:max_display][::-1]
    fig, ax = plt.subplots(figsize=figsize)
    colors = np.where(values[order] >= 0, "#D95F59", "#4C78A8")
    ax.barh(np.asarray(result.feature_names)[order], values[order], color=colors)
    ax.axvline(0, color="#666666", linewidth=0.8)
    ax.set_title(f"单样本SHAP决策贡献(样本 {result.sample_ids[position]})")
    ax.set_xlabel(f"SHAP值({result.model_output}尺度)")
    ax.set_ylabel("特征")
    return _finish(fig, show)


def plot_heatmap(result, *, explainer=None, max_display=20, figsize=(11, 6), show=True):
    """绘制多样本 SHAP 热力图。"""
    _validate_positive_int("max_display", max_display)
    order = np.argsort(np.abs(result.values).mean(axis=0))[::-1][:max_display]
    fig, ax = plt.subplots(figsize=figsize)
    limit = np.max(np.abs(result.values[:, order])) or 1.0
    image = ax.imshow(result.values[:, order], aspect="auto", cmap="RdBu_r", vmin=-limit, vmax=limit)
    ax.set_xticks(range(len(order)), np.asarray(result.feature_names)[order], rotation=45, ha="right")
    ax.set_ylabel("样本")
    ax.set_title("多样本SHAP贡献热力图")
    fig.colorbar(image, ax=ax, label="SHAP值")
    return _finish(fig, show)


def plot_distribution(result, *, feature, explainer=None, figsize=(8, 5), show=True):
    """绘制特征值与 SHAP 贡献分布。"""
    index = _feature_index(result, feature)
    x = result.data.iloc[:, index]
    y = result.values[:, index]
    fig, ax = plt.subplots(figsize=figsize)
    numeric = pd.to_numeric(x, errors="coerce")
    if numeric.notna().all():
        ax.scatter(numeric, y, c=y, cmap="RdBu_r", alpha=0.75, edgecolor="none")
    else:
        categories = pd.Categorical(x.astype(str))
        ax.scatter(categories.codes, y, c=y, cmap="RdBu_r", alpha=0.75, edgecolor="none")
        ax.set_xticks(range(len(categories.categories)), categories.categories, rotation=45, ha="right")
    ax.axhline(0, color="#666666", linewidth=0.8)
    ax.set_title(f"{result.feature_names[index]} 的SHAP贡献分布")
    ax.set_xlabel("特征值")
    ax.set_ylabel("SHAP值")
    return _finish(fig, show)


def plot_correlation(result, *, explainer=None, figsize=(8, 7), show=True):
    """绘制 SHAP 贡献相关性热力图。"""
    corr = pd.DataFrame(result.values, columns=result.feature_names).corr(method="spearman").fillna(0)
    fig, ax = plt.subplots(figsize=figsize)
    image = ax.imshow(corr, cmap="RdBu_r", vmin=-1, vmax=1)
    ax.set_xticks(range(len(corr)), corr.columns, rotation=45, ha="right")
    ax.set_yticks(range(len(corr)), corr.index)
    ax.set_title("SHAP贡献相关性")
    fig.colorbar(image, ax=ax, label="Spearman相关系数")
    return _finish(fig, show)


def plot_feature_clustering(result, *, explainer=None, figsize=(9, 5), show=True):
    """绘制基于 SHAP 贡献相似性的层次聚类。"""
    fig, ax = plt.subplots(figsize=figsize)
    if len(result.feature_names) == 1:
        ax.text(0.5, 0.5, result.feature_names[0], ha="center", va="center")
        ax.set_axis_off()
    else:
        corr = pd.DataFrame(result.values).corr(method="spearman").fillna(0).to_numpy()
        np.fill_diagonal(corr, 1)
        tree = linkage(squareform(np.clip(1 - np.abs(corr), 0, 1), checks=False), method="average")
        dendrogram(tree, labels=result.feature_names, leaf_rotation=45, ax=ax)
    ax.set_title("SHAP特征层次聚类")
    ax.set_ylabel("贡献距离")
    return _finish(fig, show)


def _interaction_matrix(result, explainer):
    table = explainer.get_feature_interactions(result=result, top_n=max(1, len(result.feature_names) ** 2))
    matrix = pd.DataFrame(0.0, index=result.feature_names, columns=result.feature_names)
    for row in table.itertuples(index=False):
        matrix.loc[row.特征1, row.特征2] = row.交互强度
        matrix.loc[row.特征2, row.特征1] = row.交互强度
    return table, matrix


def plot_interaction_heatmap(result, *, explainer, figsize=(8, 7), show=True):
    """绘制全部特征对的 SHAP 交互强度热力图。"""
    table, matrix = _interaction_matrix(result, explainer)
    fig, ax = plt.subplots(figsize=figsize)
    image = ax.imshow(matrix, cmap="Blues")
    ax.set_xticks(range(len(matrix)), matrix.columns, rotation=45, ha="right")
    ax.set_yticks(range(len(matrix)), matrix.index)
    ax.set_title("SHAP特征交互强度")
    fig.colorbar(image, ax=ax, label="平均绝对交互值")
    return _finish(fig, show)


def plot_interaction_bubble(result, *, explainer, top_n=20, figsize=(9, 6), show=True):
    """绘制前 N 个 SHAP 交互特征对的气泡图。"""
    _validate_positive_int("top_n", top_n)
    table = explainer.get_feature_interactions(result=result, top_n=top_n)
    fig, ax = plt.subplots(figsize=figsize)
    if not table.empty:
        sizes = 100 + 900 * table["交互强度"] / max(table["交互强度"].max(), np.finfo(float).eps)
        ax.scatter(table["特征1"], table["特征2"], s=sizes, c=table["交互强度"], cmap="Blues", alpha=0.75)
        ax.tick_params(axis="x", rotation=45)
    ax.set_title("主要SHAP交互气泡图")
    ax.set_xlabel("特征1")
    ax.set_ylabel("特征2")
    return _finish(fig, show)


def plot_importance_overview(result, *, explainer=None, max_display=20, figsize=(12, 7), show=True):
    """组合展示贡献分布和全局重要性。"""
    _validate_positive_int("max_display", max_display)
    order = np.argsort(np.abs(result.values).mean(axis=0))[::-1][:max_display]
    names = np.asarray(result.feature_names)[order]
    fig, (distribution_ax, importance_ax) = plt.subplots(1, 2, figsize=figsize)
    distribution_ax.violinplot([result.values[:, index] for index in order], vert=False, showmedians=True)
    distribution_ax.set_yticks(range(1, len(names) + 1), names)
    distribution_ax.axvline(0, color="#666666", linewidth=0.8)
    distribution_ax.set_title("SHAP贡献分布")
    distribution_ax.set_xlabel("SHAP值")
    importance = np.abs(result.values[:, order]).mean(axis=0)
    importance_ax.barh(names[::-1], importance[::-1], color="#4C78A8")
    importance_ax.set_title("SHAP特征重要性")
    importance_ax.set_xlabel("平均绝对SHAP值")
    return _finish(fig, show)


def plot_explanation_overview(result, *, explainer, max_display=10, figsize=(14, 10), show=True):
    """展示重要性、方向、相关性和代表样本的综合总览。"""
    _validate_positive_int("max_display", max_display)
    report = explainer.get_global_report(result).head(max_display)
    representative = explainer.select_representative_samples(result)
    fig, axes = plt.subplots(2, 2, figsize=figsize)
    axes[0, 0].barh(report["特征"][::-1], report["平均绝对SHAP值"][::-1], color="#4C78A8")
    axes[0, 0].set_title("全局SHAP重要性")
    axes[0, 1].barh(report["特征"][::-1], report["正向影响占比"][::-1], color="#D95F59")
    axes[0, 1].set_title("提高模型输出的贡献占比")
    corr = pd.DataFrame(result.values, columns=result.feature_names).corr(method="spearman").fillna(0)
    axes[1, 0].imshow(corr, cmap="RdBu_r", vmin=-1, vmax=1)
    axes[1, 0].set_xticks(range(len(corr)), corr.columns, rotation=45, ha="right")
    axes[1, 0].set_yticks(range(len(corr)), corr.index)
    axes[1, 0].set_title("SHAP贡献相关性")
    axes[1, 1].scatter(representative["风险排名"], representative["模型输出"], color="#F28E2B")
    for row in representative.itertuples(index=False):
        axes[1, 1].annotate(str(row.样本索引), (row.风险排名, row.模型输出), fontsize=8)
    axes[1, 1].set_title("代表样本")
    axes[1, 1].set_xlabel("风险排名")
    axes[1, 1].set_ylabel("模型输出")
    fig.suptitle("模型解释综合总览")
    return _finish(fig, show)


[文档] def plot_feature_importance( model, X=None, top_n=20, importance_type="gain", figsize=(10, 8), title=None, color="#2E86AB", show_values=True, show=True, ): """绘制模型原生特征重要性水平条形图。 :param model: 已拟合且提供原生重要性或系数的模型。 :param X: 用于推断特征名的 DataFrame 或数组,可选。 :param top_n: 展示前 N 个特征,必须为正整数。 :param importance_type: 传给模型重要性接口的类型。 :param figsize: Matplotlib 画布大小。 :param title: 自定义标题;None 时使用中文默认标题。 :param color: 条形颜色,显式值优先。 :param show_values: 是否标注重要性数值。 :param show: 是否调用 ``plt.show()``。 :return: Matplotlib Figure。 """ from .reports import model_explain_report if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数") table = model_explain_report(model, X=X, importance_type=importance_type, top_n=top_n, normalize=False) fig, ax = plt.subplots(figsize=figsize) display = table.iloc[::-1] bars = ax.barh(display["特征名"], display["重要性"], color=color) ax.set_xlabel("重要性") ax.set_ylabel("特征") ax.set_title(title or f"原生特征重要性({importance_type})") ax.grid(axis="x", alpha=0.3, linestyle="--") ax.set_axisbelow(True) if show_values: for bar, value in zip(bars, display["重要性"]): ax.text(value, bar.get_y() + bar.get_height() / 2, f" {value:.4g}", va="center", fontsize=9) return _finish(fig, show)
[文档] def plot_shap_importance(model, X, top_n=20, figsize=(10, 8), title=None, color="#4C78A8", show=True): """计算并绘制平均绝对 SHAP 特征重要性。 :return: 单坐标轴 Matplotlib Figure,``show=False`` 时不显示窗口。 """ from .explainer import ModelExplainer if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数") importance = ModelExplainer(model).get_shap_importance(X).head(top_n).iloc[::-1] fig, ax = plt.subplots(figsize=figsize) ax.barh(importance.index, importance.values, color=color) ax.set_xlabel("平均绝对SHAP值") ax.set_ylabel("特征") ax.set_title(title or "SHAP特征重要性") ax.grid(axis="x", alpha=0.3, linestyle="--") ax.set_axisbelow(True) return _finish(fig, show)
def plot_shap_result_importance( result, max_display=20, figsize=(10, 8), title=None, color="#4C78A8", show=True, ): """使用既有 ExplanationResult 绘制单面板 SHAP 重要性图。""" if not isinstance(max_display, int) or isinstance(max_display, bool) or max_display <= 0: raise ValidationError("max_display 必须是正整数") importance = pd.Series( np.abs(result.values).mean(axis=0), index=result.feature_names, name="SHAP重要性" ).sort_values(ascending=False, kind="mergesort").head(max_display).iloc[::-1] fig, ax = plt.subplots(figsize=figsize) ax.barh(importance.index, importance.values, color=color) ax.set_xlabel("平均绝对SHAP值") ax.set_ylabel("特征") ax.set_title(title or "SHAP特征重要性") ax.grid(axis="x", alpha=0.3, linestyle="--") ax.set_axisbelow(True) return _finish(fig, show)
[文档] def plot_importance_comparison( model, X, top_n=15, figsize=(16, 10), importance_type="gain", title=None, colors=("#2E86AB", "#4C78A8"), show=True, ): """并排比较模型原生重要性与平均绝对 SHAP 重要性。 :return: 含“原生特征重要性”和“SHAP特征重要性”两个面板的 Figure。 """ from .explainer import ModelExplainer from .reports import model_explain_report if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数") if len(colors) < 2: raise ValidationError("colors 至少需要两种颜色") native = model_explain_report(model, X=X, importance_type=importance_type, top_n=top_n, normalize=False) shap_importance = ModelExplainer(model).get_shap_importance(X).head(top_n) fig, axes = plt.subplots(1, 2, figsize=figsize) native_display = native.iloc[::-1] shap_display = shap_importance.iloc[::-1] axes[0].barh(native_display["特征名"], native_display["重要性"], color=colors[0]) axes[0].set_title("原生特征重要性") axes[0].set_xlabel("重要性") axes[1].barh(shap_display.index, shap_display.values, color=colors[1]) axes[1].set_title("SHAP特征重要性") axes[1].set_xlabel("平均绝对SHAP值") for axis in axes: axis.grid(axis="x", alpha=0.3, linestyle="--") axis.set_axisbelow(True) if title: fig.suptitle(title) return _finish(fig, show)