hscredit.core.viz.risk_plots 源代码

# -*- coding: utf-8 -*-
"""
金融风控数据可视化函数.

提供金融建模和风控策略分析专用的可视化功能,包括:
- ROC曲线图 (roc_plot)
- Lift提升图 (lift_plot)
- Gain增益图 (gain_plot)
- 评分分布对比图 (score_dist_plot)
- 坏样本率趋势图 (bad_rate_trend_plot)
- 特征重要性图 (feature_importance_plot)
- 混淆矩阵图 (confusion_matrix_plot)
- PR曲线图 (pr_plot)
- 校准曲线图 (calibration_plot)
- Vintage账龄曲线图 (vintage_plot)
- 决策阈值分析图 (threshold_analysis_plot)
- 策略效果对比图 (strategy_compare_plot)

采用函数式API设计,与hscredit.core.viz模块风格保持一致。
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import Union, Optional, List, Dict, Tuple, Any
from sklearn.metrics import (
    roc_curve, auc, precision_recall_curve,
    confusion_matrix, brier_score_loss,
    accuracy_score, precision_score, recall_score, f1_score,
)
from sklearn.calibration import calibration_curve
from matplotlib.colors import to_hex
from matplotlib.ticker import PercentFormatter

from .utils import (
    DEFAULT_COLORS, setup_axis_style, save_figure,
    get_or_create_ax, BAD_RATE_COLOR, NEUTRAL_COLOR,
    get_series_colors, make_colormap, make_risk_cmap,
    _layout_top_center_legend,
)
from ..._lazy import LazyModule

# 延迟加载 seaborn:仅在首次实际绘图(访问 sns 属性)时才导入,
# 避免 import hscredit 时即触发 seaborn(及其 ipywidgets/IPython 依赖)的加载。
sns = LazyModule("seaborn")


# ==================== 模型评估图表 ====================

[文档] def roc_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (8, 8), title: str = "ROC Curve", colors: Optional[List[str]] = None, show_auc: bool = True, show_diagonal: bool = True, label: Optional[str] = None, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制ROC曲线. :param y_true: 真实标签 :param y_score: 预测概率分数 :param ax: matplotlib Axes对象,None时自动创建 :param figsize: 图像尺寸,默认(8, 8) :param title: 图表标题 :param colors: 配色方案 :param show_auc: 是否显示AUC值 :param show_diagonal: 是否显示对角线(随机猜测线) :param label: 曲线标签(多模型对比时使用) :param save: 保存路径 :param kwargs: 其他参数传递给plt.plot :return: matplotlib Figure对象 **参考样例** >>> fig = roc_plot(y_test, model.predict_proba(X_test)[:, 1]) >>> >>> # 多模型对比 >>> fig, ax = plt.subplots(figsize=(8, 8)) >>> roc_plot(y_test, model1.predict_proba(X_test)[:, 1], ax=ax, label='Model A') >>> roc_plot(y_test, model2.predict_proba(X_test)[:, 1], ax=ax, label='Model B') """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 计算ROC曲线 fpr, tpr, _ = roc_curve(y_true, y_score) roc_auc = auc(fpr, tpr) # 绘制对角线 if show_diagonal: ax.plot([0, 1], [0, 1], color=NEUTRAL_COLOR, linestyle='--', lw=1, alpha=0.5, label='Random (AUC = 0.50)') # 绘制ROC曲线 label_str = label if label else 'Model' if show_auc: label_str += f' (AUC = {roc_auc:.3f})' ax.plot(fpr, tpr, color=colors[0], lw=2, label=label_str, **kwargs) # 设置图表属性 ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate (1 - Specificity)', fontsize=12) ax.set_ylabel('True Positive Rate (Sensitivity)', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.legend(loc='lower right', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3) if save: save_figure(fig, save) return fig
[文档] def pr_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (8, 8), title: str = "Precision-Recall Curve", colors: Optional[List[str]] = None, show_ap: bool = True, show_baseline: bool = True, label: Optional[str] = None, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制Precision-Recall曲线. :param y_true: 真实标签 :param y_score: 预测概率分数 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸,默认(8, 8) :param title: 图表标题 :param colors: 配色方案 :param show_ap: 是否显示Average Precision :param show_baseline: 是否显示基线(随机猜测) :param label: 曲线标签 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = pr_plot(y_test, model.predict_proba(X_test)[:, 1]) """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 计算PR曲线 precision, recall, _ = precision_recall_curve(y_true, y_score) # 计算基线(正样本比例) if show_baseline: baseline = np.mean(y_true) ax.axhline(y=baseline, color=NEUTRAL_COLOR, linestyle='--', alpha=0.5, label=f'Baseline ({baseline:.2%})') # 绘制PR曲线 label_str = label if label else 'Model' if show_ap: from sklearn.metrics import average_precision_score ap_score = average_precision_score(y_true, y_score) label_str += f' (AP = {ap_score:.3f})' ax.plot(recall, precision, color=colors[0], lw=2, label=label_str, **kwargs) # 设置图表属性 ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('Recall', fontsize=12) ax.set_ylabel('Precision', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.legend(loc='lower left', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3) if save: save_figure(fig, save) return fig
[文档] def lift_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], n_bins: int = 10, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (10, 6), title: str = "Lift 提升图", colors: Optional[List[str]] = None, show_baseline: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制Lift提升图. Lift = (该分箱坏样本率) / (整体坏样本率) :param y_true: 真实标签 :param y_score: 预测概率分数 :param n_bins: 分箱数,默认10 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_baseline: 是否显示基线(Lift=1) :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = lift_plot(y_test, model.predict_proba(X_test)[:, 1], n_bins=10) """ y_true = np.asarray(y_true) y_score = np.asarray(y_score, dtype=float) if y_true.ndim != 1 or y_score.ndim != 1: raise ValueError("y_true 和 y_score 必须是一维数组") if len(y_true) != len(y_score): raise ValueError(f"y_true 与 y_score 长度不一致: {len(y_true)} != {len(y_score)}") valid_mask = ~pd.isna(y_true) & ~pd.isna(y_score) y_true = y_true[valid_mask] y_score = y_score[valid_mask] if len(y_true) == 0: raise ValueError("y_true 和 y_score 没有可用的非缺失数据") unique_labels = np.unique(y_true) if len(unique_labels) != 2 or not set(unique_labels).issubset({0, 1, False, True}): raise ValueError("y_true 必须是包含 0/1 的二分类标签") if isinstance(n_bins, bool) or not isinstance(n_bins, (int, np.integer)) or n_bins <= 0: raise ValueError("分箱数 n_bins 必须是正整数") if n_bins > len(y_true): raise ValueError(f"分箱数 ({n_bins}) 不能大于有效样本数 ({len(y_true)})") fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 计算Lift overall_bad_rate = np.mean(y_true) # 按分数排序分箱 sorted_indices = np.argsort(-y_score) # 降序 y_true_sorted = np.array(y_true)[sorted_indices] # 计算每个分箱的Lift lifts = [] depths = [] end = 0 for bin_values in np.array_split(y_true_sorted, n_bins): end += len(bin_values) bin_bad_rate = np.mean(bin_values) lift = bin_bad_rate / overall_bad_rate if overall_bad_rate > 0 else 1.0 lifts.append(lift) depths.append((end / len(y_true)) * 100) # 绘制基线 if show_baseline: ax.axhline(y=1, color=NEUTRAL_COLOR, linestyle='--', alpha=0.5, label='基准线(Lift=1)') # 绘制Lift曲线 ax.plot(depths, lifts, color=colors[0], marker='o', lw=2, markersize=6, **kwargs) # 绘制柱状图 ax.bar(depths, lifts, width=8, alpha=0.3, color=colors[0], edgecolor=colors[0]) # 设置图表属性 ax.set_xlabel('样本深度(累计占比)', fontsize=12) ax.set_ylabel('Lift 值', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.set_xlim([0, 105]) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3, axis='y') if save: save_figure(fig, save) return fig
[文档] def gain_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], n_bins: int = 10, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (10, 6), title: str = "Cumulative Gain Chart", colors: Optional[List[str]] = None, show_baseline: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制累积Gain增益图. Gain表示捕获的坏样本比例。 :param y_true: 真实标签 :param y_score: 预测概率分数 :param n_bins: 分箱数,默认10 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_baseline: 是否显示基线(随机模型) :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = gain_plot(y_test, model.predict_proba(X_test)[:, 1], n_bins=10) """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 按分数排序 sorted_indices = np.argsort(-y_score) y_true_sorted = np.array(y_true)[sorted_indices] total_bads = np.sum(y_true) # 计算累积Gain bin_size = len(y_true) // n_bins cumulative_gains = [0] depths = [0] for i in range(n_bins): end = (i + 1) * bin_size if i < n_bins - 1 else len(y_true) captured_bads = np.sum(y_true_sorted[:end]) gain = captured_bads / total_bads if total_bads > 0 else 0 cumulative_gains.append(gain * 100) depths.append((end / len(y_true)) * 100) # 绘制基线(随机模型) if show_baseline: ax.plot([0, 100], [0, 100], color=NEUTRAL_COLOR, linestyle='--', alpha=0.5, label='Baseline (Random)') # 绘制Gain曲线 ax.plot(depths, cumulative_gains, color=colors[0], marker='o', lw=2, markersize=6, label='Model', **kwargs) ax.fill_between(depths, cumulative_gains, alpha=0.2, color=colors[0]) # 设置图表属性 ax.set_xlabel('% of Population (Cumulative)', fontsize=12) ax.set_ylabel('% of Bad Samples Captured', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.set_xlim([0, 100]) ax.set_ylim([0, 105]) ax.legend(loc='lower right', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3) if save: save_figure(fig, save) return fig
[文档] def confusion_matrix_plot( y_true: Union[pd.Series, np.ndarray], y_pred: Union[pd.Series, np.ndarray], ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (8, 6), title: str = "混淆矩阵", cmap: Optional[Any] = None, normalize: Optional[str] = None, show_values: bool = True, show_metrics: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制混淆矩阵热力图. :param y_true: 真实标签 :param y_pred: 预测标签 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param cmap: 颜色映射 :param normalize: 归一化方式,None/'true'/'pred'/'all' :param show_values: 是否显示数值 :param show_metrics: 是否显示评估指标 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = confusion_matrix_plot(y_test, y_pred) >>> fig = confusion_matrix_plot(y_test, y_pred, normalize='true') """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if cmap is None: cmap = make_colormap("hscredit_confusion", ["#F7F8FF", DEFAULT_COLORS[0]]) # 计算混淆矩阵;标签来自真实值与预测值并集,兼容二分类和多分类。 labels = np.unique(np.concatenate([np.asarray(y_true), np.asarray(y_pred)])) cm_counts = confusion_matrix(y_true, y_pred, labels=labels) cm = cm_counts.copy() # 归一化 if normalize == 'true': cm = cm.astype('float') / cm.sum(axis=1, keepdims=True) elif normalize == 'pred': cm = cm.astype('float') / cm.sum(axis=0, keepdims=True) elif normalize == 'all': cm = cm.astype('float') / cm.sum() elif normalize is not None: raise ValueError("normalize 仅支持 None/'true'/'pred'/'all'") # 绘制热力图 sns.heatmap(cm, annot=show_values, fmt='.2f' if normalize else 'd', cmap=cmap, square=True, ax=ax, xticklabels=[str(label) for label in labels], yticklabels=[str(label) for label in labels], **kwargs) ax.set_xlabel('预测标签', fontsize=12) ax.set_ylabel('真实标签', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') # 显示评估指标 if show_metrics: accuracy = accuracy_score(y_true, y_pred) if len(labels) == 2: tn, fp, fn, tp = cm_counts.ravel() precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 else: precision = precision_score(y_true, y_pred, average='macro', zero_division=0) recall = recall_score(y_true, y_pred, average='macro', zero_division=0) f1 = f1_score(y_true, y_pred, average='macro', zero_division=0) metrics_text = ( f'准确率: {accuracy:.3f} | 精确率: {precision:.3f} | ' f'召回率: {recall:.3f} | F1: {f1:.3f}' ) ax.set_title(f'{title}\n{metrics_text}', fontsize=12, fontweight='bold') if save: save_figure(fig, save) return fig
[文档] def calibration_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], n_bins: int = 10, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (8, 8), title: str = "校准曲线", colors: Optional[List[str]] = None, show_histogram: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制校准曲线(可靠性图). 评估模型预测概率的可靠性。 :param y_true: 真实标签 :param y_score: 预测概率分数 :param n_bins: 分箱数,默认10 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_histogram: 是否显示样本分布直方图 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = calibration_plot(y_test, model.predict_proba(X_test)[:, 1]) """ if colors is None: colors = DEFAULT_COLORS y_true = np.asarray(y_true) y_score = np.asarray(y_score, dtype=float) if y_true.ndim != 1 or y_score.ndim != 1: raise ValueError("y_true 和 y_score 必须是一维数组") if len(y_true) != len(y_score): raise ValueError(f"y_true 与 y_score 长度不一致: {len(y_true)} != {len(y_score)}") valid_mask = ~pd.isna(y_true) & ~pd.isna(y_score) y_true = y_true[valid_mask] y_score = y_score[valid_mask] if len(y_true) == 0: raise ValueError("y_true 和 y_score 没有可用的非缺失数据") if isinstance(n_bins, bool) or not isinstance(n_bins, (int, np.integer)) or n_bins <= 0: raise ValueError("n_bins 必须是正整数") fig, ax = get_or_create_ax(figsize=figsize, ax=ax) ax_hist = ax.twinx() if show_histogram else None bin_accuracies, mean_probabilities = calibration_curve( y_true, y_score, n_bins=n_bins, strategy='uniform', ) # 绘制完美校准线 ax.plot([0, 1], [0, 1], color=NEUTRAL_COLOR, linestyle='--', label='完美校准') # 绘制校准曲线 brier = brier_score_loss(y_true, y_score) ax.plot(mean_probabilities, bin_accuracies, 's-', color=colors[0], label=f'模型(Brier={brier:.3f})', **kwargs) # 绘制样本分布直方图 if show_histogram and ax_hist is not None: bin_boundaries = np.linspace(0, 1, n_bins + 1) bin_centers = (bin_boundaries[:-1] + bin_boundaries[1:]) / 2 bin_counts = np.histogram(y_score, bins=bin_boundaries)[0] ax_hist.bar(bin_centers, bin_counts, width=1/n_bins, alpha=0.3, color=colors[1], edgecolor=colors[1]) ax_hist.set_ylabel('样本数', fontsize=10, color=colors[1]) ax_hist.tick_params(axis='y', labelcolor=colors[1]) ax.set_xlabel('平均预测概率', fontsize=12) ax.set_ylabel('实际正样本率', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.set_xlim([0, 1]) ax.set_ylim([0, 1]) ax.legend(loc='upper left', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3) if save: save_figure(fig, save) return fig
# ==================== 评分卡相关图表 ====================
[文档] def score_dist_plot( df: Union[pd.DataFrame, pd.Series], score_col: Optional[str] = None, target_col: Optional[str] = None, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (12, 6), title: Optional[str] = None, colors: Optional[List[str]] = None, n_bins: int = 30, kde: bool = True, show_stats: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制评分分布对比图(好/坏样本分布对比). :param df: 数据DataFrame :param score_col: 评分列名 :param target_col: 目标变量列名,None时不区分好坏 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param n_bins: 直方图分箱数 :param kde: 是否显示核密度估计曲线 :param show_stats: 是否显示统计信息 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = score_dist_plot(df, 'score', 'target') """ created_ax = ax is None fig, ax = get_or_create_ax(figsize=figsize, ax=ax) fontsize = kwargs.pop('fontsize', 14) anchor = kwargs.pop('anchor', None) labels = kwargs.pop('labels', ["好样本", "坏样本"]) # 支持两种调用方式: # 1. score_dist_plot(df, 'score', 'target') # 原始:df + 列名 # 2. score_dist_plot(scores_series, targets_series) # 简化:直接传 Series if isinstance(df, pd.Series): score_series = df.dropna() if score_col is None and target_col is None else df target_series = score_col if isinstance(score_col, pd.Series) else None score_col = df.name or "评分" else: if target_col is not None: score_series = df[score_col] target_series = df[target_col] else: score_series = df[score_col] target_series = None score_col = score_col or "评分" # 对齐好/坏样本:复用 hist_plot 的 step + probability 风格 has_target = target_series is not None if has_target: mask = score_series.notna() & target_series.notna() score_series = score_series[mask] target_series = target_series[mask] target_unique = len(np.unique(target_series)) if isinstance(labels, dict): hue = target_series.map(labels) hue_order = list(labels.values()) else: hue = target_series.map({i: v for i, v in enumerate(labels)}) hue_order = labels hue_order_final = hue_order[::-1] palette = get_series_colors(target_unique) sns.histplot( x=score_series, hue=hue, element="step", stat="probability", bins=n_bins, common_bins=True, common_norm=True, ax=ax, kde=kde, palette=palette, hue_order=hue_order_final, **kwargs, ) else: score_series = score_series.dropna() color = colors[0] if colors else DEFAULT_COLORS[0] sns.histplot( x=score_series, element="step", stat="probability", bins=n_bins, ax=ax, kde=kde, color=color, **kwargs, ) # 坐标轴样式(与 hist_plot 一致) setup_axis_style(ax) ax.set_xlabel(f"{score_col}区间", fontsize=fontsize) ax.set_ylabel("样本占比", fontsize=fontsize) ax.yaxis.set_major_formatter(PercentFormatter(1)) # 标题 if title is None: title = f"{score_col}分布情况" if created_ax: title_artist = fig.suptitle(title, fontsize=fontsize) else: title_artist = ax.set_title(title, fontsize=fontsize) # KS 统计信息 if has_target and show_stats: from ..metrics import ks_2samps as ks_metric good_scores = score_series[target_series == 0] bad_scores = score_series[target_series == 1] ks_val = ks_metric(good_scores, bad_scores) ax.text(0.98, 0.98, f'KS: {ks_val:.3f}', transform=ax.transAxes, ha='right', va='top', fontsize=fontsize - 2, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) # 图例(顶部居中,与 hist_plot 一致) if has_target: legend_anchor = 1.15 if anchor is None else anchor handles, legend_labels = ax.get_legend_handles_labels() if handles: ax.legend(handles, hue_order_final[:len(handles)], loc='upper center', ncol=len(handles), bbox_to_anchor=(0.5, legend_anchor), frameon=False, fontsize=fontsize) else: ax.legend(hue_order, loc='upper center', ncol=target_unique, bbox_to_anchor=(0.5, legend_anchor), frameon=False, fontsize=fontsize) if created_ax: fig.tight_layout() if created_ax and has_target and anchor is None: _layout_top_center_legend(fig, ax.get_legend(), title=title_artist, axes=[ax]) if save: save_figure(fig, save) return fig
[文档] def score_bin_plot( df: pd.DataFrame, score_col: str, target_col: str, n_bins: int = 10, bin_type: str = 'quantile', ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (12, 6), title: Optional[str] = None, colors: Optional[List[str]] = None, show_table: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制评分分箱效果图(分箱区间+坏样本率). 使用 bin_plot(横向) + dataframe_plot 实现。 :param df: 数据DataFrame :param score_col: 评分列名 :param target_col: 目标变量列名 :param n_bins: 分箱数,默认10 :param bin_type: 分箱方式,'quantile'(等频)或'uniform'(等宽) :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_table: 是否显示数据表格 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = score_bin_plot(df, 'score', 'target', n_bins=10) """ # 导入需要的函数 from .binning_plots import bin_plot, dataframe_plot if colors is None: colors = DEFAULT_COLORS if bin_type not in {'quantile', 'uniform'}: raise ValueError("bin_type 仅支持 'quantile' 或 'uniform'") # 提取数据 score_series = df[score_col] target_series = df[target_col] # 传入 ax 时复用调用方画布;未传 ax 时按是否展示表格创建一栏或两栏布局。 if ax is not None: fig_charts = ax.figure ax_chart = ax if show_table: from mpl_toolkits.axes_grid1 import make_axes_locatable ax_table = make_axes_locatable(ax_chart).append_axes("right", size="42%", pad=0.6) else: ax_table = None elif show_table: fig_charts, axes = plt.subplots( 1, 2, figsize=figsize, gridspec_kw={'width_ratios': [2.5, 1]}, ) ax_chart, ax_table = axes else: fig_charts, ax_chart = plt.subplots(figsize=figsize) ax_table = None # 统一由推荐入口 bin_plot 同时生成图形和统计表,避免两套分箱口径漂移。 _, bin_stats = bin_plot( score_series, target=target_series, desc=title or f'{score_col}分箱', figsize=(figsize[0] * 0.65, figsize[1]), colors=colors, ax=ax_chart, orientation='horizontal', n_bins=n_bins, method=bin_type, show_data_points=True, show_overall_bad_rate=True, return_frame=True, save=None, ) # 2) dataframe_plot 显示分箱统计表 if show_table and ax_table is not None: label_col = '分箱标签' if '分箱标签' in bin_stats.columns else '分箱' table_df = bin_stats[[label_col, '样本总数', '坏样本数', '坏样本率']].copy() table_df.columns = ['评分区间', '样本总数', '坏样本数', '坏样本率'] table_df['坏样本率'] = table_df['坏样本率'].apply(lambda x: f'{x:.2%}') ax_table.axis('off') dataframe_plot( table_df, row_height=0.35, font_size=10, header_color=colors[0], ax=ax_table, save=None, ) fig_charts.tight_layout() if save: save_figure(fig_charts, save) return fig_charts
# ==================== 风控策略相关图表 ====================
[文档] def threshold_analysis_plot( y_true: Union[pd.Series, np.ndarray], y_score: Union[pd.Series, np.ndarray], thresholds: Optional[np.ndarray] = None, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (12, 8), title: str = "Threshold Analysis", colors: Optional[List[str]] = None, metrics: List[str] = ['precision', 'recall', 'f1', 'approval_rate'], save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制决策阈值分析图. 展示不同阈值下的各项评估指标,帮助选择最优决策阈值。 :param y_true: 真实标签 :param y_score: 预测概率分数 :param thresholds: 阈值数组,None时自动生成 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param metrics: 要显示的指标列表 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = threshold_analysis_plot(y_test, y_score) """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS if thresholds is None: thresholds = np.linspace(0.01, 0.99, 99) # 计算各阈值下的指标 results = {metric: [] for metric in metrics} for threshold in thresholds: y_pred = (y_score >= threshold).astype(int) tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel() precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 approval_rate = (tp + fp) / len(y_true) if 'precision' in metrics: results['precision'].append(precision) if 'recall' in metrics: results['recall'].append(recall) if 'f1' in metrics: results['f1'].append(f1) if 'approval_rate' in metrics: results['approval_rate'].append(approval_rate) if 'specificity' in metrics: specificity = tn / (tn + fp) if (tn + fp) > 0 else 0 results['specificity'].append(specificity) if 'accuracy' in metrics: accuracy = (tp + tn) / (tp + tn + fp + fn) results['accuracy'].append(accuracy) # 绘制各指标曲线 metric_labels = { 'precision': 'Precision', 'recall': 'Recall (TPR)', 'f1': 'F1 Score', 'approval_rate': 'Approval Rate', 'specificity': 'Specificity (TNR)', 'accuracy': 'Accuracy' } for i, metric in enumerate(metrics): if metric in results: ax.plot(thresholds, results[metric], lw=2, color=colors[i % len(colors)], label=metric_labels.get(metric, metric)) ax.set_xlabel('Threshold', fontsize=12) ax.set_ylabel('Score / Rate', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.set_xlim([0, 1]) ax.set_ylim([0, 1.05]) ax.legend(loc='best', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3) if save: save_figure(fig, save) return fig
[文档] def strategy_compare_plot( strategies: List[Dict[str, Any]], ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (12, 8), title: str = "Strategy Comparison", colors: Optional[List[str]] = None, metrics: List[str] = ['approval_rate', 'bad_rate', 'ks'], save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制多策略效果对比图. :param strategies: 策略列表,每项为包含策略指标的字典 例如: [{'name': '策略A', 'approval_rate': 0.8, 'bad_rate': 0.05, 'ks': 0.45}, ...] :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param metrics: 要对比的指标 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> strategies = [ ... {'name': 'Current', 'approval_rate': 0.75, 'bad_rate': 0.08, 'ks': 0.40}, ... {'name': 'New', 'approval_rate': 0.80, 'bad_rate': 0.06, 'ks': 0.50} ... ] >>> fig = strategy_compare_plot(strategies) """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) strategy_names = [s['name'] for s in strategies] n_strategies = len(strategy_names) n_metrics = len(metrics) if colors is None: colors = get_series_colors(n_strategies) # 设置柱状图位置 x = np.arange(n_metrics) width = 0.8 / n_strategies # 绘制每组策略的柱状图 for i, strategy in enumerate(strategies): values = [strategy.get(m, 0) for m in metrics] offset = (i - n_strategies/2 + 0.5) * width ax.bar(x + offset, values, width, label=strategy['name'], color=colors[i % len(colors)], alpha=0.8) # 设置标签 metric_labels = { 'approval_rate': 'Approval Rate', 'bad_rate': 'Bad Rate', 'ks': 'KS Statistic', 'auc': 'AUC', 'iv': 'IV', 'precision': 'Precision', 'recall': 'Recall' } ax.set_ylabel('Value', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.set_xticks(x) ax.set_xticklabels([metric_labels.get(m, m) for m in metrics], rotation=45, ha='right') ax.legend(loc='best', frameon=True) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3, axis='y') # 添加数值标签 for i, strategy in enumerate(strategies): values = [strategy.get(m, 0) for m in metrics] offset = (i - n_strategies/2 + 0.5) * width for j, v in enumerate(values): ax.text(j + offset, v + 0.01, f'{v:.3f}', ha='center', va='bottom', fontsize=8) if save: save_figure(fig, save) return fig
[文档] def vintage_plot( df: pd.DataFrame, mob_col: str, target_col: str, vintage_col: Optional[str] = None, ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (14, 8), title: Optional[str] = None, colors: Optional[List[str]] = None, max_mob: Optional[int] = None, show_heatmap: bool = False, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制Vintage账龄曲线图. 展示不同放款月份的资产在不同账龄(MOB)时的逾期率表现。 :param df: 数据DataFrame :param mob_col: MOB(账龄)列名 :param target_col: 目标变量列名(逾期标识) :param vintage_col: 放款月份/批次列名,None时不区分批次 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param max_mob: 最大MOB显示值 :param show_heatmap: 是否同时显示热力图 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = vintage_plot(df, 'mob', 'ever_dpd30', 'issue_month') """ # 创建透视表 if vintage_col: vintage_data = df.groupby([vintage_col, mob_col])[target_col].mean().reset_index() vintage_pivot = vintage_data.pivot(index=vintage_col, columns=mob_col, values=target_col) else: # 不区分批次,计算整体 vintage_overall = df.groupby(mob_col)[target_col].mean() vintage_pivot = vintage_overall.to_frame().T vintage_pivot.index = ['Overall'] if colors is None: colors = get_series_colors(len(vintage_pivot)) # 限制最大MOB if max_mob: mob_cols = [c for c in vintage_pivot.columns if c <= max_mob] vintage_pivot = vintage_pivot[mob_cols] # 创建图表:传入ax时复用该ax绘制曲线,热力图作为附加面板挂载在同一figure上, # 而不是整体重新plt.subplots()丢弃调用方传入的ax if ax is not None: ax_line = ax fig = ax_line.get_figure() if show_heatmap: from mpl_toolkits.axes_grid1 import make_axes_locatable ax_heat = make_axes_locatable(ax_line).append_axes("right", size="40%", pad=0.8) elif show_heatmap: fig, (ax_line, ax_heat) = plt.subplots(1, 2, figsize=figsize, gridspec_kw={'width_ratios': [2, 1]}) else: fig, ax_line = get_or_create_ax(figsize=figsize, ax=ax) # 绘制曲线 mob_values = vintage_pivot.columns.values for i, (vintage, row) in enumerate(vintage_pivot.iterrows()): color = colors[i % len(colors)] ax_line.plot(mob_values, row.values * 100, 'o-', label=str(vintage), color=color, lw=2, markersize=4) ax_line.set_xlabel('Month on Book (MOB)', fontsize=12) ax_line.set_ylabel('Bad Rate (%)', fontsize=12) if title is None: title = 'Vintage Analysis' ax_line.set_title(title, fontsize=14, fontweight='bold') ax_line.legend(loc='upper left', frameon=True, title='Vintage') setup_axis_style(ax_line, colors, hide_top_right=True) ax_line.grid(True, alpha=0.3) # 绘制热力图 if show_heatmap: sns.heatmap(vintage_pivot * 100, annot=True, fmt='.2f', cmap=make_risk_cmap("hscredit_vintage"), ax=ax_heat, cbar_kws={'label': 'Bad Rate (%)'}) ax_heat.set_title('Vintage Heatmap', fontsize=12, fontweight='bold') ax_heat.set_xlabel('MOB', fontsize=10) ax_heat.set_ylabel('Vintage', fontsize=10) if save: save_figure(fig, save) return fig
[文档] def feature_importance_plot( features: List[str], importance: Union[List[float], np.ndarray], ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (10, 8), title: str = "Feature Importance", colors: Optional[List[str]] = None, top_n: Optional[int] = 20, horizontal: bool = True, show_values: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制特征重要性图. :param features: 特征名称列表 :param importance: 特征重要性值列表 :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param top_n: 显示前N个特征,None时显示全部 :param horizontal: 是否水平显示 :param show_values: 是否显示数值 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> features = ['age', 'income', 'score', ...] >>> importance = model.feature_importances_ >>> fig = feature_importance_plot(features, importance, top_n=15) """ fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 排序并选择Top N sorted_indices = np.argsort(importance)[::-1] if top_n: sorted_indices = sorted_indices[:top_n] sorted_features = [features[i] for i in sorted_indices] sorted_importance = [importance[i] for i in sorted_indices] # 绘制 if horizontal: y_pos = np.arange(len(sorted_features)) bars = ax.barh(y_pos, sorted_importance, color=colors[0], alpha=0.8) ax.set_yticks(y_pos) ax.set_yticklabels(sorted_features) ax.invert_yaxis() # 最高重要性在顶部 ax.set_xlabel('Importance', fontsize=12) else: x_pos = np.arange(len(sorted_features)) bars = ax.bar(x_pos, sorted_importance, color=colors[0], alpha=0.8) ax.set_xticks(x_pos) ax.set_xticklabels(sorted_features, rotation=45, ha='right') ax.set_ylabel('Importance', fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') # 添加数值标签 if show_values: for bar, val in zip(bars, sorted_importance): if horizontal: ax.text(val + 0.01 * max(sorted_importance), bar.get_y() + bar.get_height()/2, f'{val:.3f}', va='center', fontsize=9) else: ax.text(bar.get_x() + bar.get_width()/2, val + 0.01 * max(sorted_importance), f'{val:.3f}', ha='center', fontsize=9) setup_axis_style(ax, colors, hide_top_right=True) ax.grid(True, alpha=0.3, axis='x' if horizontal else 'y') if save: save_figure(fig, save) return fig
[文档] def approval_rate_trend_plot( df: pd.DataFrame, date_col: str, decision_col: Optional[str] = None, score_col: Optional[str] = None, threshold: Optional[float] = None, freq: str = 'M', ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (14, 6), title: Optional[str] = None, colors: Optional[List[str]] = None, show_bad_rate: bool = True, target_col: Optional[str] = None, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制审批通过率趋势图. :param df: 数据DataFrame :param date_col: 日期列名 :param decision_col: 决策结果列名(通过/拒绝),None时使用score_col+threshold :param score_col: 评分列名(用于计算通过/拒绝) :param threshold: 通过阈值(分数>=threshold为通过) :param freq: 时间频率,'D'/'W'/'M'/'Q' :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_bad_rate: 是否同时显示逾期率趋势 :param target_col: 目标变量列名(show_bad_rate=True时需要) :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = approval_rate_trend_plot(df, 'apply_date', decision_col='is_approved') >>> fig = approval_rate_trend_plot(df, 'apply_date', score_col='score', threshold=500) """ df = df.copy() fig, ax = get_or_create_ax(figsize=figsize, ax=ax) if colors is None: colors = DEFAULT_COLORS # 确保日期格式正确 df[date_col] = pd.to_datetime(df[date_col]) # 计算通过标识 if decision_col: df['_approved'] = df[decision_col] elif score_col and threshold is not None: df['_approved'] = (df[score_col] >= threshold).astype(int) else: raise ValueError("必须提供decision_col或(score_col+threshold)") # 按时间聚合 df['_period'] = df[date_col].dt.to_period(freq) # 仅当提供 target_col 时才聚合逾期率,避免对 None 列聚合导致 KeyError agg_spec = {'_approved': ['count', 'sum', 'mean']} if target_col: agg_spec[target_col] = 'mean' trend_data = df.groupby('_period').agg(agg_spec).reset_index() if target_col: trend_data.columns = ['period', 'total', 'approved_count', 'approval_rate', 'bad_rate'] else: trend_data.columns = ['period', 'total', 'approved_count', 'approval_rate'] trend_data['period'] = trend_data['period'].dt.to_timestamp() # 绘制审批率 ax.plot(trend_data['period'], trend_data['approval_rate'] * 100, 'o-', color=colors[0], lw=2, markersize=4, label='Approval Rate') ax.fill_between(trend_data['period'], trend_data['approval_rate'] * 100, alpha=0.2, color=colors[0]) ax.set_xlabel('Date', fontsize=12) ax.set_ylabel('Approval Rate (%)', fontsize=12, color=colors[0]) ax.tick_params(axis='y', labelcolor=colors[0]) ax.yaxis.set_major_formatter(PercentFormatter()) # 绘制逾期率(双轴) if show_bad_rate and target_col: ax2 = ax.twinx() ax2.plot(trend_data['period'], trend_data['bad_rate'] * 100, 's-', color=BAD_RATE_COLOR, lw=2, markersize=4, label='Bad Rate') ax2.set_ylabel('Bad Rate (%)', fontsize=12, color=BAD_RATE_COLOR) ax2.tick_params(axis='y', labelcolor=BAD_RATE_COLOR) ax2.yaxis.set_major_formatter(PercentFormatter()) if title is None: title = 'Approval Rate Trend' ax.set_title(title, fontsize=14, fontweight='bold') # 合并图例 lines1, labels1 = ax.get_legend_handles_labels() if show_bad_rate and target_col: lines2, labels2 = ax2.get_legend_handles_labels() ax.legend(lines1 + lines2, labels1 + labels2, loc='best', frameon=True) else: ax.legend(loc='best', frameon=True) setup_axis_style(ax, colors) ax.grid(True, alpha=0.3) # 清理临时列 df.drop(columns=['_approved', '_period'], inplace=True, errors='ignore') if save: save_figure(fig, save) return fig
[文档] def bad_rate_trend_plot( df: pd.DataFrame, date_col: str, target: Optional[str] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, List[int]]] = None, del_grey: bool = False, dimension_col: Optional[str] = None, freq: str = 'M', ax: Optional[plt.Axes] = None, figsize: Tuple[float, float] = (14, 6), title: Optional[str] = None, colors: Optional[List[str]] = None, show_sample_count: bool = True, save: Optional[str] = None, **kwargs ) -> plt.Figure: """绘制坏样本率趋势图(支持分维度和多逾期标签展示). :param df: 数据DataFrame :param date_col: 日期列名 :param target: 目标变量列名(单标签模式) :param overdue: 逾期天数字段名或列表,优先于 target :param dpds: 逾期定义天数或列表,与 overdue 配合生成标签 :param del_grey: 是否排除逾期天数在 (0, dpd] 区间的灰样本 :param dimension_col: 维度列名(如客户等级),None时不分维度 :param freq: 时间频率,'D'/'W'/'M'/'Q' :param ax: matplotlib Axes对象 :param figsize: 图像尺寸 :param title: 图表标题 :param colors: 配色方案 :param show_sample_count: 是否显示样本数柱状图 :param save: 保存路径 :param kwargs: 其他参数 :return: matplotlib Figure对象 **参考样例** >>> fig = bad_rate_trend_plot(df, 'apply_date', target='target') >>> fig = bad_rate_trend_plot(df, 'apply_date', overdue='MOB1', dpds=[7, 30]) """ if 'target_col' in kwargs: raise TypeError("bad_rate_trend_plot 已统一使用 target 参数,请将 target_col 改为 target") df = df.copy() # 全库统一标签入口:overdue + dpds 显式传入时优先于 target。 target_groups = {} using_overdue = overdue is not None if using_overdue: if dpds is None: raise ValueError("传入 overdue 参数时必须同时传入 dpds") overdue_cols = [overdue] if isinstance(overdue, str) else list(overdue) dpd_values = [dpds] if isinstance(dpds, (int, np.integer)) else list(dpds) if not overdue_cols or not dpd_values: raise ValueError("overdue 和 dpds 不能为空") for overdue_col in overdue_cols: if overdue_col not in df.columns: raise ValueError(f"数据集缺少逾期天数列: {overdue_col}") overdue_days = pd.to_numeric(df[overdue_col], errors='coerce') overdue_targets = [] for dpd in dpd_values: label = f"{overdue_col}_{dpd}+" generated_target = (overdue_days > dpd).astype(float) if del_grey: generated_target[(overdue_days > 0) & (overdue_days <= dpd)] = np.nan overdue_targets.append((label, generated_target)) target_groups[overdue_col] = overdue_targets else: if target is None or target not in df.columns: raise ValueError("必须传入数据集中存在的 target 或 overdue+dpds 参数") target_values = pd.to_numeric(df[target], errors='coerce') invalid_values = target_values.dropna()[~target_values.dropna().isin([0, 1])] if not invalid_values.empty: raise ValueError(f"target 列 {target} 必须是 0/1 二分类标签") target_groups[target] = [(target, target_values)] all_targets = [ (target_label, target_values) for grouped_targets in target_groups.values() for target_label, target_values in grouped_targets ] # 传入ax时复用该ax绘制主曲线,样本数柱状图作为附加面板挂载在同一figure上, # 而不是整体重新plt.subplots()丢弃调用方传入的ax if ax is not None: ax_line = ax fig = ax_line.get_figure() if show_sample_count: panel_position = ax_line.get_position().frozen() panel_gap = min(0.02, panel_position.height * 0.05) available_height = panel_position.height - panel_gap bar_height = available_height / 4.0 line_height = available_height * 3.0 / 4.0 ax_bar = fig.add_axes( [panel_position.x0, panel_position.y0, panel_position.width, bar_height], sharex=ax_line, ) ax_line.set_position( [ panel_position.x0, panel_position.y0 + bar_height + panel_gap, panel_position.width, line_height, ] ) elif show_sample_count: fig, (ax_line, ax_bar) = plt.subplots(2, 1, figsize=figsize, sharex=True, gridspec_kw={'height_ratios': [3, 1], 'hspace': 0.1}) else: fig, ax_line = get_or_create_ax(figsize=figsize, ax=ax) # 日期先转为周期,再用离散位置绘制;刻度只来自真实分组,不交给日期定位器补点。 if date_col not in df.columns: raise ValueError(f"数据集缺少日期列: {date_col}") df[date_col] = pd.to_datetime(df[date_col]) freq = str(freq).upper() if freq not in {'D', 'W', 'M', 'Q'}: raise ValueError("freq 必须是 'D'/'W'/'M'/'Q' 之一") df['_period'] = df[date_col].dt.to_period(freq) periods = sorted(df['_period'].dropna().unique()) positions = np.arange(len(periods)) if colors is not None and not colors: raise ValueError("colors 至少需要包含一种颜色") def _unique_series_palette(requested_colors, required_count): palette = [] normalized_colors = set() fallback_colors = [BAD_RATE_COLOR, *get_series_colors(required_count + 1)] for color in [*requested_colors, *fallback_colors]: normalized = to_hex(color).lower() if normalized in normalized_colors: continue palette.append(color) normalized_colors.add(normalized) if len(palette) >= required_count: return palette risk_cmap = make_risk_cmap() for position in np.linspace(0.0, 1.0, max(required_count * 2, 2)): color = to_hex(risk_cmap(position)) normalized = color.lower() if normalized in normalized_colors: continue palette.append(color) normalized_colors.add(normalized) if len(palette) >= required_count: break return palette requested_series_colors = ( [BAD_RATE_COLOR, *get_series_colors(len(all_targets) + 1)] if colors is None else list(colors) ) series_colors = _unique_series_palette(requested_series_colors, len(all_targets)) dimensions = None dimension_series_colors = None if dimension_col: if dimension_col not in df.columns: raise ValueError(f"数据集缺少维度列: {dimension_col}") dimensions = list(pd.unique(df[dimension_col].dropna())) required_dimension_colors = len(all_targets) * len(dimensions) requested_dimension_colors = ( get_series_colors(required_dimension_colors) if colors is None else list(colors) ) dimension_series_colors = _unique_series_palette( requested_dimension_colors, required_dimension_colors, ) rate_axes = [] line_handles = [] line_labels = [] line_styles = ['-', '--', '-.', ':'] target_index = 0 for axis_index, (axis_label, grouped_targets) in enumerate(target_groups.items()): rate_ax = ax_line if axis_index == 0 else ax_line.twinx() if axis_index > 0: rate_ax.spines['right'].set_position(('outward', 52 * (axis_index - 1))) rate_axes.append(rate_ax) axis_color = series_colors[target_index % len(series_colors)] for target_label, target_values in grouped_targets: color = series_colors[target_index % len(series_colors)] working = pd.DataFrame({ '_period': df['_period'], '_target': target_values, }, index=df.index) if dimension_col: working['_dimension'] = df[dimension_col] for dimension_index, dimension in enumerate(dimensions): dimension_rates = ( working[working['_dimension'] == dimension] .groupby('_period', observed=False)['_target'] .mean() .reindex(periods) ) legend_label = ( str(dimension) if not using_overdue and len(all_targets) == 1 else f"{target_label} · {dimension}" ) color_index = target_index * len(dimensions) + dimension_index line_color = dimension_series_colors[color_index % len(dimension_series_colors)] line, = rate_ax.plot( positions, dimension_rates.to_numpy(dtype=float), marker='o', linestyle=line_styles[dimension_index % len(line_styles)], color=line_color, lw=2, markersize=4, label=legend_label, ) line_handles.append(line) line_labels.append(legend_label) else: bad_rates = ( working.groupby('_period', observed=False)['_target'] .mean() .reindex(periods) ) line, = rate_ax.plot( positions, bad_rates.to_numpy(dtype=float), 'o-', color=color, lw=2, markersize=4, label=( target_label if using_overdue or len(all_targets) > 1 else '坏样本率' ), ) line_handles.append(line) line_labels.append(line.get_label()) if len(all_targets) == 1: rate_ax.fill_between( positions, bad_rates.to_numpy(dtype=float), alpha=0.12, color=color, ) target_index += 1 rate_ax.set_ylabel( f'{axis_label} 坏样本率' if using_overdue else '坏样本率', fontsize=12, color=axis_color, ) rate_ax.tick_params(axis='y', colors=axis_color) rate_ax.yaxis.set_major_formatter(PercentFormatter(1.0)) if axis_index == 0: setup_axis_style(rate_ax, [axis_color], hide_top_right=True) else: rate_ax.spines['top'].set_visible(False) rate_ax.spines['bottom'].set_visible(False) rate_ax.spines['left'].set_visible(False) rate_ax.spines['right'].set_color(axis_color) rate_ax.tick_params(axis='x', bottom=False, labelbottom=False) rate_ax.set_axisbelow(True) rate_ax.grid(False) # 多坐标轴必须共享同一自适应百分比范围,否则成比例的序列会被各自缩放成完全重合的像素轨迹。 if len(rate_axes) > 1: plotted_rates = np.concatenate([ np.asarray(line.get_ydata(), dtype=float) for rate_ax in rate_axes for line in rate_ax.lines ]) finite_rates = plotted_rates[np.isfinite(plotted_rates)] if finite_rates.size: rate_min = float(finite_rates.min()) rate_max = float(finite_rates.max()) rate_span = rate_max - rate_min padding = max(rate_span * 0.08, 0.01) lower_limit = max(0.0, rate_min - padding) upper_limit = min(1.0, rate_max + padding) if upper_limit <= lower_limit: upper_limit = min(1.0, lower_limit + 0.05) lower_limit = max(0.0, upper_limit - 0.05) for rate_ax in rate_axes: rate_ax.set_ylim(lower_limit, upper_limit) if title is None: title = '坏样本率趋势' + (f'(按{dimension_col})' if dimension_col else '') title_artist = fig.suptitle(title, fontsize=14, fontweight='bold') max_legend_columns = max(1, int(fig.get_figwidth() // 2.2)) legend = fig.legend( line_handles, line_labels, loc='upper center', bbox_to_anchor=(0.5, 0.94), ncol=min(len(line_labels), max_legend_columns), frameon=False, ) ax_line.grid(True, axis='y', alpha=0.3, linestyle='--') # 绘制样本数柱状图 if show_sample_count: sample_counts = df.groupby('_period', observed=False).size().reindex(periods, fill_value=0) bar_color = DEFAULT_COLORS[0] ax_bar.bar(positions, sample_counts.to_numpy(), width=0.65, alpha=0.6, color=bar_color) ax_bar.set_ylabel('样本数', fontsize=10) ax_bar.set_xlabel('日期', fontsize=12) setup_axis_style(ax_bar, [bar_color], hide_top_right=True) ax_bar.set_axisbelow(True) ax_bar.grid(True, axis='y', alpha=0.3, linestyle='--') else: ax_line.set_xlabel('日期', fontsize=12) tick_axis = ax_bar if show_sample_count else ax_line tick_axis.set_xticks(positions) tick_axis.set_xticklabels([str(period) for period in periods]) tick_axis.tick_params(axis='x', labelrotation=30) for label in tick_axis.get_xticklabels(): label.set_horizontalalignment('right') if show_sample_count: for rate_ax in rate_axes: rate_ax.tick_params(axis='x', bottom=False, labelbottom=False) x_limits = (-0.5, len(periods) - 0.5) if periods else (-0.5, 0.5) for rate_ax in rate_axes: rate_ax.set_xlim(x_limits) if show_sample_count: ax_bar.set_xlim(x_limits) # 多个右侧坏率轴按实际渲染宽度收缩内容区,避免外移轴及标签被画布裁切。 fig.canvas.draw() renderer = fig.canvas.get_renderer() right_artists = [] for rate_ax in rate_axes[1:]: right_artists.extend([rate_ax.yaxis.label, *rate_ax.get_yticklabels()]) right_edges = [ artist.get_window_extent(renderer).x1 for artist in right_artists if artist.get_visible() and artist.get_text() ] padding_pixels = 6.0 * fig.dpi / 72.0 if right_edges: overflow = max(right_edges) - (fig.bbox.x1 - padding_pixels) else: overflow = 0.0 # twinx 轴共享位置;公共几何只计算一次并原样赋给上下所有面板,避免逐轴重复收窄。 rate_position = ax_line.get_position().frozen() shrink_fraction = max(0.0, overflow) / float(fig.bbox.width) + (0.01 if overflow > 0 else 0.0) common_width = max(0.2, rate_position.width - shrink_fraction) common_x0 = rate_position.x0 common_rate_position = (common_x0, rate_position.y0, common_width, rate_position.height) for rate_ax in rate_axes: rate_ax.set_position(common_rate_position) if show_sample_count: bar_position = ax_bar.get_position().frozen() ax_bar.set_position((common_x0, bar_position.y0, common_width, bar_position.height)) _layout_top_center_legend(fig, legend, title=title_artist, axes=[ax_line]) final_rate_position = ax_line.get_position().frozen() for rate_ax in rate_axes[1:]: rate_ax.set_position(final_rate_position.bounds) if show_sample_count: final_bar_position = ax_bar.get_position().frozen() ax_bar.set_position( ( final_rate_position.x0, final_bar_position.y0, final_rate_position.width, final_bar_position.height, ) ) if save: save_figure(fig, save) return fig