hscredit.core.selectors.chi2_selector 源代码

"""卡方筛选器.

使用卡方检验(Chi-Squared Test)评估特征与目标变量的独立性,
筛选出与目标显著相关的特征。适用于分类问题,需要非负特征值。
基于 sklearn.feature_selection.chi2 实现。

**参考样例**

>>> from hscredit.core.selectors import Chi2Selector
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.abs(np.random.randn(1000, 5)), columns=[f'f{i}' for i in range(5)])  # 非负特征(chi2要求)
>>> y = pd.Series(np.random.randint(0, 2, 1000))  # 目标变量
>>> selector = Chi2Selector(k=3)  # 选择得分最高的前3个特征
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
"""

from typing import Union, List, Optional, Dict, Any
import warnings
import numpy as np
import pandas as pd
from sklearn.feature_selection import chi2
from sklearn.utils.multiclass import check_classification_targets
from scipy.stats import chi2_contingency

from .base import BaseFeatureSelector
from ._statistical_utils import (
    is_categorical,
    rank_scores,
    record_conditions,
    record_counts,
    top_k_mask,
    validate_k,
    validate_real,
)


def _compute_chi2_feature(task):
    """计算单个非负特征的卡方得分和 p 值。"""
    feature, values, y = task
    scores, p_values = chi2(values.reshape(-1, 1), y)
    return feature, scores[0], p_values[0]


[文档] class Chi2Selector(BaseFeatureSelector): """卡方筛选器. 使用卡方检验评估特征与目标变量的独立性。 适用于分类问题和非负特征。 卡方值解释: - 值越大: 特征与目标变量越相关 **参数** :param threshold: 得分阈值,默认为0.0 :param k: 保留的特征数,默认为'all' :param missing: 缺失值处理方式。数值则直接填充;字符串 ``'mean'``/``'min'``/``'max'`` 按列统计量填充; ``None`` 或 ``False`` 则删除含缺失值的行。默认为 ``-99.0`` :param target: 目标变量列名,默认为'target' :param categorical_strategy: 默认 'contingency' 对 object/string/category/bool 使用 Pearson 列联表检验(无连续性校正),不受类别编码或样本排列影响;'legacy_ordinal' 恢复旧版 首次出现顺序编码再计算的口径,结果依赖样本顺序。类别缺失在默认策略中独立成类, missing=None/False 时与数值列统一删除含缺失的行,其他填充值不与真实类别合并。 **参考样例** :: >>> from hscredit.core.selectors import Chi2Selector >>> import pandas as pd >>> import numpy as np >>> np.random.seed(42) >>> X = pd.DataFrame(np.abs(np.random.randn(1000, 5)), columns=[f'f{i}' for i in range(5)]) >>> y = pd.Series(np.random.randint(0, 2, 1000)) >>> selector = Chi2Selector(k=3) >>> selector.fit(X, y) >>> print(selector.selected_features_) **注意** 卡方检验要求特征非负(本类对负值通过 ``missing``/填充策略处理);``k`` 与 ``threshold`` 同时生效——先按得分阈值过滤,再取前 ``k`` 个。 **引用** 基于 sklearn ``chi2`` 评分: https://scikit-learn.org/stable/modules/generated/sklearn.feature_selection.chi2.html """ method_name = "卡方检验筛选" def __init__( self, threshold: float = 0.0, k: Union[int, str] = "all", missing: Union[float, int, str, None, bool] = -99.0, 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, categorical_strategy: str = "contingency", ): """初始化筛选器;默认透传已有目标列,仅target_rm=True移除。""" 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.k = k self.missing = missing self.categorical_strategy = categorical_strategy def __setstate__(self, state): legacy = "categorical_strategy" not in state super().__setstate__(state) if legacy: self.categorical_strategy = "legacy_ordinal" warnings.warn( "旧卡方筛选器保留类别序号检验;新训练建议显式使用 categorical_strategy='contingency'", UserWarning, stacklevel=2, ) def _check_input(self, X, y=None): validate_real(self.threshold, "卡方阈值", allow_infinite=True) validate_k(self.k) if self.categorical_strategy not in {"contingency", "legacy_ordinal"}: raise ValueError("categorical_strategy 必须是 'contingency' 或 'legacy_ordinal'") if isinstance(self.missing, str): if self.missing not in {"mean", "min", "max"}: raise ValueError("missing 仅支持 'mean'/'min'/'max'、有限数值或 None/False") elif self.missing is not None and self.missing is not False: validate_real(self.missing, "missing") X, y = super()._check_input(X, y) if y is None or np.asarray(y).ndim != 1 or pd.isna(np.asarray(y)).any(): raise ValueError("卡方筛选需要无缺失的一维分类目标 y") try: check_classification_targets(y) except ValueError as exc: raise ValueError("卡方筛选的目标 y 必须是离散分类标签,不能为连续数值") from exc return X, y def _fit_impl( self, X: pd.DataFrame, y: Optional[Union[pd.Series, np.ndarray]], ) -> None: """拟合卡方筛选器。 :param X: 输入特征DataFrame(需要非负值) :param y: 目标变量 """ self._get_feature_names(X) record_counts(self, X) self.threshold_ = self.threshold self.score_name_, self.score_direction_ = "卡方统计量", "越大越好" categorical = [column for column in X if is_categorical(X[column])] processed = X.copy() if self.categorical_strategy == "legacy_ordinal": for column in categorical: processed[column] = pd.factorize(processed[column])[0] if self.missing is None or self.missing is False: mask = processed.notna().all(axis=1) processed = processed.loc[mask] y = np.asarray(y)[mask.to_numpy()] else: y = np.asarray(y) if processed.empty: raise ValueError("缺失值处理后没有可用于卡方检验的样本") self.effective_counts_ = pd.Series(len(processed), index=X.columns, dtype=np.int64) categorical_tests = categorical if self.categorical_strategy == "contingency" else [] numeric = [column for column in X if column not in categorical_tests] scores = pd.Series(np.nan, index=X.columns, dtype=float) p_values = pd.Series(np.nan, index=X.columns, dtype=float) methods = pd.Series("非负数值卡方检验", index=X.columns, dtype=object) if numeric: numeric_frame = processed[numeric].astype(float) if isinstance(self.missing, str): numeric_frame = numeric_frame.fillna(getattr(numeric_frame, self.missing)()) elif self.missing is not None and self.missing is not False: numeric_frame = numeric_frame.fillna(float(self.missing)) if numeric_frame.isna().any().any(): raise ValueError("数值字段全缺失,无法按列统计量填充;请提供有限数值 missing") numeric_scores, numeric_p = chi2(np.maximum(numeric_frame.to_numpy(), 0), y) scores.loc[numeric], p_values.loc[numeric] = numeric_scores, numeric_p if self.categorical_strategy == "legacy_ordinal": methods.loc[categorical] = "旧版类别序号卡方检验" target_codes, target_labels = pd.factorize(y, sort=False) for column in categorical_tests: codes, labels = pd.factorize(processed[column], sort=False, use_na_sentinel=False) table = np.bincount( codes * len(target_labels) + target_codes, minlength=len(labels) * len(target_labels), ).reshape(len(labels), len(target_labels)) if min(table.shape) < 2: score, probability = 0.0, 1.0 else: score, probability, _, _ = chi2_contingency(table, correction=False) scores[column], p_values[column] = score, probability methods[column] = "类别列联表卡方检验" self._validate_parallel_configuration() self.scores_, self.p_values_, self.test_methods_ = scores, p_values, methods self.ranks_ = rank_scores(scores) threshold_mask = scores.to_numpy() >= self.threshold quantity_mask = top_k_mask(scores.to_numpy(), self.k) record_conditions(self, X.columns, 阈值达标=threshold_mask, 数量限制达标=quantity_mask) selected_mask = threshold_mask & quantity_mask selected_cols = X.columns[selected_mask].tolist() self.selected_features_ = selected_cols # 构建详细的dropped_记录,包含卡方得分 dropped_cols = [c for c in X.columns if c not in selected_cols] if len(dropped_cols) > 0: reasons = [] for column in dropped_cols: parts = [] if not np.isfinite(scores[column]): parts.append("卡方统计量无效(全零或无有效变异)") elif not bool(self.condition_results_.loc[column, "阈值达标"]): parts.append(f"卡方统计量 < {self.threshold}") if not bool(self.condition_results_.loc[column, "数量限制达标"]): parts.append(f"未进入前{self.k}名") reasons.append(";".join(parts)) self.dropped_ = pd.DataFrame( { "特征": dropped_cols, "剔除原因": reasons, "卡方得分": [self.scores_[col] for col in dropped_cols], } ) else: self.dropped_ = pd.DataFrame(columns=["特征", "剔除原因", "卡方得分"])