hscredit.core.selectors.corr_selector 源代码

"""相关性筛选器.

移除与其它特征高度相关的特征,保留指标值更优的特征。
参考 scorecardpipeline / toad 的相关性筛选逻辑。

**参考样例**

>>> from hscredit.core.selectors import CorrSelector
>>> 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))  # 目标变量(用于IV计算)
>>> selector = CorrSelector(threshold=0.7, metric='iv')  # 默认Spearman,相关性>0.7时移除
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
"""

import numbers
from typing import Union, List, Optional, Dict, Any

import numpy as np
import pandas as pd
from threadpoolctl import threadpool_limits

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


# bin_tables_ 中指标列名 → 聚合方式的映射
_METRIC_COL_MAP = {
    "iv": ("指标IV值", lambda s: s.iloc[0]),  # 每行相同,取第一个
    "ks": ("分档KS值", "max"),  # 取最大KS
    "lift": ("LIFT值", "max"),  # 取最大LIFT
    "bad_rate": ("坏样本率", "max"),  # 取最大坏样本率
}

class _DefaultBinningParams(dict):
    """可被 sklearn clone 保留身份的不可变默认配置标记。"""

    def __deepcopy__(self, memo):
        return self

    def __reduce__(self):
        return _get_default_binning_params, ()

    def _immutable(self, *args, **kwargs):
        raise TypeError("默认分箱配置不可变;如需修改请显式传入新的 binning_params 字典")

    __setitem__ = _immutable
    __delitem__ = _immutable
    clear = _immutable
    pop = _immutable
    popitem = _immutable
    setdefault = _immutable
    update = _immutable


DEFAULT_BINNING_PARAMS = _DefaultBinningParams(
    {
        "method": "best_iv",
        "max_n_bins": 5,
        "min_bin_size": 0.01,
        "missing_separate": True,
    }
)


def _get_default_binning_params():
    """供 pickle 恢复不可变默认配置单例。"""
    return DEFAULT_BINNING_PARAMS


def _rank_corr_column(task):
    """按列计算平均秩;线程后端可直接写回当前列。"""
    ordinal, column, *options = task
    ranked = pd.Series(column, copy=False).rank(method="average").to_numpy(dtype=np.float64)
    if options and options[0]:
        column[:] = ranked
        return ordinal, None
    return ordinal, ranked


def _corr_block_worker(task):
    """计算当前候选块与一个已处理块的相关性。"""
    (
        ordinal,
        values,
        row_start,
        row_stop,
        column_start,
        column_stop,
        threshold,
        method,
        fast_matrix,
        kept_rows,
    ) = task

    right = values[:, column_start:column_stop]
    diagonal = row_start == column_start
    if diagonal:
        left = values[:, row_start:row_stop]
        kept_positions = None
    else:
        kept_positions = np.flatnonzero(np.asarray(kept_rows, dtype=bool))
        width = column_stop - column_start
        if kept_positions.size == 0:
            return (
                ordinal,
                "prior",
                column_start,
                np.full(width, np.nan, dtype=np.float64),
                np.full(width, -1, dtype=np.int64),
            )
        left = values[:, row_start:row_stop][:, kept_positions]

    if fast_matrix:
        corr = np.matmul(left.T, right)
        corr = np.clip(corr, -1.0, 1.0)
    else:
        if diagonal:
            corr = pd.DataFrame(left).corr(method=method).to_numpy(dtype=np.float64)
        else:
            combined = np.concatenate((left, right), axis=1)
            combined_corr = pd.DataFrame(combined).corr(method=method).to_numpy(dtype=np.float64)
            corr = combined_corr[: left.shape[1], left.shape[1] :]

    absolute = np.abs(np.asarray(corr, dtype=np.float64))
    if diagonal:
        np.fill_diagonal(absolute, np.nan)
        return ordinal, "diagonal", column_start, absolute, None

    width = column_stop - column_start
    safe = np.where(np.isnan(absolute), -np.inf, absolute)
    positions = np.argmax(safe, axis=0)
    column_positions = np.arange(width)
    max_values = safe[positions, column_positions]
    related_indices = row_start + kept_positions[positions]
    conflicts = np.isfinite(max_values) & (max_values > threshold)
    return (
        ordinal,
        "prior",
        column_start,
        np.where(conflicts, max_values, np.nan).astype(np.float64, copy=False),
        np.where(conflicts, related_indices, -1).astype(np.int64, copy=False),
    )


[文档] class CorrSelector(BaseFeatureSelector): """相关性筛选器. 移除与其它特征相关性高于阈值的特征。 当两个特征高度相关时,保留指定指标(默认 IV)更高的特征。 特征按指标稳定降序逐步处理,只允许已实际保留的特征淘汰后续特征, 避免相关链中已删除变量继续造成过度筛除。 **参数** :param threshold: 相关系数阈值,默认为0.7 - 0.7: 移除与其它特征相关性超过0.7的特征 - 范围: 0-1之间的浮点数 :param method: 相关性计算方法,默认为'spearman' - 'pearson': 皮尔逊相关系数 - 'spearman': 斯皮尔曼等级相关系数 - 'kendall': 肯德尔相关系数 :param metric: 用于决定保留哪个特征的指标,默认为'iv' - 'iv': 信息值(需要目标变量 y) - 'ks': KS 统计量 - 'lift': LIFT 值 - 'bad_rate': 坏样本率 指标通过分箱后的 bin_tables_ 计算得到。 :param weights: 特征权重,用于决定保留哪个特征,默认为None 如果同时传入 weights 和 metric,weights 优先。 :param binning_params: 透传给 OptimalBinning 的筛选前分箱参数,例如: - method: 分箱方法,默认'best_iv' - max_n_bins: 最大分箱数,默认5 - min_bin_size: 最小分箱比例,默认0.01 - missing_separate: 是否缺失单独分箱,默认True - prebinning: 预分箱方法 等等,详见 OptimalBinning 文档。 :param corr_block_size: 相关矩阵分块列数,默认为512。仅控制内存和任务粒度, 不改变相关系数、筛选阈值或结果顺序。 **参考样例** >>> from hscredit.core.selectors import CorrSelector >>> selector = CorrSelector(threshold=0.7, metric='iv') >>> selector.fit(X, y) >>> print(selector.selected_features_) >>> >>> # 基于 KS 的相关性筛选 >>> selector = CorrSelector(threshold=0.7, metric='ks') >>> selector.fit(X, y) >>> >>> # 自定义分箱参数 >>> selector = CorrSelector( ... threshold=0.7, ... metric='iv', ... binning_params={'method': 'cart', 'max_n_bins': 8} ... ) >>> selector.fit(X, y) >>> >>> # 使用自定义权重(不做分箱) >>> selector = CorrSelector(threshold=0.7, weights=iv_series) >>> selector.fit(X) **引用** 无缺失 Pearson 使用数学等价的标准化分块矩阵乘法;含缺失数据及 Spearman/Kendall 使用 ``pandas.DataFrame.corr`` 的分块兼容路径: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.corr.html ; 高相关特征对中保留 IV/KS 更高者的原则参考 toad / scorecardpipeline。 """ method_name = "相关性筛选" def __init__( self, threshold: float = 0.7, method: str = "spearman", metric: str = "iv", weights: Optional[Union[pd.Series, Dict[str, float], List[float]]] = None, binning_params: Optional[Dict[str, Any]] = DEFAULT_BINNING_PARAMS, 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, parallel_backend: Optional[str] = None, parallel_config: Optional[Dict[str, Any]] = None, corr_block_size: int = 512, ): 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.method = method self.metric = metric self.weights = weights self.corr_block_size = corr_block_size @property def _uses_default_binning_params(self) -> bool: """区分隐式默认配置与内容相同的显式用户配置。""" return isinstance(self.binning_params, _DefaultBinningParams) def _validate_corr_block_size(self) -> int: """校验并返回相关计算块大小。""" if isinstance(self.corr_block_size, (bool, np.bool_)) or not isinstance(self.corr_block_size, numbers.Integral) or int(self.corr_block_size) < 1: raise ValidationError("corr_block_size 必须为正整数") return int(self.corr_block_size) def _resolve_corr_total_workers(self, task_count: int) -> int: """解析 Corr 阶段可由外层任务和原生线程共同使用的总预算。""" return _resolve_current_n_jobs(self.n_jobs) or 1 def _rank_corr_values(self, values: np.ndarray, default_backend: str) -> np.ndarray: """按输入列顺序分批并行排名,并原地复用 float64 输入缓冲区。""" if values.dtype != np.float64: values = values.astype(np.float64, copy=False) expected_shape = (values.shape[0],) effective_backend = self.parallel_backend or default_backend if effective_backend == "threading": ordinals = list(range(values.shape[1])) tasks = [(ordinal, values[:, ordinal], True) for ordinal in ordinals] results = self._parallel_execute( _rank_corr_column, tasks, task_labels=ordinals, default_backend=default_backend, workload=ParallelWorkload( task_count=len(tasks), rows=values.shape[0], columns=values.shape[1], data_bytes=int(values.nbytes), cost_per_item=2.0, capability="thread_safe", releases_gil=True, operation="Spearman字段排名", ), ) for expected_ordinal, result in zip(ordinals, results): if result != (expected_ordinal, None): raise TypeError(f"相关排名任务 {expected_ordinal} 返回结果无效") return values batch_size = self._validate_corr_block_size() for batch_start in range(0, values.shape[1], batch_size): batch_stop = min(batch_start + batch_size, values.shape[1]) ordinals = list(range(batch_start, batch_stop)) tasks = [(ordinal, values[:, ordinal]) for ordinal in ordinals] results = self._parallel_execute( _rank_corr_column, tasks, task_labels=ordinals, default_backend=default_backend, workload=ParallelWorkload( task_count=len(tasks), rows=values.shape[0], columns=len(tasks), data_bytes=int(values[:, batch_start:batch_stop].nbytes), cost_per_item=2.0, capability="process_safe", operation="Spearman字段排名", ), ) for expected_ordinal, result in zip(ordinals, results): if not isinstance(result, tuple) or len(result) != 2 or result[0] != expected_ordinal: raise TypeError(f"相关排名任务 {expected_ordinal} 返回结果无效") ranked = np.asarray(result[1], dtype=np.float64) if ranked.shape != expected_shape: raise TypeError(f"相关排名任务 {expected_ordinal} 返回形状无效") values[:, expected_ordinal] = ranked return values def _execute_corr_tasks( self, tasks, labels, default_backend: str, total_workers: int, ): """在同一总预算内协调相关块 joblib 任务和 BLAS 原生线程。""" effective_backend = self.parallel_backend or default_backend matrix = tasks[0][1] if tasks else np.empty((0, 0), dtype=float) workload = ParallelWorkload( task_count=len(tasks), rows=matrix.shape[0] if matrix.ndim > 0 else 0, columns=matrix.shape[1] if matrix.ndim > 1 else 1, data_bytes=int(matrix.nbytes), cost_per_item=max(4.0, float(matrix.shape[1] if matrix.ndim > 1 else 1)), capability="thread_safe" if effective_backend == "threading" else "process_safe", releases_gil=effective_backend == "threading", operation=f"{self.method}相关块计算", ) if effective_backend != "threading": return self._parallel_execute( _corr_block_worker, tasks, task_labels=labels, default_backend=default_backend, workload=workload, ) outer_workers = min(total_workers, max(1, len(tasks))) native_threads = max(1, total_workers // outer_workers) with threadpool_limits(limits=native_threads): return self._parallel_execute( _corr_block_worker, tasks, task_labels=labels, default_backend=default_backend, workload=workload, ) @staticmethod def _merge_max_candidates( max_values: np.ndarray, max_indices: np.ndarray, indices: np.ndarray, values: np.ndarray, others: np.ndarray, ) -> None: """按完整相关矩阵的列顺序确定性合并最大相关候选。""" for index, value, other in zip(indices, values, others): index = int(index) other = int(other) if other < 0 or np.isnan(value): continue current_value = max_values[index] current_other = max_indices[index] if np.isnan(current_value) or value > current_value or (value == current_value and (current_other < 0 or other < current_other)): max_values[index] = float(value) max_indices[index] = other def _compute_block_correlations( self, X: pd.DataFrame, sorted_names: List[str], forced_keep_count: int = 0, ): """按 metric 顺序分块筛选,只与已实际保留的特征比较。""" block_size = self._validate_corr_block_size() n_features = len(sorted_names) if n_features == 0: return set(), np.empty(0, dtype=float), np.empty(0, dtype=np.int64) try: values = X.loc[:, sorted_names].to_numpy(dtype=np.float64, copy=True) except (TypeError, ValueError) as exc: raise ValidationError("CorrSelector 相关计算仅支持可转换为数值的特征") from exc fast_matrix = self.method in ("pearson", "spearman") and np.isfinite(values).all() has_inner_thread_limit = isinstance(self.parallel_config, dict) and self.parallel_config.get("inner_max_num_threads") is not None default_backend = "threading" if fast_matrix and not has_inner_thread_limit else "loky" if fast_matrix: if self.method == "spearman": # 无缺失时,Spearman 等价于先按列进行一次平均秩转换,再计算 Pearson; # 排名只做一次,避免每个相关块重复排序。 values = self._rank_corr_values(values, default_backend) values -= values.mean(axis=0) norms = np.sqrt(np.einsum("ij,ij->j", values, values)) with np.errstate(divide="ignore", invalid="ignore"): values /= norms values[:, norms == 0.0] = np.nan blocks = [(start, min(start + block_size, n_features)) for start in range(0, n_features, block_size)] kept = np.zeros(n_features, dtype=bool) drops = set() max_values = np.full(n_features, np.nan, dtype=np.float64) max_indices = np.full(n_features, -1, dtype=np.int64) total_workers = self._resolve_corr_total_workers(n_features) for column_block, (column_start, column_stop) in enumerate(blocks): tasks = [] labels = [] ordinal = 0 for row_start, row_stop in blocks[:column_block]: kept_rows = kept[row_start:row_stop] if not np.any(kept_rows): continue tasks.append( ( ordinal, values, row_start, row_stop, column_start, column_stop, self.threshold, self.method, fast_matrix, kept_rows.copy(), ) ) labels.append(ordinal) ordinal += 1 tasks.append( ( ordinal, values, column_start, column_stop, column_start, column_stop, self.threshold, self.method, fast_matrix, None, ) ) labels.append(ordinal) results = self._execute_corr_tasks( tasks, labels, default_backend, total_workers, ) diagonal = None for expected_ordinal, result in enumerate(results): if not isinstance(result, tuple) or len(result) != 5 or result[0] != expected_ordinal: raise TypeError(f"相关块任务 {expected_ordinal} 返回结果无效") _, result_type, result_start, result_values, result_indices = result if result_type == "diagonal": if result_start != column_start or diagonal is not None: raise TypeError(f"相关块任务 {expected_ordinal} 返回对角块无效") diagonal = result_values continue if result_type != "prior" or result_start != column_start: raise TypeError(f"相关块任务 {expected_ordinal} 返回前置块无效") column_indices = np.arange(column_start, column_stop, dtype=np.int64) self._merge_max_candidates( max_values, max_indices, column_indices, result_values, result_indices, ) width = column_stop - column_start if diagonal is None or diagonal.shape != (width, width): raise TypeError(f"相关块 {column_block} 缺少有效的对角相关矩阵") for local_index, feature_index in enumerate(range(column_start, column_stop)): if local_index: local_kept = kept[column_start:feature_index] if np.any(local_kept): local_values = diagonal[:local_index, local_index] safe = np.where(local_kept & ~np.isnan(local_values), local_values, -np.inf) related_local = int(np.argmax(safe)) related_value = float(safe[related_local]) if np.isfinite(related_value) and related_value > self.threshold: self._merge_max_candidates( max_values, max_indices, np.asarray([feature_index], dtype=np.int64), np.asarray([related_value], dtype=np.float64), np.asarray([column_start + related_local], dtype=np.int64), ) if feature_index < forced_keep_count: max_values[feature_index] = np.nan max_indices[feature_index] = -1 kept[feature_index] = True elif max_indices[feature_index] >= 0: drops.add(feature_index) else: kept[feature_index] = True return drops, max_values, max_indices def _metric_weights_from_binner(self, feature_names: List[str]) -> pd.Series: """从基类管理的同一分箱器中提取特征指标权重。""" metric_key = self.metric.lower() if metric_key not in _METRIC_COL_MAP: raise ValidationError(f"不支持的指标 '{self.metric}',可选: {list(_METRIC_COL_MAP.keys())}") col_name, agg_func = _METRIC_COL_MAP[metric_key] binner = getattr(self, "_binner_instance", None) bin_tables = getattr(binner, "bin_tables_", {}) if binner is not None else {} if not bin_tables: raise ValidationError("CorrSelector 使用指标权重时需要配置 binner 或 binning_params," "也可以显式传入 weights") scores = {} for col in feature_names: if col in bin_tables: bt = bin_tables[col] if col_name in bt.columns: scores[col] = bt[col_name].agg(agg_func) else: scores[col] = 0.0 else: scores[col] = 0.0 return pd.Series(scores) def _should_apply_binner( self, y: Optional[Union[pd.Series, np.ndarray]], ) -> bool: """无目标时仅跳过构造函数提供的默认监督分箱。""" if y is None and self.binner is None and self._uses_default_binning_params: return False return super()._should_apply_binner(y) def _resolve_binner(self) -> Optional[Any]: """创建指标分箱器,并在未显式覆盖时继承 CorrSelector 的并行配置。""" binner = super()._resolve_binner() if binner is None or self.binner is not None: return binner configured = self.binning_params if isinstance(self.binning_params, dict) else {} if "n_jobs" not in configured: binner.n_jobs = self.n_jobs if "parallel_backend" not in configured: # BestIV/MDLP 等包含较多 Python 循环;默认使用进程避免 GIL 退化。 binner.parallel_backend = self.parallel_backend or "loky" if "parallel_config" not in configured: binner.parallel_config = dict(self.parallel_config) if self.parallel_config is not None else None return binner def _included_features_participate_in_selection(self) -> bool: """强制保留字段仍作为相关性比较基准参与分箱和相关计算。""" return True def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合相关性筛选器。 :param X: 输入特征DataFrame :param y: 目标变量 """ self._get_feature_names(X) self._validate_corr_block_size() n_features = X.shape[1] feature_names = X.columns.tolist() # ── 构建权重 ── if self.weights is not None: # 用户显式传入权重 if isinstance(self.weights, pd.Series): weight_series = self.weights.reindex(feature_names).fillna(0.0) elif isinstance(self.weights, dict): weight_series = pd.Series(self.weights).reindex(feature_names).fillna(0.0) else: weight_series = ( pd.Series( np.array(self.weights)[:n_features], index=feature_names[: len(self.weights)], ) .reindex(feature_names) .fillna(0.0) ) elif y is not None or getattr(self, "_binner_instance", None) is not None: # 从基类已训练的同一分箱器中读取指标权重 weight_series = self._metric_weights_from_binner(feature_names) weight_series = weight_series.reindex(feature_names).fillna(0.0) else: # 无 y 且无 weights,使用等权(退化为按列顺序保留) weight_series = pd.Series(np.ones(n_features), index=feature_names) try: weight_arr = weight_series.to_numpy(dtype=np.float64) except (TypeError, ValueError) as exc: raise ValidationError("CorrSelector 的 weights 或 metric 必须为数值") from exc self.feature_scores_ = weight_series.copy() self.scores_ = weight_series.copy() # ── 强制保留优先,其余特征按权重稳定降序排列 ── forced_exclude = set(getattr(self, "exclude_", [])) forced_include = set(getattr(self, "include_", [])).difference(forced_exclude) included_names = [name for name in feature_names if name in forced_include] sort_idx = np.argsort(-weight_arr, kind="stable") sorted_names = included_names + [feature_names[index] for index in sort_idx if feature_names[index] not in forced_include and feature_names[index] not in forced_exclude] forced_keep_count = len(included_names) # ── 分块计算相关性,不再分配完整 p×p 相关矩阵 ── if forced_keep_count == len(sorted_names): drops = set() max_corr_by_index = np.full(len(sorted_names), np.nan, dtype=np.float64) max_corr_feature_index = np.full(len(sorted_names), -1, dtype=np.int64) else: drops, max_corr_by_index, max_corr_feature_index = self._compute_block_correlations( X, sorted_names, forced_keep_count=forced_keep_count, ) # ── 获取保留的特征 ── keep_idx = [idx for idx in range(len(sorted_names)) if idx not in drops] self.selected_features_ = [sorted_names[idx] for idx in keep_idx] # 保存 scores(与原始列顺序一致) self.scores_ = weight_series # ── 构建 dropped_ 报告 ── if len(drops) > 0: ordered_drops = sorted(drops) dropped_cols = [sorted_names[idx] for idx in ordered_drops] max_corr_values = [] max_corr_features = [] metric_values = [] for idx in ordered_drops: col_name = sorted_names[idx] max_corr = max_corr_by_index[idx] related_index = int(max_corr_feature_index[idx]) max_corr_feat = sorted_names[related_index] if related_index >= 0 else None max_corr_values.append(max_corr) max_corr_features.append(max_corr_feat) metric_values.append(weight_series.get(col_name, 0.0)) metric_label = self.metric.upper() if self.weights is None and getattr(self, "_binner_instance", None) is not None else "权重" drop_reasons = [] for index, col_name in enumerate(dropped_cols): related_name = max_corr_features[index] if related_name in forced_include: suffix = "相关特征为强制保留变量" else: suffix = f"{metric_label}({metric_values[index]:.4f})不高于相关特征" drop_reasons.append(f"与{related_name}相关系数({max_corr_values[index]:.4f})>" f"{self.threshold}{suffix}") self.dropped_ = pd.DataFrame( { "特征": dropped_cols, "剔除原因": drop_reasons, "最大相关系数": max_corr_values, "相关特征": max_corr_features, metric_label: metric_values, "阈值": [self.threshold] * len(dropped_cols), } ) else: self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "最大相关系数", "相关特征", "阈值"])