hscredit.core.metrics.stability 源代码

"""稳定性指标计算.

提供评估模型稳定性和分布变化的指标。

**参考样例**

>>> from hscredit.core.metrics import psi, psi_table, batch_psi, psi_rating
>>> import numpy as np
>>> np.random.seed(42)
>>> train = np.random.randn(1000)  # 训练集分布(期望)
>>> test = np.random.randn(1000) + 0.5  # 测试集分布偏移(实际)
>>> print(f"PSI={psi(train, test):.4f}")  # PSI>0.25表示分布变化显著
>>> print(psi_rating(psi(train, test)))  # 稳定性评级
>>> print(psi_table(train, test).head())

**引用**

- PSI(群体稳定性指标)/ CSI(特征稳定性指标)的定义与分级阈值见
  Siddiqi, N. (2006). *Credit Risk Scorecards.* Wiley;
  另见 Yurdakul, B. (2018). *Statistical Properties of Population Stability
  Index (PSI).* PhD Dissertation, Western Michigan University。
- PSI 本质为期望分布与实际分布之间的对称 KL 散度(Jensen 散度)之和,
  公式:PSI = Σ (实际占比 − 期望占比) · ln(实际占比 / 期望占比)。
"""

import numpy as np
import pandas as pd
from typing import Union, Tuple, Optional, List
from scipy.stats import chi2_contingency

from ._base import _create_bin_edges


[文档] def psi(expected: Union[np.ndarray, pd.Series], actual: Union[np.ndarray, pd.Series], method: str = 'quantile', max_n_bins: int = 10, min_bin_size: float = 0.01, **kwargs) -> float: """计算Population Stability Index (群体稳定性指标). PSI用于衡量两个分布之间的差异,评估模型或特征的稳定性。 值越小表示两个分布越接近,模型越稳定。 PSI分级标准: - PSI < 0.1: 没有显著变化,分布稳定 - 0.1 <= PSI < 0.25: 有轻微变化,需关注 - PSI >= 0.25: 有显著变化,模型可能需要重新训练 **参数** :param expected: 期望分布数据(通常是训练集或基准数据的特征/评分) :param actual: 实际分布数据(通常是测试集或新上线数据的特征/评分) :param method: 分箱方法,默认为'quantile'(等频分箱) :param max_n_bins: 最大分箱数,默认为10 :param min_bin_size: 每箱最小样本占比,默认为0.01 :param kwargs: 其他传递给OptimalBinning的参数 :return: PSI值 **参考样例** >>> from hscredit.core.metrics import psi, psi_rating >>> import numpy as np >>> np.random.seed(42) >>> train_scores = np.random.randn(1000) # 训练集评分分布 >>> test_scores = np.random.randn(1000) + 0.3 # 测试集评分分布偏移 >>> p = psi(train_scores, test_scores) # 计算PSI评估分布稳定性 >>> print(f"PSI={p:.4f}, 评级: {psi_rating(p)}") """ table = psi_table(expected, actual, method, max_n_bins, min_bin_size, **kwargs) return table['PSI贡献'].sum()
[文档] def psi_table(expected: Union[np.ndarray, pd.Series], actual: Union[np.ndarray, pd.Series], method: str = 'quantile', max_n_bins: int = 10, min_bin_size: float = 0.01, **kwargs) -> pd.DataFrame: """计算PSI详细统计表. 返回每个分箱的期望占比、实际占比及PSI贡献,用于分析分布变化的具体来源。 **参数** :param expected: 期望分布数据(通常是训练集或基准数据) :param actual: 实际分布数据(通常是测试集或新数据) :param method: 分箱方法,默认为'quantile'(等频分箱) :param max_n_bins: 最大分箱数,默认为10 :param min_bin_size: 每箱最小样本占比,默认为0.01 :param kwargs: 其他传递给OptimalBinning的参数 :return: 包含各分箱详细统计的DataFrame,列包括: - 分箱: 分箱标签 - 期望样本数: 该分箱内期望数据量 - 实际样本数: 该分箱内实际数据量 - 期望占比: 期望样本占总样本比例 - 实际占比: 实际样本占总样本比例 - PSI贡献: 该分箱对总PSI的贡献 **参考样例** >>> from hscredit.core.metrics import psi_table >>> import numpy as np >>> np.random.seed(42) >>> train = np.random.randn(1000) >>> test = np.random.randn(1000) + 0.5 >>> table = psi_table(train, test) >>> print(table) """ expected = np.asarray(expected) actual = np.asarray(actual) # 移除缺失值 expected_clean = expected[~pd.isna(expected)] actual_clean = actual[~pd.isna(actual)] total_expected = len(expected_clean) total_actual = len(actual_clean) if total_expected == 0 and total_actual == 0: return pd.DataFrame(columns=['分箱', '期望样本数', '实际样本数', '期望占比', '实际占比', 'PSI贡献']) # 使用OptimalBinning进行分箱 from ..binning import OptimalBinning # 构建DataFrame df_expected = pd.DataFrame({'value': expected_clean, 'is_expected': 1}) df_actual = pd.DataFrame({'value': actual_clean, 'is_expected': 0}) df_combined = pd.concat([df_expected, df_actual], ignore_index=True) binner = OptimalBinning( method=method, max_n_bins=max_n_bins, min_bin_size=min_bin_size, verbose=False, **kwargs ) # PSI 的分箱边界必须可复现。对于有监督分箱方法,用“基准/实际”作为确定性目标, # 避免随机 dummy target 让同一输入多次调用得到不同分箱和 PSI。 binner.fit(df_combined[['value']], df_combined['is_expected']) # 分别转换expected和actual bins_expected = binner.transform(df_expected[['value']], metric='indices').values.flatten() bins_actual = binner.transform(df_actual[['value']], metric='indices').values.flatten() # 计算每个箱的统计 unique_bins = sorted(set(bins_expected) | set(bins_actual)) results = [] epsilon = 1e-10 for bin_idx in unique_bins: expected_count = np.sum(bins_expected == bin_idx) actual_count = np.sum(bins_actual == bin_idx) expected_prop = expected_count / total_expected if total_expected > 0 else epsilon actual_prop = actual_count / total_actual if total_actual > 0 else epsilon # 避免除零 expected_prop = max(expected_prop, epsilon) actual_prop = max(actual_prop, epsilon) psi_contrib = (actual_prop - expected_prop) * np.log(actual_prop / expected_prop) # 获取分箱标签 bin_label = f"Bin_{bin_idx}" if 'value' in binner.bin_tables_: bin_table = binner.bin_tables_['value'] if bin_idx < len(bin_table) and '分箱标签' in bin_table.columns: bin_label = bin_table.iloc[bin_idx]['分箱标签'] results.append({ '分箱': bin_label, '期望样本数': expected_count, '实际样本数': actual_count, '期望占比': expected_prop, '实际占比': actual_prop, 'PSI贡献': psi_contrib, }) return pd.DataFrame(results)
[文档] def psi_rating(psi_value: float) -> str: """根据PSI值返回稳定性评级. **参数** :param psi_value: PSI值(通常由psi()函数计算得到) :return: 稳定性评级描述字符串 - PSI < 0.1: "没有显著变化 (PSI < 0.1)" - 0.1 <= PSI < 0.25: "有轻微变化 (0.1 <= PSI < 0.25)" - PSI >= 0.25: "有显著变化 (PSI >= 0.25)" **参考样例** >>> from hscredit.core.metrics import psi_rating >>> psi_rating(0.05) '没有显著变化 (PSI < 0.1)' >>> psi_rating(0.3) '有显著变化 (PSI >= 0.25)' """ if psi_value < 0.1: return "没有显著变化 (PSI < 0.1)" elif psi_value < 0.25: return "有轻微变化 (0.1 <= PSI < 0.25)" else: return "有显著变化 (PSI >= 0.25)"
[文档] def csi(expected: Union[np.ndarray, pd.Series], actual: Union[np.ndarray, pd.Series], method: str = 'quantile', max_n_bins: int = 10, min_bin_size: float = 0.01, **kwargs) -> float: """计算Characteristic Stability Index (特征稳定性指标). CSI是PSI的变体,专门用于衡量单个特征分布的稳定性。 与PSI的区别在于CSI通常针对单一特征,而非模型评分。 **参数** :param expected: 期望分布数据(通常是训练集的特征数据) :param actual: 实际分布数据(通常是测试集或新数据的特征) :param method: 分箱方法,默认为'quantile'(等频分箱) :param max_n_bins: 最大分箱数,默认为10 :param min_bin_size: 每箱最小样本占比,默认为0.01 :param kwargs: 其他传递给OptimalBinning的参数 :return: CSI值(计算方法与PSI相同) :raises ValueError: 数据为空时 **参考样例** >>> from hscredit.core.metrics import csi >>> import numpy as np >>> np.random.seed(42) >>> train = np.random.randn(1000) >>> test = np.random.randn(1000) + 0.5 >>> csi(train, test) 0.34 """ return psi(expected, actual, method, max_n_bins, min_bin_size, **kwargs)
[文档] def csi_table(expected: Union[np.ndarray, pd.Series], actual: Union[np.ndarray, pd.Series], method: str = 'quantile', max_n_bins: int = 10, min_bin_size: float = 0.01, **kwargs) -> pd.DataFrame: """计算CSI详细统计表. **参数** :param expected: 期望分布数据(通常是训练集的特征数据) :param actual: 实际分布数据(通常是测试集或新数据的特征) :param method: 分箱方法,默认为'quantile'(等频分箱) :param max_n_bins: 最大分箱数,默认为10 :param min_bin_size: 每箱最小样本占比,默认为0.01 :param kwargs: 其他传递给OptimalBinning的参数 :return: 包含各分箱详细统计的DataFrame,列与psi_table相同,PSI贡献列重命名为CSI贡献 **参考样例** >>> from hscredit.core.metrics import csi_table >>> import numpy as np >>> np.random.seed(42) >>> train = np.random.randn(1000) >>> test = np.random.randn(1000) + 0.5 >>> table = csi_table(train, test) >>> print(table) """ table = psi_table(expected, actual, method, max_n_bins, min_bin_size, **kwargs) table = table.rename(columns={'PSI贡献': 'CSI贡献'}) return table
[文档] def batch_psi(X_train: pd.DataFrame, X_test: pd.DataFrame, features: Optional[List[str]] = None, method: str = 'quantile', max_n_bins: int = 10, min_bin_size: float = 0.01, **kwargs) -> pd.DataFrame: """批量计算多特征的PSI. 对指定的多个特征同时计算PSI,返回各特征的PSI值和稳定性评级。 **参数** :param X_train: 训练集特征DataFrame :param X_test: 测试集特征DataFrame(与X_train列结构一致) :param features: 需要计算PSI的特征列表,默认为None(计算全部共同列) :param method: 分箱方法,默认为'quantile'(等频分箱) :param max_n_bins: 最大分箱数,默认为10 :param min_bin_size: 每箱最小样本占比,默认为0.01 :param kwargs: 其他传递给OptimalBinning的参数 :return: 包含各特征PSI结果的DataFrame,列包括: - 特征: 特征名称 - PSI: PSI值 - 评级: 稳定性评级(由psi_rating函数返回) **参考样例** >>> from hscredit.core.metrics import batch_psi >>> import numpy as np >>> import pandas as pd >>> np.random.seed(42) >>> cols = ['age', 'income', 'credit_score'] >>> X_train = pd.DataFrame(np.random.randn(1000, 3), columns=cols) >>> X_test = pd.DataFrame(np.random.randn(1000, 3) + 0.5, columns=cols) >>> result = batch_psi(X_train, X_test) >>> print(result) """ if features is None: features = list(X_train.columns) results = [] for feature in features: if feature in X_train.columns and feature in X_test.columns: try: psi_value = psi( X_train[feature], X_test[feature], method, max_n_bins, min_bin_size, **kwargs ) rating = psi_rating(psi_value) results.append({ '特征': feature, 'PSI': psi_value, '评级': rating, }) except Exception as e: results.append({ '特征': feature, 'PSI': np.nan, '评级': f'计算失败: {str(e)}', }) return pd.DataFrame(results)