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 scipy.linalg import solve_triangular
from threadpoolctl import threadpool_limits

from .base import BaseFeatureSelector
from ._selection_history import initialize_history, record_event
from ...utils.parallel import ParallelWorkload, resolve_native_workers

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)


def _compute_vif_bulk(task):
    """一次带截距的共享QR;数值边界不可靠时返回None,沿用逐列OLS。

    不对原始列缩放,以免改变sklearn对秩和微小变化量的判断。
    额外工作区为中心化矩阵O(np)和三角矩阵O(p²),不是p份回归输入。
    """
    x, native_threads = task
    n_features = x.shape[1]
    if n_features <= 1:
        return np.ones(n_features, dtype=float)
    try:
        values = np.asarray(x, dtype=float)
    except (TypeError, ValueError):
        return None
    values = values[~np.isnan(values).any(axis=1)]
    if len(values) < 2:
        return np.full(n_features, np.inf)
    if not np.isfinite(values).all():
        return None
    with threadpool_limits(limits=resolve_native_workers(-1, native_threads)):
        centered = values - values.mean(axis=0)
        ss_tot = np.einsum("ij,ij->j", centered, centered)
        deviations = np.std(values, axis=0)
        nonzero = np.flatnonzero(ss_tot > 0)
        output = np.ones(n_features, dtype=float)
        if len(nonzero) <= 1:
            return output
        if np.any(np.abs(values.mean(axis=0)[nonzero]) > 1e8 * deviations[nonzero]):
            return None
        if len(values) <= len(nonzero):
            return None
        try:
            triangular = np.linalg.qr(centered[:, nonzero], mode="r")
            singular = np.linalg.svd(triangular, compute_uv=False)
            if singular[-1] <= 0 or singular[0] / singular[-1] > 1e7:
                return None
            inverse = solve_triangular(triangular, np.eye(len(nonzero)), lower=False)
            vif = ss_tot[nonzero] * np.einsum("ij,ij->i", inverse, inverse)
            # 在R²舍入敏感区域保留旧OLS数值与平局剔除顺序。
            if not np.isfinite(vif).all() or np.any(vif > 1e8):
                return None
            ratio = 1.0 / np.maximum(vif, 1.0)
            output[nonzero] = 1.0 / (1.0 - (1.0 - ratio))
            constant_targets = (deviations < 1e-10) | (ss_tot < 1e-10)
            other_all_constant = (np.sum(deviations >= 1e-10) - (deviations >= 1e-10)) == 0
            output[constant_targets | other_all_constant] = 1.0
            return output
        except np.linalg.LinAlgError:
            return None


[文档] 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, target_rm: bool = False, report_history: str = "summary", max_report_events: int = 10000, max_report_bytes: int = 8 * 1024 * 1024, ): 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, target_rm=target_rm, ) self.missing = missing self.max_iter = max_iter self.verbose = verbose self.report_history = report_history self.max_report_events = max_report_events self.max_report_bytes = max_report_bytes 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) # QR/OLS都使用浮点工作区,不能用int8/float32输入的nbytes低估副本。 working_matrix_bytes = X.shape[0] * n_features * np.dtype(np.float64).itemsize bulk = self._parallel_execute( _compute_vif_bulk, [(x_filled, resolve_native_workers(self.n_jobs))], task_labels=["共享QR"], default_backend="threading", workload=ParallelWorkload( task_count=1, rows=X.shape[0], columns=n_features, data_bytes=int(x_filled.nbytes), capability="vectorized", releases_gil=True, cost_per_item=max(10.0, float(n_features)), operation="VIF共享QR计算", working_bytes_per_task=int(5 * working_matrix_bytes + 4 * n_features * n_features * 8), result_bytes_per_task=n_features * 8, output_rows_per_task=n_features, ), )[0] if bulk is not None: self.vif_last_solver_ = "共享QR" eligible = np.array([name not in set(getattr(self, "include_", [])) for name in X.columns]) if eligible.any(): maximum = np.max(bulk[eligible]) tied = np.flatnonzero(eligible & np.isclose(bulk, maximum, rtol=1e-12, atol=1e-12)) if maximum > self.threshold and len(tied) > 1: # VIF对两变量对称,但浮点残差会影响idxmax的旧剔除顺序。 # 只复核真正可能被剔除的平局候选,不重复全部p个OLS。 with threadpool_limits(limits=resolve_native_workers(self.n_jobs)): for index in tied: bulk[index] = _compute_vif_single(x_filled, int(index)) self.vif_last_solver_ = "共享QR(平局OLS复核)" return pd.Series(bulk, index=X.columns) self.vif_last_solver_ = "逐列OLS回退" 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回归计算", working_bytes_per_task=int(4 * working_matrix_bytes + X.shape[0] * 24), result_bytes_per_task=16, output_rows_per_task=1, ), ) 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) initialize_history(self) if ( isinstance(self.max_iter, (bool, np.bool_)) or not isinstance(self.max_iter, (int, np.integer)) or self.max_iter < 1 ): raise ValueError("max_iter 必须为正整数") if ( isinstance(self.threshold, (bool, np.bool_)) or not isinstance(self.threshold, (int, float, np.number)) or not np.isfinite(self.threshold) or self.threshold <= 0 ): raise ValueError("VIF 阈值必须为有限正数") self.score_name_ = "VIF值" self.score_direction_ = "越小越好" self.effective_threshold_ = self.threshold self.selection_stopping_reason_ = "达到最大迭代次数" self.decision_vif_ = pd.Series(np.nan, index=X.columns, dtype=float) self.elimination_round_ = pd.Series(0, index=X.columns, dtype=int) # 保留的特征列表 remaining_features = X.columns.tolist() forced_include = set(self.include_).intersection(remaining_features) # 被剔除的特征及原因 dropped_features = [] dropped_reasons = [] dropped_values = [] # 记录每次迭代的VIF值 vif_history = [] stored_cells = 0 stored_bytes = 0 iteration_count = 0 last_vif = None # 迭代剔除 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) iteration_count += 1 last_vif = vif_series series_bytes = int(vif_series.memory_usage(index=True, deep=True)) + 256 if ( stored_cells + len(vif_series) <= self.max_report_events and stored_bytes + series_bytes <= self.max_report_bytes ): vif_history.append(vif_series.copy()) stored_cells += len(vif_series) stored_bytes += series_bytes for feature, value in vif_series.items(): record_event( self, { "轮次": iteration + 1, "特征": feature, "动作": "候选评估", "指标名称": "VIF值", "指标值": value, "有效阈值": self.threshold, "是否有效": not np.isnan(value), "原因": "当前条件变量集合的多重共线性", }, diagnostic=True, ) # 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: self.selection_stopping_reason_ = "仅剩强制保留字段" 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: self.selection_stopping_reason_ = "所有可剔除字段满足VIF阈值" 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}轮剔除)") dropped_values.append(float(max_vif)) self.decision_vif_[max_feature] = max_vif self.elimination_round_[max_feature] = iteration + 1 record_event( self, { "轮次": iteration + 1, "特征": max_feature, "动作": "剔除", "指标名称": "VIF值", "指标值": float(max_vif), "有效阈值": self.threshold, "是否有效": not np.isnan(max_vif), "原因": "本轮可剔除字段中VIF最大且超过阈值", }, ) 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 last_vif is not None and list(last_vif.index) == remaining_features: self.scores_ = last_vif 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_ = iteration_count self.vif_history_truncated_ = iteration_count - len(vif_history) self.vif_history_bytes_ = stored_bytes self.decision_vif_.loc[self.scores_.index] = self.scores_ self.unresolved_features_ = self.scores_.index[self.scores_ > self.threshold].tolist() self.converged_ = not any(name not in forced_include for name in self.unresolved_features_) for feature, value in self.scores_.items(): record_event( self, { "轮次": iteration_count, "特征": feature, "动作": "保留", "指标名称": "VIF值", "指标值": value, "有效阈值": self.threshold, "是否有效": not np.isnan(value), "原因": ( "强制保留" if feature in forced_include else ("满足VIF阈值" if value <= self.threshold else "迭代上限结束,仍超过阈值") ), }, ) # 构建dropped_ DataFrame(提取VIF数值) if len(dropped_features) > 0: self.dropped_ = pd.DataFrame( { "特征": dropped_features, "剔除原因": dropped_reasons, "VIF值": dropped_values, "决策轮次": [int(self.elimination_round_[name]) for name in dropped_features], "阈值": [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}")