hscredit.core.selectors.psi_selector 源代码

"""PSI稳定性筛选器.

使用群体稳定性指标(PSI)筛选特征。

**参考样例**

>>> from hscredit.core.selectors import PSISelector
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.random.randn(1000, 5), columns=[f'f{i}' for i in range(5)])  # 5个特征
>>> y = pd.Series(np.random.randint(0, 2, 1000))  # 目标变量
>>> selector = PSISelector(threshold=0.25, n_splits=5)  # 筛选PSI<0.25的稳定特征
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
"""

from typing import Union, List, Optional, Dict, Any
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold
from pandas.api.types import is_numeric_dtype

from .base import BaseFeatureSelector
from ...exceptions import ValidationError
from ...utils.parallel import ParallelWorkload


def _psi_from_counts(expected_counts: np.ndarray, actual_counts: np.ndarray) -> float:
    """用轻量平滑从同一组桶计数计算 PSI。"""
    epsilon = 1e-6
    expected_counts = np.asarray(expected_counts, dtype=float)
    actual_counts = np.asarray(actual_counts, dtype=float)
    expected_rates = (expected_counts + epsilon) / (expected_counts.sum() + epsilon * len(expected_counts))
    actual_rates = (actual_counts + epsilon) / (actual_counts.sum() + epsilon * len(actual_counts))
    return float(np.sum((actual_rates - expected_rates) * np.log(actual_rates / expected_rates)))


def _compute_psi_single(expected: np.ndarray, actual: np.ndarray, n_bins: int = 10) -> float:
    """计算单个特征的PSI值。

    :param expected: 期望值数组(训练集)
    :param actual: 实际值数组(测试集)
    :return: PSI值
    """
    if isinstance(n_bins, (bool, np.bool_)) or not isinstance(n_bins, (int, np.integer)) or int(n_bins) < 2:
        raise ValueError("n_bins 必须是大于等于 2 的整数")

    expected_series = pd.Series(expected)
    actual_series = pd.Series(actual)
    expected_missing = int(expected_series.isna().sum())
    actual_missing = int(actual_series.isna().sum())
    expected_valid = expected_series.dropna()
    actual_valid = actual_series.dropna()

    if expected_valid.empty and actual_valid.empty:
        return 0.0

    numeric = is_numeric_dtype(expected_valid.dtype) and is_numeric_dtype(actual_valid.dtype)
    expected_unique = expected_valid.nunique(dropna=True)

    if numeric and expected_unique > int(n_bins):
        expected_values = expected_valid.to_numpy(dtype=float)
        actual_values = actual_valid.to_numpy(dtype=float)
        cuts = np.unique(np.quantile(expected_values, np.linspace(0, 1, int(n_bins) + 1)[1:-1]))
        bins = np.concatenate(([-np.inf], cuts, [np.inf]))
        expected_counts = np.histogram(expected_values, bins=bins)[0]
        actual_counts = np.histogram(actual_values, bins=bins)[0]
    else:
        categories = []
        seen = set()
        for value in pd.concat([expected_valid.astype(object), actual_valid.astype(object)], ignore_index=True):
            key = (type(value), value)
            if key not in seen:
                seen.add(key)
                categories.append(value)
        expected_counts = np.array([(expected_valid == value).sum() for value in categories], dtype=float)
        actual_counts = np.array([(actual_valid == value).sum() for value in categories], dtype=float)

    expected_counts = np.append(expected_counts, expected_missing)
    actual_counts = np.append(actual_counts, actual_missing)
    return _psi_from_counts(expected_counts, actual_counts)


def _compute_psi_feature(task):
    """按既定折序计算单个特征的平均 PSI。"""
    feature, values, splits, n_bins = task
    total = 0.0
    for train_idx, test_idx in splits:
        total += _compute_psi_single(values[train_idx], values[test_idx], n_bins=n_bins)
    return feature, total / len(splits)


def _compute_psi_pair_feature(task):
    """计算一个训练/OOT字段对的 PSI。"""
    feature, expected, actual, n_bins = task
    return feature, _compute_psi_single(expected, actual, n_bins=n_bins)


[文档] class PSISelector(BaseFeatureSelector): """PSI筛选器. 使用群体稳定性指标(Population Stability Index)筛选特征。 PSI衡量特征在不同样本间的分布稳定性。 常用于跨时间验证和oot验证。 PSI值解释: - < 0.1: 特征稳定性好 - 0.1 - 0.25: 特征有轻微变化,需要关注 - > 0.25: 特征分布变化显著,需要处理 **参数** :param threshold: PSI阈值,默认为0.25 - 0.25: 移除PSI值超过0.25的特征 :param n_splits: 交叉验证折数,用于计算PSI :param oot_df: 可选的真实 OOT 对照集;传入时直接计算训练集与 OOT 的 PSI :param psi_bins: 连续变量分箱数,默认为10;低基数/类别变量按类别对齐 :param random_state: 未传 OOT 时交叉分折的随机种子 :param target: 目标变量列名,默认为'target' :param n_jobs: 并行计算的任务数 **参考样例** :: >>> from hscredit.core.selectors import PSISelector >>> import pandas as pd >>> import numpy as np >>> np.random.seed(42) >>> X = pd.DataFrame(np.random.randn(1000, 5), columns=[f'f{i}' for i in range(5)]) >>> y = pd.Series(np.random.randint(0, 2, 1000)) >>> selector = PSISelector(threshold=0.25, n_splits=5) >>> selector.fit(X, y) >>> print(selector.selected_features_) **引用** PSI(群体稳定性指标)公式 ``PSI = Σ (实际占比 - 预期占比) × ln(实际占比 / 预期占比)``, 经验阈值 <0.1 稳定、0.1~0.25 轻微漂移、>0.25 显著漂移,广泛用于信用评分的稳定性 监控,参见 Siddiqi, N. (2006). *Credit Risk Scorecards.* Wiley。 """ method_name = "PSI筛选" def __init__( self, threshold: float = 0.25, n_splits: int = 5, oot_df: Optional[pd.DataFrame] = None, psi_bins: int = 10, random_state: Optional[int] = 42, target: str = "target", include: Optional[List[str]] = None, exclude: Optional[List[str]] = None, force_drop: Optional[List[str]] = None, n_jobs: Optional[Union[int, float]] = -1, binner: Optional[Any] = None, binning_params: Optional[Dict[str, Any]] = None, parallel_backend: Optional[str] = None, parallel_config: Optional[Dict[str, Any]] = None, ): super().__init__( target=target, threshold=threshold, include=include, exclude=exclude, force_drop=force_drop, n_jobs=n_jobs, binner=binner, binning_params=binning_params, parallel_backend=parallel_backend, parallel_config=parallel_config, ) self.n_splits = n_splits self.oot_df = oot_df self.psi_bins = psi_bins self.random_state = random_state def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合PSI筛选器。 使用交叉验证,将数据分为训练集和验证集,计算PSI。 :param X: 输入特征DataFrame :param y: 目标变量 """ self._get_feature_names(X) if isinstance(self.psi_bins, (bool, np.bool_)) or not isinstance(self.psi_bins, (int, np.integer)): raise ValueError("psi_bins 必须是整数") if int(self.psi_bins) < 2: raise ValueError("psi_bins 必须大于等于 2") if isinstance(self.threshold, (bool, np.bool_)) or not isinstance( self.threshold, (int, float, np.integer, np.floating) ): raise ValueError("threshold 必须是非负数") if float(self.threshold) < 0: raise ValueError("threshold 必须是非负数") if self.oot_df is not None: if not isinstance(self.oot_df, pd.DataFrame): raise ValidationError("oot_df 必须是 pandas DataFrame") oot = self.oot_df.drop(columns=[self.target], errors="ignore") missing = [column for column in X.columns if column not in oot.columns] if missing: raise ValidationError(f"OOT 数据缺少拟合字段: {missing}") oot = oot.loc[:, X.columns] if self._binner_instance is not None: oot = self._transform_with_fitted_binner(oot) worker = _compute_psi_pair_feature tasks = ((col, X[col].values, oot[col].values, self.psi_bins) for col in X.columns) operation = "训练集与OOT的PSI计算" cost = 6.0 else: if isinstance(self.n_splits, (bool, np.bool_)) or not isinstance(self.n_splits, (int, np.integer)): raise ValueError("n_splits 必须是整数") if not 2 <= int(self.n_splits) <= len(X): raise ValueError("n_splits 必须在 [2, 样本数] 范围内") kfold = KFold(n_splits=int(self.n_splits), shuffle=True, random_state=self.random_state) splits = list(kfold.split(X)) worker = _compute_psi_feature tasks = ((col, X[col].values, splits, self.psi_bins) for col in X.columns) operation = "PSI交叉验证" cost = max(4.0, float(self.n_splits) * 2.0) results = self._parallel_execute( worker, tasks, task_labels=X.columns, default_backend="threading", workload=ParallelWorkload( task_count=X.shape[1], rows=X.shape[0], columns=X.shape[1], data_bytes=int(X.memory_usage(deep=True).sum()), cost_per_item=cost, capability="thread_safe", releases_gil=True, operation=operation, ), ) psi_values = np.array([score for _, score in results]) self.scores_ = pd.Series(psi_values, index=X.columns) # 选择PSI值小于阈值的特征(PSI越小越稳定) selected_mask = psi_values < self.threshold self.selected_features_ = X.columns[selected_mask].tolist() # 构建详细的dropped_记录,包含PSI值 dropped_cols = X.columns[~selected_mask].tolist() if len(dropped_cols) > 0: self.dropped_ = pd.DataFrame( { "特征": dropped_cols, "剔除原因": [f"PSI值({self.scores_[col]:.4f}) >= 阈值({self.threshold})" for col in dropped_cols], "PSI值": [self.scores_[col] for col in dropped_cols], "阈值": [self.threshold] * len(dropped_cols), } ) else: self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "PSI值", "阈值"])