# -*- coding: utf-8 -*-
"""
稳定性感知特征筛选器.
同时考虑特征有效性(IV/KS)和稳定性(PSI),通过综合评分筛选特征,
避免选出区分力强但分布不稳定的特征。
**参考样例**
>>> from hscredit.core.selectors import StabilityAwareSelector
>>> 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)) # 目标变量
>>> oot_data = pd.DataFrame(np.random.randn(500, 5), columns=[f'f{i}' for i in range(5)]) # OOT验证集
>>> selector = StabilityAwareSelector(
... iv_threshold=0.02, # IV>0.02表示有预测能力
... psi_threshold=0.25, # PSI>0.25表示分布不稳定
... oot_df=oot_data, # 传入OOT数据计算PSI
... )
>>> 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 .base import BaseFeatureSelector
from .iv_selector import _compute_iv_feature, _compute_iv_single
from .psi_selector import _compute_psi_single
from ...exceptions import ValidationError
from ...utils.parallel import ParallelWorkload
def _compute_iv(x: np.ndarray, y: np.ndarray, regularization: float = 1.0) -> float:
"""计算单个特征的IV值.
使用信息价值(Information Value)评估特征的区分能力,支持正则化平滑处理。
:param x: 特征值数组
:param y: 目标变量数组(0/1)
:param regularization: 正则化系数,默认1.0,用于平滑分箱占比避免零除
:return: IV值,类型为float
"""
return _compute_iv_single(x, y, regularization)
def _compute_psi(expected: np.ndarray, actual: np.ndarray, n_bins: int = 10) -> float:
"""计算单个特征的PSI.
使用群体稳定性指数(Population Stability Index)评估特征在期望分布与实际分布间的差异。
:param expected: 期望分布数组(训练集/基准期数据)
:param actual: 实际分布数组(验证集/新数据)
:param n_bins: 分箱数量,默认10,用于计算各分箱的占比差异
:return: PSI值,类型为float
"""
return _compute_psi_single(expected, actual, n_bins=n_bins)
def _compute_stability_feature(task):
"""计算单个特征的 IV 与 PSI。"""
feature, iv_series, y, expected_values, oot_values, psi_bins = task
_, iv = _compute_iv_feature((feature, iv_series, y, 1.0))
psi = _compute_psi(expected_values, oot_values, psi_bins)
return feature, iv, psi
[文档]
class StabilityAwareSelector(BaseFeatureSelector):
"""稳定性感知筛选器.
综合考虑特征区分力(IV)和分布稳定性(PSI),通过加权评分公式选择特征:
score = iv_weight × IV_normalized − psi_weight × PSI_normalized
筛选条件(同时满足):
1. IV >= iv_threshold
2. PSI <= psi_threshold
3. 综合评分 >= score_threshold
**适用场景:**
- 模型变量筛选阶段,避免选入"高区分但不稳定"的特征
- 跨时间段 / OOT 验证时的特征稳健性筛选
:param iv_threshold: IV下限,默认 0.02
:param psi_threshold: PSI上限,默认 0.25
:param score_threshold: 综合评分下限,默认 0.0
:param iv_weight: IV 在综合评分中的权重,默认 0.6
:param psi_weight: PSI 在综合评分中的权重,默认 0.4
:param oot_df: 用于计算 PSI 的 OOT 数据集(DataFrame),
若不传则使用 fit 时的 X 进行随机对半拆分
:param psi_bins: PSI 分箱数,默认 10
:param target: 目标变量列名(当通过DataFrame入参时使用)
:param n_jobs: 并行数
**参考样例**
>>> from hscredit.core.selectors import StabilityAwareSelector
>>> 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))
>>> oot_data = pd.DataFrame(np.random.randn(500, 5), columns=[f'f{i}' for i in range(5)])
>>> selector = StabilityAwareSelector(
... iv_threshold=0.02,
... psi_threshold=0.25,
... oot_df=oot_data,
... )
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
**注意**
本筛选器是"稳定性感知"的复合筛选(IV 区分力 + PSI 稳定性加权评分),并非 Meinshausen
& Bühlmann 的 stability selection(重采样频率法)。IV 与 PSI 的定义与阈值分别见
:class:`~hscredit.core.selectors.IVSelector` 与
:class:`~hscredit.core.selectors.PSISelector`。
**引用**
IV / PSI 用于评分卡变量筛选与稳定性监控见 Siddiqi, N. (2006).
*Credit Risk Scorecards.* Wiley。
"""
method_name = "稳定性感知筛选"
def __init__(
self,
iv_threshold: float = 0.02,
psi_threshold: float = 0.25,
score_threshold: float = 0.0,
iv_weight: float = 0.6,
psi_weight: float = 0.4,
oot_df: Optional[pd.DataFrame] = None,
psi_bins: int = 10,
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,
random_state: Optional[int] = None,
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=iv_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.iv_threshold = iv_threshold
self.psi_threshold = psi_threshold
self.score_threshold = score_threshold
self.iv_weight = iv_weight
self.psi_weight = psi_weight
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:
if self.iv_threshold < 0 or self.psi_threshold < 0:
raise ValueError("iv_threshold 和 psi_threshold 不能小于 0")
if self.iv_weight < 0 or self.psi_weight < 0 or self.iv_weight + self.psi_weight <= 0:
raise ValueError("iv_weight 和 psi_weight 必须非负且至少一个大于 0")
if isinstance(self.psi_bins, (bool, np.bool_)) or not isinstance(self.psi_bins, (int, np.integer)) or int(self.psi_bins) < 2:
raise ValueError("psi_bins 必须是大于等于 2 的整数")
if y is None:
if self.target not in X.columns:
raise ValueError(f"需要传入 y 或 X 中包含 '{self.target}' 列")
y = X[self.target].values
X = X.drop(columns=self.target)
self._get_feature_names(X)
y = np.asarray(y)
iv_source = X
# --- PSI ---
if self.oot_df is not None:
if not isinstance(self.oot_df, pd.DataFrame):
raise ValidationError("oot_df 必须是 DataFrame")
oot = self.oot_df
if self.target in oot.columns:
oot = oot.drop(columns=self.target)
missing = [column for column in X.columns if column not in oot.columns]
if missing:
raise ValidationError(f"OOT 数据缺少拟合字段: {missing}")
oot_source = oot.loc[:, X.columns]
if self._binner_instance is not None:
oot_source = self._transform_with_fitted_binner(oot_source)
expected_source = X
else:
# 随机对半拆分
n = len(X)
if n < 2:
raise ValidationError("稳定性筛选至少需要 2 条样本")
rng = np.random.RandomState(self.random_state)
idx = rng.permutation(n)
expected_source = X.iloc[idx[: n // 2]]
oot_source = X.iloc[idx[n // 2 :]]
def iter_tasks():
for col in X.columns:
yield col, iv_source[col], y, expected_source[col].values, oot_source[col].values, self.psi_bins
results = self._parallel_execute(
_compute_stability_feature,
iter_tasks(),
task_labels=X.columns,
default_backend="loky",
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=10.0,
capability="process_safe",
operation="IV与PSI稳定性计算",
),
)
iv_vals = np.array([iv for _, iv, _ in results])
psi_vals = np.array([psi for _, _, psi in results])
# --- 综合评分 ---
iv_norm = iv_vals / max(iv_vals.max(), 1e-10)
psi_norm = psi_vals / max(psi_vals.max(), 1e-10)
combined = self.iv_weight * iv_norm - self.psi_weight * psi_norm
self.iv_scores_ = pd.Series(iv_vals, index=X.columns)
self.psi_scores_ = pd.Series(psi_vals, index=X.columns)
self.combined_scores_ = pd.Series(combined, index=X.columns)
self.scores_ = self.combined_scores_
# --- 筛选 ---
mask = (iv_vals >= self.iv_threshold) & (psi_vals <= self.psi_threshold) & (combined >= self.score_threshold)
self.selected_features_ = X.columns[mask].tolist()
dropped_cols = X.columns[~mask].tolist()
if dropped_cols:
reasons = []
for c in dropped_cols:
parts = []
if self.iv_scores_[c] < self.iv_threshold:
parts.append(f"IV({self.iv_scores_[c]:.4f})<{self.iv_threshold}")
if self.psi_scores_[c] > self.psi_threshold:
parts.append(f"PSI({self.psi_scores_[c]:.4f})>{self.psi_threshold}")
if self.combined_scores_[c] < self.score_threshold:
parts.append(f"综合分({self.combined_scores_[c]:.4f})<{self.score_threshold}")
reasons.append("; ".join(parts) if parts else "综合不达标")
self.dropped_ = pd.DataFrame(
{
"特征": dropped_cols,
"剔除原因": reasons,
"IV值": [self.iv_scores_[c] for c in dropped_cols],
"PSI值": [self.psi_scores_[c] for c in dropped_cols],
"综合评分": [self.combined_scores_[c] for c in dropped_cols],
}
)
else:
self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "IV值", "PSI值", "综合评分"])
[文档]
def get_detail(self) -> pd.DataFrame:
"""获取所有特征的 IV / PSI / 综合评分明细.
:return: DataFrame,含 IV、PSI、综合评分、是否入选
"""
if not hasattr(self, "iv_scores_"):
return pd.DataFrame()
df = pd.DataFrame(
{
"IV": self.iv_scores_,
"PSI": self.psi_scores_,
"综合评分": self.combined_scores_,
"入选": [c in self.selected_features_ for c in self.iv_scores_.index],
}
)
return df.sort_values("综合评分", ascending=False)