hscredit.core.selectors.sequential_selector 源代码

"""逐步特征筛选器.

使用前向逐步选择或后向逐步消除搜索最优特征子集。
前向选择从空集开始逐步添加最有价值的特征;
后向消除从全特征集开始逐步剔除最无价值的特征。
基于 sklearn.feature_selection.SequentialFeatureSelector 实现。

**参考样例**

>>> from hscredit.core.selectors import SequentialFeatureSelector
>>> 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 = SequentialFeatureSelector(
...     RandomForestClassifier(n_estimators=50, random_state=42),
...     n_features_to_select=5,  # 选择5个最优特征
...     direction='forward',    # 前向选择(从空集开始逐步加入)
...     cv=3                     # 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.base import clone, is_classifier
from sklearn.model_selection import check_cv, cross_val_score

from .base import BaseFeatureSelector, _set_estimator_parallel_budget
from ._selection_history import event_bytes, initialize_history, record_event
from ...utils.parallel import ParallelWorkload, _current_parallel_budget


def _evaluate_sequential_candidate(task):
    """评估当前轮的一个候选子集。"""
    ordinal, candidate, estimator, X, y, selected, direction, scoring, cv = task
    if direction == "forward":
        features = selected + [candidate]
    else:
        features = [feature for feature in selected if feature != candidate]
    model = clone(estimator)
    _set_estimator_parallel_budget(model, _current_parallel_budget().available)
    try:
        fold_scores = np.asarray(
            cross_val_score(
                model,
                X[features],
                y,
                scoring=scoring,
                cv=cv,
                n_jobs=1,
                error_score=np.nan,
            ),
            dtype=float,
        )
        valid = bool(fold_scores.size and np.isfinite(fold_scores).all())
        score = float(fold_scores.mean()) if valid else np.nan
        reason = "" if valid else "至少一折评分非有限值,候选不可参与择优"
        return ordinal, candidate, score, fold_scores.tolist(), reason
    except Exception as exc:
        return ordinal, candidate, np.nan, [], f"候选交叉验证失败:{type(exc).__name__}: {str(exc)[:500]}"


[文档] class SequentialFeatureSelector(BaseFeatureSelector): """逐步特征筛选器. 使用前向或后向逐步选择选择最优特征子集。 前向选择:从空集开始,逐步添加最有价值的特征 后向消除:从所有特征开始,逐步剔除最无价值的特征 **参数** :param estimator: 评估器 :param n_features_to_select: 保留的特征数,默认为'auto' - 'auto': 保留一半特征 - 整数: 保留的特征数量 - 浮点数: 保留的特征比例 数量预算包含 include;强制保留字段始终作为候选模型的条件变量。 :param direction: 方向,默认为'forward' - 'forward': 前向选择 - 'backward': 后向消除 :param scoring: 评分指标,默认为None :param cv: 交叉验证折数,默认为5 :param target: 目标变量列名,默认为'target' **参考样例** :: >>> from hscredit.core.selectors import SequentialFeatureSelector >>> 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 = SequentialFeatureSelector( ... RandomForestClassifier(n_estimators=50, random_state=42), ... n_features_to_select=5, ... direction='forward', ... cv=3 ... ) >>> selector.fit(X, y) >>> print(selector.selected_features_) **注意** 与 :class:`RFESelector` 不同,本类基于交叉验证评分而非模型权重逐个增删特征,更稳健但 更耗时(约 ``n_features × cv`` 次拟合);与 :class:`StepwiseSelector`(基于 AIC/BIC/KS 等 统计准则、面向逻辑回归)适用场景亦不同。 **引用** 对齐 sklearn ``SequentialFeatureSelector``: https://scikit-learn.org/stable/modules/generated/sklearn.feature_selection.SequentialFeatureSelector.html """ method_name = "逐步筛选" def __init__( self, estimator, n_features_to_select: Union[int, float, str] = "auto", direction: str = "forward", scoring: Optional[str] = None, cv: int = 5, 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, 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=n_features_to_select, 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.estimator = estimator self.n_features_to_select = n_features_to_select self.direction = direction self.scoring = scoring self.cv = cv 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 _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) self._get_feature_names(X) initialize_history(self) n_features = X.shape[1] if self.n_features_to_select == "auto": n_to_select = max(1, n_features // 2) elif isinstance(self.n_features_to_select, (bool, np.bool_)): raise ValueError("n_features_to_select 不能是布尔值") elif isinstance(self.n_features_to_select, (float, np.floating)): if not 0 < self.n_features_to_select <= 1: raise ValueError("n_features_to_select 为浮点数时必须在 (0, 1] 范围内") n_to_select = max(1, int(n_features * self.n_features_to_select)) elif isinstance(self.n_features_to_select, (int, np.integer)): n_to_select = int(self.n_features_to_select) else: raise ValueError("n_features_to_select 必须为 'auto'、正整数或 (0, 1] 比例") if not 0 < n_to_select <= n_features: raise ValueError("n_features_to_select 必须在有效特征数量范围内") if self.direction not in ("forward", "backward"): raise ValueError("direction 必须为 'forward' 或 'backward'") forced = [feature for feature in X.columns if feature in self.include_] if len(forced) > n_to_select: raise ValueError("include 特征数不能超过 n_features_to_select 总预算") fixed_budget = len(forced) == n_to_select selected = list(forced) if self.direction == "forward" or fixed_budget else X.columns.tolist() needs_search = len(selected) != n_to_select cv = check_cv(self.cv, y, classifier=is_classifier(self.estimator)) # 固定同一组拆分,避免随机 splitter 每个候选重新抽样而失去可比性。 splits = list(cv.split(X, y)) if needs_search else [] if needs_search and not splits: raise ValueError("交叉验证至少需要一个有效拆分") self.n_cv_splits_ = len(splits) self.selection_reasons_ = pd.Series(index=X.columns, dtype=object) if not needs_search: self.selection_input_features_ = [] for feature in X.columns: self.selection_reasons_[feature] = ( "强制保留" if feature in forced else ( "总数量预算已由强制保留字段占满,未执行交叉验证搜索" if fixed_budget else "总数量预算保留全部字段,无需交叉验证搜索" ) ) self.selection_history_ = [] self.selection_history_bytes_ = 0 self.selection_history_truncated_ = 0 self.candidate_scores_ = pd.Series(np.nan, index=X.columns, dtype=float) self.candidate_failures_ = 0 self.score_name_ = "加入该特征后子集CV评分" if self.direction == "forward" else "移除该特征后子集CV评分" self.score_direction_ = "候选子集评分越大越好" self.effective_threshold_ = n_to_select iteration = 0 while len(selected) < n_to_select if self.direction == "forward" else len(selected) > n_to_select: candidates = ( [feature for feature in X.columns if feature not in selected] if self.direction == "forward" else [feature for feature in selected if feature not in forced] ) tasks = [ ( ordinal, candidate, self.estimator, X, np.asarray(y), list(selected), self.direction, self.scoring, splits, ) for ordinal, candidate in enumerate(candidates) ] results = self._parallel_execute( _evaluate_sequential_candidate, tasks, task_labels=candidates, has_parallel_children=True, default_backend="loky", workload=ParallelWorkload( task_count=len(tasks), rows=len(X), columns=max(1, len(selected) + 1), data_bytes=int(X.memory_usage(deep=True).sum()), cost_per_item=max(10.0, float(len(splits)) * 10.0), capability="process_safe", has_parallel_children=True, operation="逐步筛选候选交叉验证", ), ) iteration += 1 valid_results = [] for result in results: _, feature, score, folds, reason = result valid = bool(np.isfinite(score)) self.candidate_scores_[feature] = score self.candidate_failures_ += int(not valid) event = { "轮次": iteration, "特征": feature, "动作": "候选评估", "指标名称": self.score_name_, "指标值": score, "有效阈值": None, "是否有效": valid, "原因": reason, "补充信息": {"总保留数量预算": n_to_select}, } if self.report_history == "full": subset = ( selected + [feature] if self.direction == "forward" else [name for name in selected if name != feature] ) event["补充信息"].update({"候选子集": subset, "折评分": folds}) record_event(self, event, diagnostic=valid) if valid: valid_results.append(result) if not valid_results: reasons = "; ".join(result[4] for result in results[:3]) raise ValueError(f"第 {iteration} 轮没有有效的交叉验证候选:{reasons}") # 有序候选和严格比较保留输入顺序平局规则,NaN 不参与择优。 _, best_feature, best_score, _, _ = max(valid_results, key=lambda item: item[2]) if self.direction == "forward": selected.append(best_feature) action = "add" else: selected.remove(best_feature) action = "remove" history_item = {"轮次": iteration, "动作": action, "特征": best_feature, "得分": best_score} history_bytes = event_bytes(history_item, self.max_report_bytes) if ( len(self.selection_history_) < self.max_report_events and self.selection_history_bytes_ + history_bytes <= self.max_report_bytes ): self.selection_history_.append(history_item) self.selection_history_bytes_ += history_bytes else: self.selection_history_truncated_ += 1 record_event( self, { "轮次": iteration, "特征": best_feature, "动作": "加入" if self.direction == "forward" else "剔除", "指标名称": self.score_name_, "指标值": best_score, "有效阈值": None, "是否有效": True, "原因": "本轮有效候选中评分最高", "补充信息": {"总保留数量预算": n_to_select}, }, ) selected_mask = X.columns.isin(selected) self.selected_features_ = X.columns[selected_mask].tolist() self.scores_ = self.candidate_scores_.copy() self.selection_stopping_reason_ = "达到包含强制保留字段的总数量预算" self.n_iterations_ = iteration self._drop_reason = "未进入交叉验证搜索的最终特征子集"