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

"""统一 SHAP 模型解释器与结构化分析。"""

from datetime import datetime, timezone
from typing import Any, List, Optional, Sequence, Union

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import fcluster, leaves_list, linkage
from scipy.spatial.distance import squareform
from sklearn.base import clone

from hscredit.exceptions import ValidationError

from .result import ExplanationResult, coerce_explanation_frame, fingerprint_frame


def _load_shap():
    try:
        import shap
    except Exception as exc:
        raise ImportError(f"SHAP基础依赖加载失败,请检查shap/numba依赖兼容性: {exc}") from exc
    return shap


[文档] class ModelExplainer: """面向信贷模型的结构化 SHAP 解释器。 **参数** :param model: 已拟合且提供预测接口的模型。 :param background_data: SHAP 背景数据;未提供时从解释数据确定性抽样。 :param algorithm: ``auto``、``tree``、``linear``、``permutation`` 或 ``kernel``。 **属性** :attr:`last_result_`: 最近一次 :class:`ExplanationResult`。 **参考样例** >>> result = ModelExplainer(model, background_data=X_train).explain(X_test) >>> ModelExplainer(model).get_global_report(result) """ def __init__( self, model: Any, feature_names: Optional[Sequence[str]] = None, background_data: Optional[Union[np.ndarray, pd.DataFrame]] = None, algorithm: str = "auto", model_output: str = "probability", target_class: Any = 1, max_background: int = 200, random_state: int = 42, explainer_type: Optional[str] = None, ): if model_output not in {"probability", "raw", "score"}: raise ValidationError("model_output 必须是 probability、raw 或 score") if not isinstance(max_background, int) or max_background <= 0: raise ValidationError("max_background 必须是正整数") self.model = model self.feature_names = list(feature_names) if feature_names is not None else self._model_feature_names() self.background_data = background_data self.algorithm = explainer_type or algorithm self.explainer_type = self.algorithm # 旧属性兼容 self.model_output = model_output self.target_class = target_class self.max_background = max_background self.random_state = random_state self.last_result_: Optional[ExplanationResult] = None self._explainer = None self._explainer_signature = None self._interaction_explainer = None self._shap_values = None self._expected_value = None def _native_model(self): return getattr(self.model, "_model", self.model) def _model_feature_names(self) -> Optional[List[str]]: for candidate in (self.model, getattr(self.model, "_model", None)): if candidate is not None and hasattr(candidate, "feature_names_in_"): return list(candidate.feature_names_in_) return None def _is_tree_model(self) -> bool: native = self._native_model() name = native.__class__.__name__.lower() return hasattr(native, "tree_") or hasattr(native, "estimators_") or any(token in name for token in ("forest", "tree", "boost", "xgb", "lgbm", "catboost")) def _is_linear_model(self) -> bool: native = self._native_model() return hasattr(native, "coef_") and not self._is_tree_model() def _resolve_target_class(self): classes = getattr(self.model, "classes_", getattr(self._native_model(), "classes_", None)) if classes is None: if self.model_output == "probability": raise ValidationError("概率解释要求模型提供 classes_ 和 predict_proba") return None, self.target_class classes = list(classes) if len(classes) > 2 and self.target_class is None: raise ValidationError("多分类模型必须显式指定 target_class") target = self.target_class if target is None: target = 1 if 1 in classes else classes[-1] if target not in classes: raise ValidationError(f"target_class={target!r} 不在模型类别 {classes!r} 中") return classes.index(target), target def _sample(self, frame: pd.DataFrame, limit: Optional[int]) -> pd.DataFrame: if limit is None or len(frame) <= limit: return frame if not isinstance(limit, int) or limit <= 0: raise ValidationError("max_samples 必须是正整数") return frame.sample(n=limit, random_state=self.random_state).sort_index() def _resolve_background(self, frame: pd.DataFrame) -> pd.DataFrame: source = frame if self.background_data is None else coerce_explanation_frame(self.background_data, feature_names=frame.columns) return self._sample(source, self.max_background) def _predict_selected(self, frame, class_index): if self.model_output == "score": predictor = getattr(self.model, "predict_score", getattr(self.model, "predict", None)) return np.asarray(predictor(frame), dtype=float).reshape(-1) if self.model_output == "raw": if hasattr(self.model, "decision_function"): output = np.asarray(self.model.decision_function(frame)) if output.ndim == 1: return -output if class_index == 0 else output return output[:, class_index] return np.asarray(self.model.predict(frame), dtype=float).reshape(-1) probabilities = np.asarray(self.model.predict_proba(frame)) return probabilities[:, class_index] def _choose_algorithm(self): if self.algorithm != "auto": return self.algorithm if self.model_output == "score": return "permutation" if self._is_tree_model(): return "tree" if self._is_linear_model() and self.model_output == "raw": return "linear" return "permutation" def _build_explainer(self, background: pd.DataFrame, class_index: Optional[int]): shap = _load_shap() algorithm = self._choose_algorithm() signature = (algorithm, fingerprint_frame(background), self.model_output, class_index) if self._explainer is not None and self._explainer_signature == signature: return self._explainer, algorithm native = self._native_model() if algorithm == "tree": kwargs = {} if self.model_output == "probability": kwargs = { "data": background, "feature_perturbation": "interventional", "model_output": "probability", } elif self.model_output != "raw": raise ValidationError("树解释器只支持 probability 或 raw 输出尺度") backend = shap.TreeExplainer(native, **kwargs) elif algorithm == "linear": if self.model_output != "raw": raise ValidationError("LinearExplainer 仅支持 raw 输出;概率尺度请使用 permutation") backend = shap.LinearExplainer(native, background) elif algorithm == "permutation": predictor = lambda values: self._predict_selected(pd.DataFrame(values, columns=background.columns), class_index) # noqa: E731 backend = shap.Explainer(predictor, background, algorithm="permutation") elif algorithm == "kernel": predictor = lambda values: self._predict_selected(pd.DataFrame(values, columns=background.columns), class_index) # noqa: E731 backend = shap.KernelExplainer(predictor, background) else: raise ValidationError(f"不支持的解释算法: {algorithm}") self._explainer = backend self._explainer_signature = signature self.explainer_type = algorithm return backend, algorithm
[文档] def explain( self, X: Union[np.ndarray, pd.DataFrame], *, max_samples: Optional[int] = None, max_evals: Optional[int] = None, check_additivity: bool = True, ) -> ExplanationResult: """计算选定类别的结构化 SHAP 解释。""" frame = self._sample(coerce_explanation_frame(X, self.feature_names), max_samples) if self.feature_names is None: self.feature_names = list(frame.columns) class_index, class_label = self._resolve_target_class() background = self._resolve_background(frame) backend, algorithm = self._build_explainer(background, class_index) shap = _load_shap() if algorithm == "kernel": raw = backend.shap_values(frame, silent=True) explanation = shap.Explanation( values=raw, base_values=backend.expected_value, data=frame.to_numpy(), feature_names=list(frame.columns), ) output_index = None else: kwargs = {} if algorithm == "tree": kwargs["check_additivity"] = check_additivity if algorithm == "permutation": kwargs["max_evals"] = max_evals or max(2 * frame.shape[1] + 1, 50) explanation = backend(frame, **kwargs) output_index = class_index if np.asarray(explanation.values).ndim == 3 else None predictions = self._predict_selected(frame, class_index) metadata = { "模型类型": self.model.__class__.__name__, "SHAP版本": shap.__version__, "解释算法": algorithm, "计算时间": datetime.now(timezone.utc).isoformat(), "随机种子": self.random_state, "样本数": len(frame), "特征数": frame.shape[1], "特征顺序": tuple(frame.columns), "数据类型": tuple(map(str, frame.dtypes)), "目标类别": class_label, "请求输出尺度": self.model_output, "实际输出尺度": self.model_output, "风险方向": "higher_output_lower_risk" if self.model_output == "score" else "higher_output_higher_risk", "模型输出": tuple(map(float, predictions)), } result = ExplanationResult.from_explanation( explanation, data=frame, target_class=class_label, output_index=output_index, model_output=self.model_output, explainer_type=algorithm, background_summary={"样本数": len(background), "来源": "解释数据" if self.background_data is None else "显式背景数据"}, metadata=metadata, ) self.last_result_ = result self._shap_values = result.values self._expected_value = result.base_values return result
def _require_result(self, result=None) -> ExplanationResult: resolved = self.last_result_ if result is None else result if not isinstance(resolved, ExplanationResult): raise ValidationError("请先调用 explain(),或传入 ExplanationResult") return resolved
[文档] def compute_shap_values(self, X, check_additivity: bool = True) -> np.ndarray: """计算并返回选定类别的二维 SHAP 数组。""" return self.explain(X, check_additivity=check_additivity).values
[文档] def get_shap_importance(self, X=None) -> pd.Series: """返回按平均绝对 SHAP 值稳定降序排列的重要性 Series。""" result = self.explain(X) if X is not None else self._require_result() values = np.abs(result.values).mean(axis=0) return pd.Series(values, index=result.feature_names, name="SHAP重要性").sort_values(ascending=False, kind="mergesort")
def _native_importance(self, names): native = self._native_model() raw = getattr(native, "feature_importances_", None) if raw is None and hasattr(native, "coef_"): raw = np.abs(np.asarray(native.coef_)).reshape(-1) if raw is None or len(np.asarray(raw).reshape(-1)) != len(names): return pd.Series(np.nan, index=names) return pd.Series(np.asarray(raw).reshape(-1), index=names, dtype=float)
[文档] def get_global_report(self, result=None) -> pd.DataFrame: """生成含重要性、方向、分位数、原生排名和相关性的中文全局报告。""" result = self._require_result(result) values = result.values data = result.data mean_abs = np.abs(values).mean(axis=0) total = mean_abs.sum() native = self._native_importance(result.feature_names) rows = [] for i, name in enumerate(result.feature_names): x = pd.to_numeric(data.iloc[:, i], errors="coerce") s = pd.Series(values[:, i], index=data.index) rows.append( { "特征": name, "平均绝对SHAP值": mean_abs[i], "SHAP重要性占比": mean_abs[i] / total if total else 0.0, "平均SHAP值": values[:, i].mean(), "正向影响占比": (values[:, i] > 0).mean(), "负向影响占比": (values[:, i] < 0).mean(), "影响标准差": values[:, i].std(), "P25": np.quantile(values[:, i], 0.25), "P50": np.quantile(values[:, i], 0.50), "P75": np.quantile(values[:, i], 0.75), "原生特征重要性": native[name], "Pearson相关系数": x.corr(s, method="pearson"), "Spearman相关系数": x.corr(s, method="spearman"), } ) table = pd.DataFrame(rows).sort_values(["平均绝对SHAP值", "特征"], ascending=[False, True], kind="mergesort") table["SHAP排名"] = range(1, len(table) + 1) table["原生排名"] = table["原生特征重要性"].rank(method="min", ascending=False) table["排名差"] = table["原生排名"] - table["SHAP排名"] return table.reset_index(drop=True)
[文档] def get_sample_report(self, result=None, *, sample_id=None, position=None, top_n=None) -> pd.DataFrame: """按样本索引或位置生成局部贡献长表。""" result = self._require_result(result) if sample_id is not None and position is not None: raise ValidationError("sample_id 与 position 不能同时指定") if sample_id is not None: position = result.position_for(sample_id) position = 0 if position is None else int(position) if position < 0 or position >= len(result.data): raise ValidationError("样本位置超出范围") values = result.values[position] order = np.argsort(-np.abs(values), kind="stable") if top_n is not None: if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数或 None") order = order[:top_n] cumulative = 0.0 rows = [] output = float(result.metadata["模型输出"][position]) for rank, index in enumerate(order, 1): cumulative += float(values[index]) rows.append( { "样本索引": result.sample_ids[position], "目标类别": result.target_class, "模型输出": output, "基准值": result.base_values[position], "特征": result.feature_names[index], "特征值": result.data.iloc[position, index], "SHAP值": values[index], "绝对贡献": abs(values[index]), "贡献方向": "提高输出" if values[index] > 0 else "降低输出" if values[index] < 0 else "无影响", "累计贡献": cumulative, "贡献排名": rank, } ) return pd.DataFrame(rows)
[文档] def select_representative_samples(self, result=None, threshold=0.5, risk_direction=None) -> pd.DataFrame: """选择最高/最低风险、阈值附近、中位输出和贡献最大的代表样本。 :param result: 结构化解释结果;None 时使用最近一次结果。 :param threshold: 当前输出尺度下的业务决策阈值。 :param risk_direction: ``higher_output_higher_risk`` 或 ``higher_output_lower_risk``; None 时从解释元信息推导。 :return: 包含样本索引、选择理由、模型输出、风险排名和阈值距离的中文表。 """ result = self._require_result(result) outputs = np.asarray(result.metadata["模型输出"], dtype=float) if not np.isfinite(outputs).all() or outputs.size == 0: raise ValidationError("代表样本要求模型输出为非空有限数组") if not np.isscalar(threshold) or not np.isfinite(threshold): raise ValidationError("threshold 必须是有限数") direction = risk_direction or result.metadata.get("风险方向") if direction is None: direction = "higher_output_lower_risk" if result.model_output == "score" else "higher_output_higher_risk" if direction not in {"higher_output_higher_risk", "higher_output_lower_risk"}: raise ValidationError("risk_direction 必须是 higher_output_higher_risk 或 higher_output_lower_risk") highest_risk = int(np.argmax(outputs)) if direction == "higher_output_higher_risk" else int(np.argmin(outputs)) lowest_risk = int(np.argmin(outputs)) if direction == "higher_output_higher_risk" else int(np.argmax(outputs)) total_abs = np.abs(result.values).sum(axis=1) candidates = [ (highest_risk, "最高风险"), (lowest_risk, "最低风险"), (int(np.argmin(np.abs(outputs - threshold))), "最接近决策阈值"), (int(np.argmin(np.abs(outputs - np.median(outputs)))), "最接近总体中位输出"), (int(np.argmax(total_abs)), "总绝对贡献最大"), ] if result.model_output == "probability": candidates.append((int(np.argmin(np.abs(outputs - 0.5))), "最不确定样本")) elif result.model_output == "raw": candidates.append((int(np.argmin(np.abs(outputs))), "最不确定样本")) reasons = {} for position, reason in candidates: reasons.setdefault(position, []).append(reason) ranks = pd.Series(outputs).rank( method="min", ascending=direction == "higher_output_lower_risk" ).astype(int).to_numpy() return pd.DataFrame( [ { "样本索引": result.sample_ids[position], "选择理由": "、".join(labels), "模型输出": outputs[position], "风险排名": ranks[position], "阈值距离": abs(outputs[position] - threshold), } for position, labels in reasons.items() ] )
[文档] def get_correlation_report(self, result=None, kind="feature_shap") -> pd.DataFrame: """返回特征-SHAP 或 SHAP-SHAP 的相关性报告。""" result = self._require_result(result) if kind == "shap_shap": return pd.DataFrame(result.values, columns=result.feature_names).corr(method="spearman") if kind != "feature_shap": raise ValidationError("kind 必须是 feature_shap 或 shap_shap") table = self.get_global_report(result) return table[["特征", "Pearson相关系数", "Spearman相关系数"]]
[文档] def get_feature_clusters(self, result=None, max_clusters=None) -> pd.DataFrame: """按 SHAP 贡献相关性返回层次聚类叶序和聚类编号。""" result = self._require_result(result) n_features = len(result.feature_names) if max_clusters is not None and ( not isinstance(max_clusters, int) or isinstance(max_clusters, bool) or max_clusters <= 0 ): raise ValidationError("max_clusters 必须是正整数或 None") if n_features == 1: return pd.DataFrame({"特征": result.feature_names, "叶序": [1], "聚类编号": [1]}) corr = pd.DataFrame(result.values).corr(method="spearman").fillna(0).to_numpy() np.fill_diagonal(corr, 1.0) distance = np.clip(1 - np.abs(corr), 0, 1) tree = linkage(squareform(distance, checks=False), method="average", optimal_ordering=True) leaves = leaves_list(tree) count = max_clusters or min(4, n_features) labels = fcluster(tree, t=count, criterion="maxclust") leaf_order = {int(feature): position + 1 for position, feature in enumerate(leaves)} return pd.DataFrame({"特征": result.feature_names, "叶序": [leaf_order[i] for i in range(n_features)], "聚类编号": labels}).sort_values("叶序").reset_index(drop=True)
[文档] def get_feature_interactions(self, X=None, top_n=10, result=None) -> pd.DataFrame: """返回树模型精确交互或非树模型近似交互的前 N 个特征对。""" if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数") result = self._require_result(result) if result is not None else (self.explain(X) if X is not None else self._require_result()) if not self._is_tree_model(): return self.get_approximate_interactions(result, top_n=top_n) shap = _load_shap() injected = self._interaction_explainer if injected is not None and hasattr(injected, "shap_interaction_values"): backend = injected else: backend = shap.TreeExplainer(self._native_model()) # 概率尺度不支持精确交互,使用同一树模型的 raw TreeExplainer。 raw = backend.shap_interaction_values(result.data) if isinstance(raw, list): raw = raw[result.output_index or 0] array = np.asarray(raw) if array.ndim == 4: array = array[:, :, :, result.output_index or 0] strength = np.mean(np.abs(array), axis=0) rows = [] for left in range(strength.shape[0]): for right in range(left + 1, strength.shape[1]): rows.append({"特征1": result.feature_names[left], "特征2": result.feature_names[right], "交互强度": strength[left, right]}) columns = ["特征1", "特征2", "交互强度"] if not rows: return pd.DataFrame(columns=columns) return pd.DataFrame(rows, columns=columns).sort_values(["交互强度", "特征1", "特征2"], ascending=[False, True, True]).head(top_n).reset_index(drop=True)
[文档] def get_approximate_interactions(self, result=None, top_n=10) -> pd.DataFrame: """根据 SHAP 贡献 Spearman 相关性返回近似交互特征对。""" if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n <= 0: raise ValidationError("top_n 必须是正整数") result = self._require_result(result) corr = pd.DataFrame(result.values, columns=result.feature_names).corr(method="spearman").abs().fillna(0) rows = [] for left in range(len(result.feature_names)): for right in range(left + 1, len(result.feature_names)): rows.append({"特征1": result.feature_names[left], "特征2": result.feature_names[right], "交互强度": corr.iloc[left, right], "交互类型": "近似"}) columns = ["特征1", "特征2", "交互强度", "交互类型"] if not rows: return pd.DataFrame(columns=columns) return pd.DataFrame(rows, columns=columns).sort_values(["交互强度", "特征1", "特征2"], ascending=[False, True, True]).head(top_n).reset_index(drop=True)
[文档] def get_stability_report( self, result=None, *, mode="sample", X_train=None, y_train=None, X_validation=None, n_bootstrap=100, confidence_level=0.95, top_k=10, random_state=None, ) -> pd.DataFrame: """评估固定样本 Bootstrap 或模型重训后的解释稳定性。""" if not isinstance(n_bootstrap, int) or isinstance(n_bootstrap, bool) or n_bootstrap < 2: raise ValidationError("n_bootstrap 必须是不小于 2 的整数") if not np.isscalar(confidence_level) or not np.isfinite(confidence_level) or not 0 < confidence_level < 1: raise ValidationError("confidence_level 必须是(0, 1)范围内的有限数") if not isinstance(top_k, int) or isinstance(top_k, bool) or top_k <= 0: raise ValidationError("top_k 必须是正整数") rng = np.random.default_rng(self.random_state if random_state is None else random_state) if mode == "sample": resolved = self._require_result(result) names = resolved.feature_names runs = [np.abs(resolved.values[rng.integers(0, len(resolved.data), len(resolved.data))]).mean(axis=0) for _ in range(n_bootstrap)] label = "样本Bootstrap" elif mode == "refit": if X_train is None or y_train is None or X_validation is None: raise ValidationError("refit 模式必须提供训练数据、标签和固定验证数据") train = coerce_explanation_frame(X_train, self.feature_names) validation = coerce_explanation_frame(X_validation, train.columns) target = np.asarray(y_train) names = list(train.columns) runs = [] for bootstrap_index in range(n_bootstrap): indices = rng.integers(0, len(train), len(train)) try: fitted = clone(self.model).fit(train.iloc[indices], target[indices]) child = ModelExplainer( fitted, background_data=train.iloc[indices[: self.max_background]], algorithm=self.algorithm, model_output=self.model_output, target_class=self.target_class, random_state=self.random_state, ) runs.append(np.abs(child.explain(validation).values).mean(axis=0)) except Exception as exc: raise ValidationError(f"第 {bootstrap_index + 1} 次重训解释失败: {exc}") from exc label = "重训Bootstrap" else: raise ValidationError("mode 必须是 sample 或 refit") matrix = np.asarray(runs) ranks = np.argsort(np.argsort(-matrix, axis=1), axis=1) + 1 alpha = (1 - confidence_level) / 2 rows = [] for index, name in enumerate(names): rows.append( { "特征": name, "稳定性模式": label, "平均绝对SHAP值": matrix[:, index].mean(), "置信区间下限": np.quantile(matrix[:, index], alpha), "置信区间上限": np.quantile(matrix[:, index], 1 - alpha), "排名均值": ranks[:, index].mean(), "排名标准差": ranks[:, index].std(), "Top-K入选率": (ranks[:, index] <= min(top_k, len(names))).mean(), } ) return pd.DataFrame(rows).sort_values(["排名均值", "特征"]).reset_index(drop=True)
[文档] def get_reason_codes( self, result=None, *, keep=3, risk_direction="higher_output_higher_risk", feature_map=None, reason_map=None, ) -> pd.DataFrame: """返回只包含不利局部贡献的中文业务原因码。""" from .reason_codes import build_reason_codes return build_reason_codes( self._require_result(result), keep=keep, risk_direction=risk_direction, feature_map=feature_map, reason_map=reason_map, )
# 新图形入口延迟导入,避免核心计算依赖绘图实现。 def _plot(self, name, result=None, **kwargs): from . import plots as explanation_plots return getattr(explanation_plots, name)(self._require_result(result), explainer=self, **kwargs)
[文档] def plot_decision(self, result=None, **kwargs): """绘制单样本 SHAP 决策贡献条形图并返回 Figure。""" return self._plot("plot_decision", result, **kwargs)
[文档] def plot_heatmap(self, result=None, **kwargs): """绘制多样本 SHAP 贡献热力图并返回 Figure。""" return self._plot("plot_heatmap", result, **kwargs)
[文档] def plot_distribution(self, result=None, **kwargs): """绘制指定特征值与 SHAP 贡献分布并返回 Figure。""" return self._plot("plot_distribution", result, **kwargs)
[文档] def plot_correlation(self, result=None, **kwargs): """绘制 SHAP 贡献相关性热力图并返回 Figure。""" return self._plot("plot_correlation", result, **kwargs)
[文档] def plot_feature_clustering(self, result=None, **kwargs): """绘制基于 SHAP 贡献距离的特征层次聚类图。""" return self._plot("plot_feature_clustering", result, **kwargs)
[文档] def plot_interaction_heatmap(self, result=None, **kwargs): """绘制树精确或近似 SHAP 交互强度热力图。""" return self._plot("plot_interaction_heatmap", result, **kwargs)
[文档] def plot_interaction_bubble(self, result=None, **kwargs): """绘制主要 SHAP 交互特征对气泡图。""" return self._plot("plot_interaction_bubble", result, **kwargs)
[文档] def plot_importance_overview(self, result=None, **kwargs): """绘制 SHAP 贡献分布与全局重要性组合图。""" return self._plot("plot_importance_overview", result, **kwargs)
[文档] def plot_explanation_overview(self, result=None, **kwargs): """绘制重要性、方向、相关性与代表样本综合总览。""" return self._plot("plot_explanation_overview", result, **kwargs)
[文档] def plot_shap_summary(self, X=None, plot_type="dot", max_display=20, show=True, **kwargs): """绘制 SHAP summary 图;支持 ``dot``、``violin`` 和 ``bar``。""" result = X if isinstance(X, ExplanationResult) else (self.explain(X) if X is not None else self._require_result()) if plot_type == "bar": return self.plot_shap_bar(result, max_display=max_display, show=show, **kwargs) if plot_type not in {"dot", "violin"}: raise ValidationError("plot_type 必须是 dot、violin 或 bar") shap = _load_shap() import matplotlib.pyplot as plt shap.summary_plot( result.values, result.data, feature_names=result.feature_names, plot_type=plot_type, max_display=max_display, show=False, ) figure = plt.gcf() if "figsize" in kwargs: figure.set_size_inches(*kwargs["figsize"]) if kwargs.get("title"): figure.axes[0].set_title(kwargs["title"]) if show: plt.show() return figure
[文档] def plot_shap_bar(self, X=None, max_display=20, show=True, **kwargs): """绘制单面板平均绝对 SHAP 重要性条形图。""" result = X if isinstance(X, ExplanationResult) else (self.explain(X) if X is not None else self._require_result()) from .plots import plot_shap_result_importance return plot_shap_result_importance(result, max_display=max_display, show=show, **kwargs)
[文档] def plot_shap_dependence(self, feature, X=None, show=True, **kwargs): """绘制指定特征值与其 SHAP 贡献的依赖散点图。""" result = self.explain(X) if X is not None else self._require_result() return self.plot_distribution(result, feature=feature, show=show, **kwargs)
[文档] def plot_combined_importance(self, X=None, top_n=15, show=True, **kwargs): """并排绘制模型原生重要性与 SHAP 重要性。""" result = self.explain(X) if X is not None else self._require_result() from .plots import plot_importance_comparison return plot_importance_comparison(self.model, result.data, top_n=top_n, show=show, **kwargs)
[文档] def plot_shap_waterfall(self, X, sample_idx=0, max_display=15, show=True, **kwargs): """绘制真实 SHAP waterfall 图并返回 Figure。""" result = self.explain(X) if not isinstance(X, ExplanationResult) else X if not isinstance(sample_idx, int) or isinstance(sample_idx, bool) or not 0 <= sample_idx < len(result.data): raise ValidationError("sample_idx 超出解释样本范围") shap = _load_shap() import matplotlib.pyplot as plt axis = shap.plots.waterfall(result.explanation[sample_idx], max_display=max_display, show=False) figure = axis.figure if "figsize" in kwargs: figure.set_size_inches(*kwargs["figsize"]) axis.set_title(kwargs.get("title", "单样本SHAP瀑布图")) figure.tight_layout() if show: plt.show() return figure
[文档] def plot_shap_force(self, X, sample_idx=0, show=True, **kwargs): """绘制 Matplotlib SHAP force 图并返回 Figure。""" result = self.explain(X) if not isinstance(X, ExplanationResult) else X if not isinstance(sample_idx, int) or isinstance(sample_idx, bool) or not 0 <= sample_idx < len(result.data): raise ValidationError("sample_idx 超出解释样本范围") shap = _load_shap() import matplotlib.pyplot as plt shap.force_plot( result.base_values[sample_idx], result.values[sample_idx], result.data.iloc[sample_idx], feature_names=result.feature_names, matplotlib=True, show=False, ) figure = plt.gcf() if "figsize" in kwargs: figure.set_size_inches(*kwargs["figsize"]) figure.axes[0].set_title(kwargs.get("title", "单样本SHAP力图")) if show: plt.show() return figure