hscredit.core.selectors.boruta_selector 源代码

"""Boruta特征筛选器.

使用Boruta算法进行特征选择。

**参考样例**

>>> from hscredit.core.selectors import BorutaSelector
>>> from sklearn.ensemble import RandomForestClassifier
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.random.randn(200, 10), columns=[f'f{i}' for i in range(10)])  # 10个特征
>>> y = np.random.randint(0, 2, 200)  # 目标变量
>>> selector = BorutaSelector(
...     RandomForestClassifier(n_estimators=50, n_jobs=-1, random_state=42)  # Boruta需传入基模型
... )
>>> 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.base import clone

try:
    from scipy.stats import binomtest as _binomtest

    def _binomial_greater_pvalue(hits: int, iterations: int) -> float:
        return float(_binomtest(hits, n=iterations, p=0.5, alternative="greater").pvalue)

except ImportError:  # scipy 1.5/1.6 兼容路径
    from scipy.stats import binom_test as _legacy_binom_test

    def _binomial_greater_pvalue(hits: int, iterations: int) -> float:
        return float(_legacy_binom_test(hits, n=iterations, p=0.5, alternative="greater"))


from .base import BaseFeatureSelector, get_feature_importances


[文档] class BorutaSelector(BaseFeatureSelector): """Boruta筛选器. Boruta是一种基于随机森林的特征选择算法。 通过创建影子特征(shuffled版本),与原始特征进行比较, 保留统计显著优于影子特征的特征。 **参数** :param estimator: 随机森林评估器 :param n_estimators: 树的数量,默认为100 :param max_iter: 最大迭代次数,默认为100 :param alpha: 多重检验校正前的显著性水平,默认为0.05 :param random_state: 随机种子 :param target: 目标变量列名,默认为'target' :param n_jobs: 并行计算的任务数 **参考样例** :: >>> from hscredit.core.selectors import BorutaSelector >>> from sklearn.ensemble import RandomForestClassifier >>> import pandas as pd >>> import numpy as np >>> np.random.seed(42) >>> X = pd.DataFrame(np.random.randn(200, 10), columns=[f'f{i}' for i in range(10)]) >>> y = np.random.randint(0, 2, 200) >>> selector = BorutaSelector( ... RandomForestClassifier(n_estimators=50, n_jobs=-1, random_state=42) ... ) >>> selector.fit(X, y) >>> print(selector.selected_features_) **注意** Boruta 是 all-relevant(保留所有相关特征)而非 minimal-optimal 的方法,倾向于多保留 特征;计算量为 ``max_iter × n_estimators``,特征数多时较慢。 **引用** Kursa, M. B., & Rudnicki, W. R. (2010). *Feature Selection with the Boruta Package.* Journal of Statistical Software, 36(11). https://doi.org/10.18637/jss.v036.i11 """ method_name = "Boruta筛选" def __init__( self, estimator=None, n_estimators: int = 100, max_iter: int = 100, random_state: Optional[int] = 42, alpha: float = 0.05, 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, 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.estimator = estimator self.n_estimators = n_estimators self.max_iter = max_iter self.alpha = alpha self.random_state = random_state # 默认使用随机森林 def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合Boruta筛选器。 :param X: 输入特征DataFrame :param y: 目标变量 """ self._get_feature_names(X) if y is None: raise ValueError("BorutaSelector 需要目标变量 y") if ( isinstance(self.max_iter, (bool, np.bool_)) or not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1 ): raise ValueError("max_iter 必须是正整数") if ( isinstance(self.n_estimators, (bool, np.bool_)) or not isinstance(self.n_estimators, (int, np.integer)) or int(self.n_estimators) < 1 ): raise ValueError("n_estimators 必须是正整数") if isinstance(self.alpha, (bool, np.bool_)) or not 0 < float(self.alpha) < 1: raise ValueError("alpha 必须在 (0, 1) 范围内") n_samples, n_features = X.shape rng = np.random.RandomState(self.random_state) # 准备数据:编码类别变量并填充缺失值(基模型如随机森林不接受 object/NaN), # 保持与 chi2/mutual_info/f_test 等筛选器对原始信贷数据的鲁棒性一致 X_prepared = X.copy() for col in X_prepared.columns: if X_prepared[col].dtype == "object": X_prepared[col] = pd.factorize(X_prepared[col])[0] if X_prepared.isna().any().any(): X_prepared = X_prepared.fillna(X_prepared.median(numeric_only=True)).fillna(0) X_array = X_prepared.values feature_names = X.columns.tolist() if self.estimator is None: from sklearn.ensemble import RandomForestClassifier base_estimator = RandomForestClassifier( n_estimators=self.n_estimators, random_state=self.random_state, ) else: base_estimator = self.estimator base_estimator = self._clone_estimator_for_parallel(base_estimator) # 每一轮都让全部真实特征与本轮最大影子重要性比较。最终通过命中次数 # 的单侧二项检验作决定,避免早期一次失手导致永久剔除。 hits = np.zeros(n_features, dtype=int) importance_history = np.zeros((int(self.max_iter), n_features), dtype=float) shadow_thresholds = np.zeros(int(self.max_iter), dtype=float) history = [] for iteration in range(int(self.max_iter)): # 各影子列必须独立打乱,不能用同一个行排列保留列间结构。 X_shadow = np.column_stack([rng.permutation(X_array[:, index]) for index in range(n_features)]) X_with_shadow = np.hstack([X_array, X_shadow]) # 训练模型 model = clone(base_estimator) model_params = model.get_params(deep=False) if hasattr(model, "get_params") else {} if "random_state" in model_params: base_random_state = ( self.random_state if self.random_state is not None else int(rng.randint(0, np.iinfo(np.int32).max)) ) model.set_params(random_state=(int(base_random_state) + iteration) % np.iinfo(np.int32).max) with self._estimator_parallel_context(): model.fit(X_with_shadow, y) # 获取特征重要性(兼容所有模型类型) importances = np.asarray(get_feature_importances(model), dtype=float).reshape(-1) if importances.size != 2 * n_features: raise ValueError("Boruta 基模型返回的特征重要性长度与真实特征和影子特征总数不一致: " f"期望 {2 * n_features},实际 {importances.size}") # 分离真实和影子特征重要性 real_importances = importances[:n_features] shadow_importances = importances[n_features:] # 阈值:影子特征的最大重要性 shadow_max = np.max(shadow_importances) if len(shadow_importances) > 0 else 0.0 round_hits = real_importances > shadow_max hits += round_hits.astype(int) importance_history[iteration] = real_importances shadow_thresholds[iteration] = shadow_max # 记录历史 history.append( { "iteration": iteration, "selected": int(round_hits.sum()), "shadow_max": float(shadow_max), "hits": hits.copy(), } ) p_values = np.array( [_binomial_greater_pvalue(int(hit), int(self.max_iter)) for hit in hits], dtype=float, ) corrected_alpha = float(self.alpha) / max(1, n_features) support = p_values <= corrected_alpha weak_support = (~support) & (hits > int(self.max_iter) / 2) self.hits_ = pd.Series(hits, index=feature_names, dtype=int) self.p_values_ = pd.Series(p_values, index=feature_names) self.support_ = pd.Series(support, index=feature_names) self.support_weak_ = pd.Series(weak_support, index=feature_names) self.decision_ = pd.Series( np.where(support, "confirmed", np.where(weak_support, "tentative", "rejected")), index=feature_names, ) self.history_ = history self.importance_history_ = pd.DataFrame(importance_history, columns=feature_names) self.shadow_thresholds_ = pd.Series(shadow_thresholds, name="最大影子重要性") self.importance_scores_ = self.importance_history_.mean(axis=0) self.scores_ = self.hits_.astype(float) / int(self.max_iter) self.selected_features_ = [feature for feature in feature_names if bool(self.support_[feature])] self.tentative_features_ = [feature for feature in feature_names if bool(self.support_weak_[feature])] self._drop_reason = "Boruta 二项检验未显著优于影子特征"