hscredit.core.selectors.vif_selector 源代码

"""VIF筛选器.

使用方差膨胀因子(VIF)检测和移除多重共线性特征。

**参考样例**

>>> from hscredit.core.selectors import VIFSelector
>>> import pandas as pd
>>> X = pd.DataFrame({
...     'a': [1, 2, 3, 4, 5],
...     'b': [1, 2, 3, 4, 5],  # 与a完全线性相关,VIF会很高
...     'c': [5, 4, 3, 2, 1]
... })
>>> selector = VIFSelector(threshold=4.0)  # VIF>4表示存在多重共线性
>>> selector.fit(X)
>>> print(selector.selected_features_)
['a', 'c']
"""

import logging
from typing import Union, List, Optional, Dict, Any
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

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

logger = logging.getLogger(__name__)


def _compute_vif_single(x: np.ndarray, idx: int) -> float:
    """计算单个特征的VIF值。

    :param x: 特征矩阵
    :param idx: 特征索引
    :return: VIF值
    """
    n_features = x.shape[1]
    if n_features <= 1:
        return 1.0  # 只有一个特征时,VIF=1

    # 获取其他特征
    mask = np.ones(n_features, dtype=bool)
    mask[idx] = False
    x_other = x[:, mask]
    x_target = x[:, idx]

    # 处理缺失值
    valid = ~(np.isnan(x_target) | np.any(np.isnan(x_other), axis=1))
    if valid.sum() < 2:
        return np.inf
    x_other = x_other[valid]
    x_target = x_target[valid]

    # 检查目标特征是否为常数
    if np.std(x_target) < 1e-10:
        return 1.0  # 常数特征VIF=1

    # 检查其他特征是否全为常数
    if np.all(np.std(x_other, axis=0) < 1e-10):
        return 1.0  # 其他特征都是常数,无法预测目标特征

    # 线性回归
    try:
        lr = LinearRegression(fit_intercept=True)
        lr.fit(x_other, x_target)
        y_pred = lr.predict(x_other)

        # 计算VIF
        ss_res = np.sum((x_target - y_pred) ** 2)
        ss_tot = np.sum((x_target - np.mean(x_target)) ** 2)

        if ss_tot < 1e-10:
            return 1.0

        r2 = 1 - ss_res / ss_tot

        # 处理r2接近1或大于1的情况
        if r2 >= 1.0:
            return np.inf
        elif r2 < 0:
            # r2 < 0 表示模型比均值还差,VIF很小
            return 1.0
        else:
            vif = 1 / (1 - r2)
            return vif
    except Exception:
        return np.inf


def _compute_vif_feature(task):
    """计算单个特征 VIF 并携带列位置返回。"""
    idx, x = task
    return idx, _compute_vif_single(x, idx)


[文档] class VIFSelector(BaseFeatureSelector): """VIF筛选器. 使用方差膨胀因子(VIF)检测多重共线性。 VIF值越高,表示特征与其他特征的多重共线性越严重。 在金融风控中,通常认为VIF > 4存在多重共线性问题。 **算法说明:** 采用迭代剔除策略: 1. 计算所有特征的VIF值 2. 如果最大VIF > threshold,剔除VIF最大的特征 3. 重新计算剩余特征的VIF 4. 重复步骤2-3,直到所有VIF <= threshold 这种方法避免了"一刀切"地剔除所有高VIF特征的问题。 **参数** :param threshold: VIF阈值,默认为4.0 - 4.0: 移除VIF值超过4的特征 - 范围: 正数 :param missing: 缺失值填充值,默认为-1 :param max_iter: 最大迭代次数,默认为100 :param n_jobs: 并行计算的任务数 :param verbose: 是否显示详细过程,默认为False **参考样例** >>> from hscredit.core.selectors import VIFSelector >>> import pandas as pd >>> X = pd.DataFrame({ ... 'a': [1, 2, 3, 4, 5], ... 'b': [1, 2, 3, 4, 5], # 与a完全相关 ... 'c': [5, 4, 3, 2, 1] ... }) >>> selector = VIFSelector(threshold=4.0) >>> selector.fit(X) >>> print(selector.selected_features_) ['a', 'c'] # 或 ['b', 'c'],保留其中一个高相关特征 **引用** 方差膨胀因子 ``VIF_i = 1 / (1 - R_i²)``(``R_i²`` 为特征 i 对其余特征回归的判定系数), 经验上 VIF>5(严格 >10)提示多重共线性。参见 Kutner, M. et al. (2004). *Applied Linear Statistical Models*;https://en.wikipedia.org/wiki/Variance_inflation_factor """ method_name = "VIF筛选" def __init__( self, threshold: float = 4.0, missing: float = -1.0, max_iter: int = 100, 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, verbose: bool = False, 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.missing = missing self.max_iter = max_iter self.verbose = verbose def _included_features_participate_in_selection(self) -> bool: """强制保留字段参与共线性计算,但不会成为迭代剔除对象。""" return True def _compute_vif_all(self, X: pd.DataFrame) -> pd.Series: """计算所有特征的VIF值。 :param X: 特征DataFrame :return: VIF值Series """ x_filled = X.fillna(self.missing).values n_features = x_filled.shape[1] if n_features == 0: return pd.Series(dtype=float) results = self._parallel_execute( _compute_vif_feature, ((i, x_filled) for i in range(n_features)), task_labels=X.columns, default_backend="loky", workload=ParallelWorkload( task_count=n_features, rows=X.shape[0], columns=n_features, data_bytes=int(x_filled.nbytes), cost_per_item=max(10.0, float(n_features)), capability="process_safe", operation="VIF回归计算", ), ) vif_values = np.array([value for _, value in results]) return pd.Series(vif_values, index=X.columns) def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合VIF筛选器。 采用迭代剔除策略,每次只剔除VIF最大的特征。 :param X: 输入特征DataFrame :param y: 目标变量(此筛选器不需要) """ self._get_feature_names(X) # 保留的特征列表 remaining_features = X.columns.tolist() forced_include = set(self.include_).intersection(remaining_features) # 被剔除的特征及原因 dropped_features = [] dropped_reasons = [] # 记录每次迭代的VIF值 vif_history = [] # 迭代剔除 for iteration in range(self.max_iter): if len(remaining_features) == 0: break # 计算当前所有特征的VIF X_current = X[remaining_features] vif_series = self._compute_vif_all(X_current) vif_history.append(vif_series.copy()) # include 字段需要参与 VIF 回归,但不能被迭代剔除;只在其余 # 候选字段中寻找本轮最大 VIF。 removable_vif = vif_series.drop(labels=[feature for feature in forced_include if feature in vif_series.index]) if removable_vif.empty: break max_vif = removable_vif.max() max_feature = removable_vif.idxmax() if self.verbose: logger.info(f"迭代 {iteration + 1}: 最大VIF = {max_vif:.4f} (特征: {max_feature})") # 如果最大VIF <= threshold,停止 if max_vif <= self.threshold: if self.verbose: logger.info(f"所有特征VIF <= {self.threshold},停止迭代") break # 剔除VIF最大的特征 remaining_features.remove(max_feature) dropped_features.append(max_feature) dropped_reasons.append(f"VIF={max_vif:.4f} (第{iteration + 1}轮剔除)") if self.verbose: logger.info(f" 剔除特征: {max_feature}") # 保存结果 self.selected_features_ = remaining_features self.removed_features_ = dropped_features # 保存最终的VIF值作为scores_ if len(remaining_features) > 0: # 优先使用最后一次迭代计算的VIF值(避免重复计算) if vif_history and set(vif_history[-1].index) == set(remaining_features): self.scores_ = vif_history[-1] else: self.scores_ = self._compute_vif_all(X[remaining_features]) else: self.scores_ = pd.Series(dtype=float) # 记录剔除历史 self.vif_history_ = vif_history self.n_iterations_ = len(vif_history) # 构建dropped_ DataFrame(提取VIF数值) if len(dropped_features) > 0: # 从原因字符串中提取VIF数值 import re vif_values = [] for reason in dropped_reasons: match = re.search(r"VIF=([\d.]+)", reason) vif_values.append(float(match.group(1)) if match else np.nan) self.dropped_ = pd.DataFrame( { "特征": dropped_features, "剔除原因": dropped_reasons, "VIF值": vif_values, "阈值": [self.threshold] * len(dropped_features), } ) else: self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "VIF值", "阈值"]) self._drop_reason = f"VIF值 > {self.threshold}" if self.verbose: logger.info(f"\nVIF筛选完成:") logger.info(f" 迭代次数: {self.n_iterations_}") logger.info(f" 保留特征: {len(self.selected_features_)}") logger.info(f" 剔除特征: {len(self.removed_features_)}") if len(self.selected_features_) > 0: logger.info(f" 最终最大VIF: {self.scores_.max():.4f}")