hscredit.core.selectors.null_importance_selector 源代码

"""零重要性筛选器(Null Importance).

使用实际重要性与随机目标下的 null 重要性差值识别真正有价值的特征。

**参考样例**

>>> from hscredit.core.selectors import NullImportanceSelector
>>> from sklearn.ensemble import RandomForestClassifier
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.random.randn(200, 5), columns=[f'f{i}' for i in range(5)])  # 5个特征
>>> y = np.random.randint(0, 2, 200)  # 目标变量
>>> selector = NullImportanceSelector(
...     RandomForestClassifier(n_estimators=50, random_state=42),  # 传入基模型
...     threshold=0.0,  # 实际重要性-null重要性>0才保留
...     cv=3, n_runs=3  # 交叉验证次数
... )
>>> 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.model_selection import check_cv
from sklearn.base import clone
from sklearn.utils import check_random_state

from .base import BaseFeatureSelector, _set_estimator_parallel_budget, get_feature_importances
from ...utils.parallel import ParallelWorkload, _current_parallel_budget


def _fit_importance_model(estimator, X, y):
    """克隆并拟合单个重要性模型,遵守当前子预算。"""
    model = clone(estimator)
    model = _set_estimator_parallel_budget(model, _current_parallel_budget().available)
    model.fit(X, y)
    return get_feature_importances(model)


def _run_null_importance_experiment(task):
    """执行一个独立的实际/null 重要性实验。"""
    ordinal, seed, estimator, X, y, cv_spec = task
    rng = check_random_state(seed)
    order = rng.permutation(len(X))
    X_ordered = X.iloc[order].reset_index(drop=True)
    y_ordered = y[order]
    cv = check_cv(cv_spec, y_ordered, classifier=True)
    n_splits = cv.get_n_splits()
    actual = np.zeros((X.shape[1], n_splits))
    null = np.zeros((X.shape[1], n_splits))

    for fold_idx, (train_idx, _) in enumerate(cv.split(X_ordered, y_ordered)):
        actual[:, fold_idx] = _fit_importance_model(
            estimator,
            X_ordered.iloc[train_idx],
            y_ordered[train_idx],
        )

    y_null = rng.permutation(y_ordered)
    cv_null = check_cv(cv_spec, y_null, classifier=True)
    for fold_idx, (train_idx, _) in enumerate(cv_null.split(X_ordered, y_null)):
        null[:, fold_idx] = _fit_importance_model(
            estimator,
            X_ordered.iloc[train_idx],
            y_null[train_idx],
        )

    return ordinal, actual, null


[文档] class NullImportanceSelector(BaseFeatureSelector): """零重要性筛选器. 使用 null importance 识别真正有价值的特征。 通过多次 shuffle 目标变量得到随机情况下的 null 重要性, 再用实际重要性减去 null 重要性作为特征得分。 **参数** :param estimator: 评估器 :param threshold: 阈值,默认为0.0 - 保留 ``实际重要性 - null重要性 > threshold`` 的特征 :param cv: 交叉验证折数,默认为5 :param n_runs: 置换次数,默认为5 :param random_state: 随机种子 :param target: 目标变量列名,默认为'target' **参考样例** :: >>> from hscredit.core.selectors import NullImportanceSelector >>> from sklearn.ensemble import RandomForestClassifier >>> import pandas as pd >>> import numpy as np >>> np.random.seed(42) >>> X = pd.DataFrame(np.random.randn(200, 5), columns=[f'f{i}' for i in range(5)]) >>> y = np.random.randint(0, 2, 200) >>> selector = NullImportanceSelector( ... RandomForestClassifier(n_estimators=50, random_state=42), ... threshold=0.0, cv=3, n_runs=3 ... ) >>> selector.fit(X, y) >>> print(selector.selected_features_) **注意** 本方法通过多次打乱**目标变量**得到"零假设"下的重要性分布(null importances), 再以 ``实际重要性 - null重要性`` 判断特征是否显著优于随机,能有效剔除高基数/噪声特征的 虚高重要性。计算量为 ``n_runs × cv`` 次模型训练。 **引用** Altmann, A. et al. (2010). *Permutation importance: a corrected feature importance measure.* Bioinformatics, 26(10). https://doi.org/10.1093/bioinformatics/btq134 """ method_name = "零重要性筛选" def __init__( self, estimator, threshold: float = 0.0, cv: int = 5, n_runs: int = 5, random_state: Optional[int] = 42, 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, 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.estimator = estimator self.cv = cv self.n_runs = n_runs self.random_state = random_state def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合零重要性筛选器。 :param X: 输入特征DataFrame :param y: 目标变量 """ 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) # 确保 y 是 numpy 数组(base.fit 传入的可能是 Series,索引不连续会导致 y[idx] KeyError) if isinstance(y, pd.Series): y = y.values else: y = np.asarray(y) # 重置 DataFrame 索引以确保 iloc 与 positional index 一致 X = X.reset_index(drop=True) self._get_feature_names(X) cv = check_cv(self.cv, y, classifier=True) n_samples, n_features = X.shape n_splits = cv.get_n_splits() # 计算实际标签下的重要性和 shuffle 目标后的 null 重要性。 actual_importances = np.zeros((n_features, n_splits * self.n_runs)) null_importances = np.zeros((n_features, n_splits * self.n_runs)) if self.random_state is None: base_seed = int(check_random_state(None).randint(0, np.iinfo(np.int32).max)) else: base_seed = int(self.random_state) max_seed = np.iinfo(np.int32).max tasks = [(run, (base_seed + run) % max_seed, self.estimator, X, y, self.cv) for run in range(self.n_runs)] estimator_params = self.estimator.get_params(deep=True) if hasattr(self.estimator, "get_params") else {} worker_aliases = {"n_jobs", "thread_count", "num_workers"} has_parallel_children = any( name.rsplit("__", 1)[-1] in worker_aliases and isinstance(value, (int, np.integer)) and value not in (0, 1) for name, value in estimator_params.items() ) cv_cost = float(self.cv) if isinstance(self.cv, (int, np.integer)) else 5.0 results = self._parallel_execute( _run_null_importance_experiment, tasks, task_labels=[f"实验{run + 1}" for run in range(self.n_runs)], has_parallel_children=has_parallel_children, default_backend="loky", workload=ParallelWorkload( task_count=self.n_runs, rows=len(X), columns=X.shape[1], data_bytes=int(X.memory_usage(deep=True).sum()), cost_per_item=max(10.0, cv_cost * 10.0), capability="process_safe", has_parallel_children=has_parallel_children, operation="Null Importance重复实验", ), ) for run, actual, null in results: start = n_splits * run stop = start + n_splits actual_importances[:, start:stop] = actual null_importances[:, start:stop] = null actual_mean = actual_importances.mean(axis=1) null_mean = null_importances.mean(axis=1) scores = actual_mean - null_mean self.actual_importances_ = pd.Series(actual_mean, index=X.columns) self.null_importances_ = pd.Series(null_mean, index=X.columns) self.scores_ = pd.Series(scores, index=X.columns) self.actual_importance_runs_ = pd.DataFrame(actual_importances.T, columns=X.columns) self.null_importance_runs_ = pd.DataFrame(null_importances.T, columns=X.columns) self.importance_details_ = pd.DataFrame( { "特征": X.columns, "实际重要性": actual_mean, "Null重要性": null_mean, "特征得分": scores, } ) # 筛选 selected_mask = scores > self.threshold self.selected_features_ = X.columns[selected_mask].tolist() self._drop_reason = f"实际重要性-Null重要性 <= {self.threshold}" dropped_cols = X.columns[~selected_mask].tolist() if len(dropped_cols) > 0: details = self.importance_details_.set_index("特征") self.dropped_ = pd.DataFrame( { "特征": dropped_cols, "剔除原因": [self._drop_reason] * len(dropped_cols), "实际重要性": [details.loc[col, "实际重要性"] for col in dropped_cols], "Null重要性": [details.loc[col, "Null重要性"] for col in dropped_cols], "特征得分": [details.loc[col, "特征得分"] for col in dropped_cols], "阈值": [self.threshold] * len(dropped_cols), } ) else: self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "实际重要性", "Null重要性", "特征得分", "阈值"])
[文档] def get_importance_details(self) -> pd.DataFrame: """获取实际重要性、Null重要性和差值得分明细。 :returns: 包含 ``特征``、``实际重要性``、``Null重要性``、``特征得分`` 的 DataFrame """ if not hasattr(self, "importance_details_"): return pd.DataFrame(columns=["特征", "实际重要性", "Null重要性", "特征得分"]) return self.importance_details_.copy()