Sklearn-源码解析-书-v1-0-五-

Sklearn 源码解析(书)v1.0(五)

11.18 设计中的取舍

为什么采用当前方案,而不是更复杂的替代方案? 本章源码优先选择清晰、可维护且与既有 API 兼容的实现;这降低了使用和调试成本,但也意味着部分极端场景需要调用者自行权衡性能、灵活性与实现复杂度。

11.19 动手练习

11.19.1 阅读 BaseLibSVM.fit() 的 gamma 三态解析逻辑

阅读 sklearn/svm/_base.pyBaseLibSVM.fit() 方法中 gamma 部分:

  1. 找到 self.gamma 为 'scale'、'auto'、浮点数三种情况的处理代码

  2. 找到稀疏矩阵方差 X_var 的计算公式

回答问题:

  • 稀疏矩阵的方差公式为什么使用 (X.multiply(X)).mean() - (X.mean())**2

  • X_var 为 0 时为何设置 self._gamma = 1.0 而不是报错?

  • precomputed 核为何设置 self._gamma = 0.0

11.19.2 追踪二分类符号翻转的完整路径

_base.pyfit() 末尾和 _decision_function() 中找到以下代码:

  1. self.intercept_ *= -1self.dual_coef_ = -self.dual_coef_

  2. return -dec_func.ravel()

回答问题:

  • 为什么需要对二分类的 intercept_dual_coef_ 取反?

  • 内部使用的 _intercept__dual_coef_ 与公开的 intercept_dual_coef_ 有何区别?

  • libsvm 内部以什么顺序编码标签?sklearn 使用什么顺序?两者如何协调?

11.19.3 手写 Cache 类的 LRU 缓存淘汰追踪

阅读 sklearn/svm/src/libsvm/svm.cpp 中 Cache 类的 get_data()lru_insert() 方法:

  1. 画出 lru_head 双向循环链表在插入和删除时的指针变化

  2. 计算当缓存满时,哪一列被淘汰

  3. 解释 size = max(size, 2 * (long int) l) 为什么必须保证至少能存两列

思考:

缓存大小参数 cache_size 如何影响 SMO 的执行效率?在 SVC_Q::get_Q 中被如何使用?

11.19.4 对比 OVR 与 OvO 的预测投票差异

阅读 svm.cpppredict_values() 方法的投票循环:

  1. 找到多分类时 vote 数组的更新代码

  2. 找到 decision_function_shape='ovr'_base.py 中如何从 OvO 决策函数转换

  3. 画出三类问题(n_classes=3)下 sv_coef 的稀疏布局示意图

回答问题:

  • 对于 3 类问题,共训练多少个二分类器?预测时投票如何处理平局?

  • break_ties=True 与默认处理有何不同?

11.19.5 复现 l1_min_c 的边界计算

阅读 sklearn/svm/_bounds.pyl1_min_c() 函数:

  1. 用手工数据 X=[[1,0],[0,1],[1,1]], y=[0,1,1] 计算 den 的值

  2. 分别计算 loss='squared_hinge' 和 loss='log' 时的 l1_min_c

  3. 运行 sklearn/svm/tests/test_bounds.py 中的 test_l1_min_c 验证你的计算

思考:为什么截距的存在会使 den 变大?这如何影响 l1_min_c 的大小?

11.19.6 探索 OneClassSVM 的"无标签"训练机制

阅读 sklearn/svm/_classes.py 中 OneClassSVM 的 fit()decision_function()score_samples() 方法:

  1. 找到 fit() 中如何调用父类 fit(X, np.ones(_num_samples(X)))

  2. 找到 offset_ 的计算方式

  3. 对比 decision_function()score_samples() 的区别

思考:

  • 为什么 OneClassSVM 给所有样本的标签都设为 1?这与二分类 SVM 有何不同?

  • svm.cppsolve_one_class() 是如何初始化 alpha 的?

第 12 章 —— 特征选择 —— 锻造“数据降维的过滤器”

12.1 学习目标

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础

  • 理解 SelectorMixin 作为所有特征选择器统一基座的设计哲学,掌握其通过抽象方法 _get_support_mask() 实现"选什么"与"怎么裁"分离的模板方法模式

  • 深入 transform()/inverse_transform() 在稠密 DataFrame、NumPy 数组与 CSR 稀疏矩阵下的裁剪与还原逻辑,掌握零特征选中场景的语义与稀疏 indptr 重建技巧

  • 掌握 get_feature_names_out() 与 _get_feature_importances() 的特征名过滤与重要性提取机制,理解 attrgetter 对嵌套属性路径的解析

  • 解析 f_classif、chi2、r_regression、f_regression 等单变量统计评分函数的数学原理与稀疏矩阵高效实现

  • 理解 _BaseFilter 及 SelectKBest、SelectPercentile、SelectFpr、SelectFdr、SelectFwe、GenericUnivariateSelect 的泛化筛选策略与阈值/排名算法

  • 掌握 mutual_info_classif/mutual_info_regression 基于 k 近邻熵估计的互信息计算原理,以及 _compute_mi_cc/_compute_mi_cd 的三种变量类型分派

  • 理解 RFE 递归特征消除的迭代淘汰流程、特征排名机制与融合交叉验证的 RFECV 自动定特征数策略

  • 深入 SelectFromModel 的阈值解析(_calculate_threshold)与 max_features 截断逻辑,理解 prefit、partial_fit 和元数据路由在元估计器中的应用

  • 掌握 SequentialFeatureSelector 的前向/后向贪心搜索算法,理解 n_features_to_select='auto' 与 tol 早停条件的协同

  • 认识 VarianceThreshold 通过 mean_variance_axis、min_max_axis 与 np.nanvar 处理稠密/稀疏矩阵的方差计算,以及 threshold=0 时 peak-to-peak 消除浮点误差的精妙设计

  • 理解 feature_selection/init.py 作为包统一出口的 API 设计原则,以及各测试模块对选择器行为的验证策略

12.2 生活类比

想象整个 feature_selection 模块是一座特征筛选工厂:SelectorMixin 是工厂流水线的标准传送带,所有选择器共用裁剪/还原接口;_get_support_mask() 是每个工位的"质检规则"——RFE 用模型权重决定,VarianceThreshold 用方差决定,SelectKBest 用统计分数排名。get_support(indices=False/True) 则是查看质检结果的两种方式:布尔标签逐件标记"留/丢",或直接给出选中物品的编号清单。transform() 好比自动化分拣机,从所有特征箱中挑出选中的那几列装进新箱子;inverse_transform() 则是逆向打包机,把挑选后的物品放回原位,没选中的位置用零填充。稀疏矩阵的 indptr 重建像整理带索引的压缩文件柜:先数每列还有几份文件,被删列记为 0 份,再重新生成连续索引。单变量统计评分函数则像一套精密的量测仪器:f_classif 是 ANOVA 方差分析仪,度量特征在不同类别间的均值差异显著性;chi2 是卡方检验计,统计特征频数与类别标签的关联强度;r_regression / f_regression 是皮尔逊相关性温度计,测量特征与连续目标之间的线性关联热度;mutual_info_classif/regression 是互信息探测仪,用 k 近邻熵估计捕捉任意非线性依赖。SelectKBest/SelectPercentile 根据仪器读数取前 k 名或前百分之几,是"排行榜筛选器";SelectFpr/SelectFdr/SelectFwe 是基于 p 值的三位"假阳性控制专家",分别用不同统计口径控制误选率。RFE 与 SelectFromModel 则像基于教练评分的淘汰赛:RFE 反复训练模型,每轮按特征重要性淘汰最弱的 step 个"选手",直到剩下目标人数;RFECV 在 RFE 的每轮淘汰中引入交叉验证,自动找到"最佳队伍规模";SelectFromModel 一次性打分后按阈值截断,像用考试分数线录取学生,L1 正则化模型的天然分数线为 1e-5。SequentialFeatureSelector 则像贪心的选秀大会:前向选择从空队伍开始,每轮挑选一个让交叉验证得分提升最大的新成员加入;后向选择从全员队伍开始,每轮剔除一个对得分拖累最小的成员;tol 早停则是当得分提升幅度低于 tol 时停止,避免过度追求细节。VarianceThreshold 是最简单的震动筛选机:方差计算测量每列特征的"抖动程度"——在所有样本中数值完全相同的特征对区分样本毫无贡献,直接筛掉;peak-to-peak 与 NaN 处理则是有瑕疵的特征(含缺失值)不计入方差但不致崩溃;常量特征用极差辅助判断,避免浮点精度误差造成漏网之鱼。就像工厂经理需要精心安排每台机器的位置和物料流转,feature_selection 模块的每个选择器都只需专注于"选什么"这一核心逻辑,而 SelectorMixin 则负责"怎么裁"和"怎么还原"的通用流程。

12.3 源码地图

sklearn/feature_selection/_base.py
├── class SelectorMixin(TransformerMixin, metaclass=ABCMeta)
│   ├── get_support(indices=False)             # 返回布尔掩码或整数索引
│   ├── _get_support_mask()                    # 抽象方法,子类必须实现
│   ├── transform(X)                           # 输入校验 + 调用 _transform
│   ├── _transform(X)                          # 实际裁剪逻辑,零特征选中时发 UserWarning 并返回空列数据
│   ├── inverse_transform(X)                   # 逆向还原,稠密掩码填充/稀疏 indptr 重建
│   └── get_feature_names_out(input_features)  # 特征名过滤
└── _get_feature_importances(estimator, getter, transform_func, norm_order)
    ├── getter='auto' 自动探测 coef_/feature_importances_
    ├── getter 为字符串路径时经 attrgetter 解析
    ├── transform_func='norm' 按 norm_order 计算逐列范数
    └── transform_func='square' 用 safe_sqr 平方后按列求和

sklearn/feature_selection/_univariate_selection.py
├── _clean_nans(scores)                        # 将 NaN 替换为 dtype 最小值
├── f_oneway(*args)                            # 单因素 ANOVA F 统计量
├── f_classif(X, y)                            # 分类特征的 ANOVA F 值与 p 值
├── _chisquare(f_obs, f_exp)                   # 快速卡方统计量替代 scipy 实现
├── chi2(X, y)                                 # 非负特征与类别之间的卡方统计量
├── r_regression(X, y, *, center, force_finite) # Pearson 相关系数
├── f_regression(X, y, *, center, force_finite) # 单变量线性回归 F 值与 p 值
├── class _BaseFilter(SelectorMixin, BaseEstimator)
│   ├── __init__(score_func)                   # 评分函数参数
│   ├── fit(X, y=None)                         # 调用评分函数并保存 scores_/pvalues_
│   ├── _check_params(X, y)                    # 子类可覆盖的参数校验钩子
│   └── __sklearn_tags__()                     # 标记 target_tags.required 与稀疏支持
├── class SelectPercentile(_BaseFilter)
│   ├── __init__(score_func=f_classif, *, percentile=10)
│   ├── _get_support_mask()                    # 基于百分位阈值选择 top 特征
│   └── __sklearn_tags__()                     # target_tags.required=False 支持无监督
├── class SelectKBest(_BaseFilter)
│   ├── __init__(score_func=f_classif, *, k=10)
│   ├── _check_params(X, y)                    # k > n_features 时发警告
│   ├── _get_support_mask()                    # 稳定排序取前 k 高分特征
│   └── __sklearn_tags__()                     # target_tags.required=False
├── class SelectFpr(_BaseFilter)
│   ├── __init__(score_func=f_classif, *, alpha=5e-2)
│   └── _get_support_mask()                    # 选择 p 值小于 alpha 的特征
├── class SelectFdr(_BaseFilter)
│   ├── __init__(score_func=f_classif, *, alpha=5e-2)
│   └── _get_support_mask()                    # Benjamini-Hochberg FDR 控制
├── class SelectFwe(_BaseFilter)
│   ├── __init__(score_func=f_classif, *, alpha=5e-2)
│   └── _get_support_mask()                    # Bonferroni 校正 p 值阈值
└── class GenericUnivariateSelect(_BaseFilter)
    ├── __init__(score_func=f_classif, *, mode, param)
    ├── _make_selector()                       # 按 mode 动态实例化具体选择器
    ├── _check_params(X, y)                    # 委托给内部选择器
    └── _get_support_mask()                    # 委托给内部选择器计算掩码

sklearn/feature_selection/_mutual_info.py
├── _compute_mi_cc(x, y, n_neighbors)          # 连续-连续变量的 Kraskov MI 估计
├── _compute_mi_cd(c, d, n_neighbors)          # 连续-离散变量的 Ross MI 估计
├── _compute_mi(x, y, x_discrete, y_discrete)  # 按变量类型分派三种计算路径
├── _iterate_columns(X, columns)               # 逐列迭代,稀疏时从 indptr 提取
├── _estimate_mi(X, y, *, discrete_features)   # 互信息估计公共入口,处理噪声与并行
├── mutual_info_regression(X, y, *, ...)       # 连续目标的互信息
└── mutual_info_classif(X, y, *, ...)          # 离散目标的互信息,先检查分类目标

sklearn/feature_selection/_rfe.py
├── _rfe_single_fit(rfe, estimator, X, y, train, test, scorer, routed_params)  # 单折 RFE 拟合
├── class RFE(SelectorMixin, MetaEstimatorMixin, BaseEstimator)
│   ├── __init__(estimator, *, n_features_to_select, step, verbose, importance_getter)
│   ├── classes_ 属性                            # 分类器类别标签
│   ├── fit(X, y, **fit_params)                 # 元数据路由分派到 _fit
│   ├── _fit(X, y, step_score=None, **fit_params)  # 核心迭代淘汰循环
│   ├── predict(X, **predict_params)            # available_if 条件暴露
│   ├── score(X, y, **score_params)             # 条件暴露,裁剪后评分
│   ├── decision_function(X)                    # 条件暴露,裁剪后决策函数
│   ├── predict_proba(X)                        # 条件暴露,裁剪后概率
│   ├── predict_log_proba(X)                    # 条件暴露,裁剪后对数概率
│   ├── _get_support_mask()                     # 返回拟合后的 support_
│   ├── __sklearn_tags__()                      # 继承子估计器类型标签并标记 poor_score
│   └── get_metadata_routing()                  # 路由 estimator 的 fit/predict/score
└── class RFECV(RFE)
    ├── __init__(estimator, *, step, min_features_to_select, cv, scoring, verbose, n_jobs, importance_getter)
    ├── fit(X, y, **params)                     # 逐折 RFE + 交叉验证聚合 + 最优特征数选择
    ├── score(X, y, **score_params)             # 使用 RFECV 评分器
    ├── get_metadata_routing()                  # 路由 estimator/splitter/scorer
    └── _get_scorer()                           # 根据分类/回归返回默认评分器

sklearn/feature_selection/_from_model.py
├── _calculate_threshold(estimator, importances, threshold)  # 阈值字符串解析与 L1 默认值
├── class SelectFromModel(MetaEstimatorMixin, SelectorMixin, BaseEstimator)
│   ├── __init__(estimator, *, threshold, prefit, norm_order, max_features, importance_getter)
│   ├── _get_support_mask()                     # 重要性规范化 + 阈值过滤 + max_features 截断
│   ├── _check_max_features(X)                  # 校验 max_features 并设置 max_features_
│   ├── fit(X, y=None, **fit_params)            # 克隆/深拷贝估计器并拟合,元数据路由
│   ├── partial_fit(X, y=None, **partial_fit_params)  # 增量拟合,首次调用时克隆
│   ├── threshold_ 属性                          # 动态计算当前阈值
│   ├── n_features_in_ 属性                     # 从 estimator_ 继承
│   ├── get_metadata_routing()                  # 路由 estimator 的 fit/partial_fit
│   └── __sklearn_tags__()                      # 继承子估计器的 sparse/allow_nan 标签

sklearn/feature_selection/_sequential.py
└── class SequentialFeatureSelector(SelectorMixin, MetaEstimatorMixin, BaseEstimator)
    ├── __init__(estimator, *, n_features_to_select, tol, direction, scoring, cv, n_jobs)
    ├── fit(X, y=None, **params)                # 贪心前向/后向搜索循环,tol 早停
    ├── _get_best_new_feature_score(estimator, X, y, cv, current_mask, **params)  # 评估每个候选特征的 CV 得分
    ├── _get_support_mask()                     # 返回拟合后的 support_
    ├── __sklearn_tags__()                      # 继承子估计器的 sparse/allow_nan 标签
    └── get_metadata_routing()                  # 路由 estimator/splitter/scorer

sklearn/feature_selection/_variance_threshold.py
└── class VarianceThreshold(SelectorMixin, BaseEstimator)
    ├── __init__(threshold=0.0)                 # 阈值参数
    ├── fit(X, y=None)                          # 计算方差,处理稀疏与 NaN
    ├── _get_support_mask()                     # 返回方差大于阈值的掩码
    └── __sklearn_tags__()                      # 标记 allow_nan 与 sparse

sklearn/feature_selection/tests/test_base.py
├── class StepSelector(SelectorMixin, BaseEstimator)
│   ├── __init__(step=2)                        # 步长参数
│   ├── fit(X, y=None)                          # validate_data 校验输入
│   └── _get_support_mask()                     # 按步长均匀采样特征,step<1 时返回全零掩码
├── test_transform_dense()                      # 稠密变换验证 + dtype 保持 + 错误形状
├── test_transform_sparse()                     # 稀疏变换验证(CSC_CONTAINERS 参数化)
├── test_inverse_transform_dense()              # 稠密逆向验证
├── test_inverse_transform_sparse()             # 稀疏逆向验证
├── test_get_support()                          # 掩码/索引双模式验证
└── test_output_dataframe()                     # DataFrame 输出 dtype 保留验证

sklearn/feature_selection/tests/test_chi2.py
├── mkchi2(k)                                   # 创建 k-best chi2 选择器
├── test_chi2(csr_container)                    # 卡方基础功能验证(CSR 参数化)
├── test_chi2_coo(coo_container)                # COO 容器兼容性
├── test_chi2_negative(csr_container)           # 负值输入报错
├── test_chi2_unused_feature()                  # 未使用特征产生 NaN 且无运行时警告
└── test_chisquare()                            # _chisquare 与 scipy.stats 对比

sklearn/feature_selection/tests/test_feature_select.py
├── test_f_oneway_vs_scipy_stats()              # 与 SciPy 的 f_oneway 一致性
├── test_f_oneway_ints()                        # 整数输入烟囱测试
├── test_f_classif(csr_container)               # ANOVA F 值分类场景验证
├── test_r_regression(center)                   # Pearson 相关系数对照 NumPy
├── test_f_regression(csr_container)            # 回归 F 值稀疏/稠密一致
├── test_f_regression_input_dtype()             # 不同输入 dtype 一致性
├── test_f_regression_center()                  # center 参数对自由度的影响
├── test_r_regression_force_finite()            # 常量特征/目标的 force_finite 行为
├── test_f_regression_corner_case()             # 完美相关/常量特征的 F 值边界
├── test_f_classif_multi_class()                # 多分类 ANOVA F 值
├── test_select_percentile_classif()            # 百分位选择分类场景
├── test_select_percentile_classif_sparse()     # 百分位选择稀疏场景
├── test_select_kbest_classif()                 # KBest 选择分类场景
├── test_select_kbest_all()                     # k="all" 行为
├── test_select_kbest_zero(dtype_in)            # k=0 零特征选中行为
├── test_select_heuristics_classif()            # FDR/FPR/FWE 分类场景
├── assert_best_scores_kept(score_filter)       # 验证选中特征为最高分
├── test_select_percentile_regression()         # 百分位选择回归场景
├── test_select_percentile_regression_full()    # percentile=100 全选
├── test_select_kbest_regression()              # KBest 回归场景
├── test_select_heuristics_regression()         # FDR/FPR/FWE 回归场景
├── test_boundary_case_ch2()                    # 卡方边界情况
├── test_select_fdr_regression(alpha, n_informative)  # FDR 期望控制验证
├── test_select_fwe_regression()                # FWE 回归场景
├── test_selectkbest_tiebreaking()              # KBest 平局打破
├── test_selectpercentile_tiebreaking()         # Percentile 平局打破
├── test_tied_pvalues()                         # 平局 p 值处理
├── test_scorefunc_multilabel()                 # 多标签目标支持
├── test_tied_scores()                          # 平局分数稳定排序
├── test_nans()                                 # NaN 分数处理
├── test_invalid_k()                            # k > n_features 警告
├── test_f_classif_constant_feature()           # 常量特征警告
├── test_no_feature_selected()                  # 零特征选中场景
├── test_mutual_info_classif()                  # 互信息分类选择器
├── test_mutual_info_regression()               # 互信息回归选择器
├── test_dataframe_output_dtypes()              # DataFrame 输出 dtype 保持
└── test_unsupervised_filter(selector)          # 无监督过滤(y=None)支持

sklearn/feature_selection/tests/test_mutual_info.py
├── test_compute_mi_dd()                        # 离散-离散 MI 手工计算对照
├── test_compute_mi_cc(global_dtype)            # 连续-连续 MI 双变量正态对照
├── test_compute_mi_cd(global_dtype)            # 连续-离散 MI 混合分布对照
├── test_compute_mi_cd_unique_label(global_dtype)  # 唯一标签不影响 MI
├── test_mutual_info_classif_discrete(global_dtype)  # 离散特征互信息排序
├── test_mutual_info_regression(global_dtype)   # 回归互信息排序
├── test_mutual_info_classif_mixed(global_dtype)   # 混合连续/离散特征
├── test_mutual_info_options(global_dtype, csr_container)  # 参数校验与稀疏行为
├── test_mutual_information_symmetry_classif_regression()  # 分类/回归 MI 对称性
├── test_mutual_info_regression_X_int_dtype()   # 整数 dtype 一致性
└── test_mutual_info_n_jobs(global_random_seed, mutual_info_func, data_generator)  # 并行一致性

sklearn/feature_selection/tests/test_rfe.py
├── class MockClassifier(ClassifierMixin, BaseEstimator)  # RFE 测试替身
│   ├── __init__(foo_param=0)
│   ├── fit(X, y)                                # 设置全一 coef_ 并记录 classes_
│   ├── predict(T)                               # 返回全一预测
│   ├── predict_proba = predict
│   ├── decision_function = predict
│   ├── transform = predict
│   ├── score(X, y)                              # 固定返回 0.0
│   ├── get_params(deep=True)
│   ├── set_params(**params)
│   └── __sklearn_tags__()                       # allow_nan=True
├── test_rfe_features_importance()              # RFE 特征重要性排名
├── test_rfe(csr_container)                     # RFE 稠密/稀疏行为
├── test_RFE_fit_score_params()                 # fit/score 元数据传递
├── test_rfe_percent_n_features()               # 百分比 n_features_to_select
├── test_rfe_mockclassifier()                   # MockClassifier 行为
├── test_rfecv(csr_container)                   # RFECV 稠密/稀疏/评分器/step 变化
├── test_rfecv_mockclassifier()                 # RFECV + MockClassifier
├── test_rfecv_verbose_output()                 # verbose 输出
├── test_rfecv_cv_results_size(global_random_seed)  # cv_results_ 尺寸
├── test_rfe_estimator_tags()                   # RFE 标签与分层 CV
├── test_rfe_min_step(global_random_seed)       # 最小步长
├── test_number_of_subsets_of_features(global_random_seed)  # 子集数公式验证
├── test_rfe_cv_n_jobs(global_random_seed)      # n_jobs 并行一致性
├── test_rfe_cv_groups()                        # 分组 CV
├── test_rfe_wrapped_estimator(importance_getter, selector, expected_n_features)  # 包裹估计器
├── test_rfe_importance_getter_validation(importance_getter, err_type, Selector)  # getter 校验
├── test_rfe_allow_nan_inf_in_x(cv)             # NaN/Inf 输入
├── test_w_pipeline_2d_coef_()                  # Pipeline 2D coef_
├── test_rfe_pls(ClsRFE, PLSEstimator)          # PLS 估计器兼容
├── test_rfe_estimator_attribute_error()        # available_if 属性错误
├── test_rfe_n_features_to_select_warning(ClsRFE, param)  # 超界警告
├── test_rfe_with_sample_weight()               # sample_weight 传递
├── test_rfe_with_joblib_threading_backend(global_random_seed)  # threading 后端
└── test_results_per_cv_in_rfecv(global_random_seed)  # RFECV 各折结果一致性

sklearn/feature_selection/tests/test_from_model.py
├── class NaNTag(BaseEstimator)                 # allow_nan=True 标签
├── class NoNaNTag(BaseEstimator)               # allow_nan=False 标签
├── class NaNTagRandomForest(RandomForestClassifier)  # 自定义 NaN 标签 RF
├── class FixedImportanceEstimator(BaseEstimator)  # 固定重要性估计器
│   ├── __init__(importances)
│   └── fit(X, y=None)                           # 设置 feature_importances_
├── test_invalid_input()                        # 无效阈值字符串报错
├── test_input_estimator_unchanged()            # 原始估计器未被修改
├── test_max_features_error(max_features, err_type, err_msg)  # max_features 错误
├── test_inferred_max_features_integer(max_features)  # 整数 max_features
├── test_inferred_max_features_callable(max_features)  # 可调用 max_features
├── test_max_features_array_like(max_features)  # 数组输入
├── test_max_features_callable_data(max_features)  # callable 被调用于 X
├── test_max_features()                         # max_features 行为
├── test_max_features_tiebreak()                # 平局打破
├── test_threshold_and_max_features()           # threshold 与 max_features 交互
├── test_feature_importances()                  # feature_importances_ 阈值
├── test_sample_weight()                        # 样本权重传递
├── test_coef_default_threshold(estimator)      # L1 惩罚默认阈值 1e-5
├── test_2d_coef()                              # 二维 coef_ 范数
├── test_partial_fit()                          # partial_fit 增量
├── test_calling_fit_reinitializes()            # fit 重新初始化
├── test_prefit()                               # prefit 行为
├── test_prefit_max_features()                  # prefit + max_features 交互
├── test_get_feature_names_out_elasticnetcv()   # ElasticNetCV 特征名
├── test_prefit_get_feature_names_out()         # prefit 特征名
├── test_threshold_string()                     # 字符串阈值 "0.5*mean"
├── test_threshold_without_refitting()          # 无需重拟合修改阈值
├── test_fit_accepts_nan_inf()                  # fit 接受 NaN/Inf
├── test_transform_accepts_nan_inf()            # transform 接受 NaN/Inf
├── test_allow_nan_tag_comes_from_estimator()   # allow_nan 标签继承
├── _pca_importances(pca_estimator)             # PCA 重要性提取函数
├── test_importance_getter(estimator, importance_getter)  # 自定义 getter
├── test_select_from_model_pls(PLSEstimator)    # PLS 估计器兼容
├── test_estimator_does_not_support_feature_names()  # 无特征名支持
├── test_partial_fit_validate_feature_names(as_frame)  # partial_fit 特征名校验
└── test_from_model_estimator_attribute_error()  # available_if 属性错误

sklearn/feature_selection/tests/test_sequential.py
├── test_bad_n_features_to_select()             # n_features_to_select 超界报错
├── test_n_features_to_select(direction, n_features_to_select)  # 基础选择行为
├── test_n_features_to_select_auto(direction)   # auto 模式
├── test_n_features_to_select_stopping_criterion(direction)  # tol 早停验证
├── test_n_features_to_select_float(direction, n_features_to_select, expected)  # 浮点占比
├── test_sanity(seed, direction, n_features_to_select, expected_selected_features)  # 预期特征验证
├── test_sparse_support(csr_container)          # 稀疏数据支持
├── test_nan_support()                          # NaN 支持与不支持对比
├── test_pipeline_support()                     # Pipeline 嵌套支持
├── test_unsupervised_model_fit(n_features_to_select)  # 无监督模型
├── test_no_y_validation_model_fit(y)           # 非传统 y 报错
├── test_forward_neg_tol_error()                # 前向负 tol 报错
├── test_backward_neg_tol()                     # 后向负 tol 行为
├── test_cv_generator_support()                 # 生成器 CV 支持
└── test_fit_rejects_params_with_no_routing_enabled()  # 未启用路由时参数报错

sklearn/feature_selection/tests/test_variance_threshold.py
├── test_zero_variance(sparse_container)        # 零方差特征剔除(BSR/CSC/CSR 参数化)
├── test_zero_variance_value_error()            # 全常量特征 ValueError
├── test_variance_threshold(sparse_container)   # 自定义阈值
├── test_zero_variance_floating_point_error(sparse_container)  # 浮点误差消除
└── test_variance_nan(sparse_container)         # NaN 特征处理

sklearn/feature_selection/__init__.py
├── 从 _base 导入 SelectorMixin
├── 从 _from_model 导入 SelectFromModel
├── 从 _mutual_info 导入 mutual_info_classif/regression
├── 从 _rfe 导入 RFE, RFECV
├── 从 _sequential 导入 SequentialFeatureSelector
├── 从 _univariate_selection 导入 GenericUnivariateSelect, SelectFdr, SelectFpr, SelectFwe, SelectKBest, SelectPercentile, chi2, f_classif, f_oneway, f_regression, r_regression
├── 从 _variance_threshold 导入 VarianceThreshold
└── __all__ 明确定义全部公共 API

12.4 SelectorMixin 统一基座与 VarianceThreshold —— 特征选择器的"总开关与质量筛"

12.4.1 核心类型定义详解

SelectorMixin:所有特征选择器的统一接口

class SelectorMixin(TransformerMixin, metaclass=ABCMeta):
    """
    Transformer mixin that performs feature selection given a support mask.

    This mixin provides a feature selector implementation with `transform` and
    `inverse_transform` functionality given an implementation of
    `_get_support_mask`.
    """

SelectorMixin 继承自 TransformerMixin(获得 fit_transform 能力),并使用 ABCMeta 元类定义抽象方法 _get_support_mask()。这是模板方法模式的经典应用:父类定义算法骨架(transforminverse_transformget_supportget_feature_names_out),子类只需实现"选什么"的核心逻辑 _get_support_mask()。这种设计实现了"选什么"(业务逻辑)与"怎么裁/怎么还原"(通用基础设施)的完全解耦。

VarianceThreshold:最简单的过滤器

class VarianceThreshold(SelectorMixin, BaseEstimator):
    """Feature selector that removes all low-variance features."""

    _parameter_constraints: dict = {
        "threshold": [Interval(Real, 0, None, closed="left")]
    }

    def __init__(self, threshold=0.0):
        self.threshold = threshold

VarianceThreshold 直接继承 SelectorMixinBaseEstimator,只需实现 fit() 计算方差与 _get_support_mask() 返回布尔掩码。它不依赖目标变量 y,适用于无监督场景。


12.4.2 逐行解析关键函数

12.4.2.1 get_support():双模式返回机制

源码路径sklearn/feature_selection/_base.py - SelectorMixin.get_support()(第49-75行)

    def get_support(self, indices=False):
        """
        Get a mask, or integer index, of the features selected.

        Parameters
        ----------
        indices : bool, default=False
            If True, the return value will be an array of integers, rather
            than a boolean mask.

        Returns
        -------
        support : array
            An index that selects the retained features from a feature vector.
            If `indices` is False, this is a boolean array of shape
            [# input features], in which an element is True iff its
            corresponding feature is selected for retention. If `indices` is
            True, this is an integer array of shape [# output features] whose
            values are indices into the input feature vector.
        """
        mask = self._get_support_mask()          # ① 调用子类实现的抽象方法获取布尔掩码
        return mask if not indices else np.nonzero(mask)[0]  # ② 根据 indices 参数决定返回掩码还是整数索引

这段代码实现了双模式返回:布尔掩码适合直接用于 NumPy 索引(X[:, mask]),整数索引适合展示选中特征的位置或用于稀疏矩阵切片。

12.4.2.2 transform() 与 _transform():输入校验与核心裁剪

源码路径sklearn/feature_selection/_base.py - SelectorMixin.transform()(第88-117行)与 _transform()(第119-133行)

    def transform(self, X):
        """Reduce X to the selected features."""
        # ① 判断是否需要保留 DataFrame 输出格式
        output_config_dense = _get_output_config("transform", estimator=self)["dense"]
        preserve_X = output_config_dense != "default" and is_pandas_df(X)

        # ② 输入校验:接受 CSR 稀疏矩阵,根据 allow_nan 标签决定是否允许 NaN
        X = validate_data(
            self,
            X,
            dtype=None,
            accept_sparse="csr",
            ensure_all_finite=not get_tags(self).input_tags.allow_nan,
            skip_check_array=preserve_X,
            reset=False,
        )
        return self._transform(X)

    def _transform(self, X):
        """Reduce X to the selected features."""
        mask = self.get_support()                # ① 获取选中特征的布尔掩码
        if not mask.any():                       # ② 零特征选中场景
            warnings.warn(
                (
                    "No features were selected: either the data is"
                    " too noisy or the selection test too strict."
                ),
                UserWarning,
            )
            if hasattr(X, "iloc"):               # ③ DataFrame 分支:返回空列 DataFrame
                return X.iloc[:, :0]
            return np.empty(0, dtype=X.dtype).reshape((X.shape[0], 0))  # ④ NumPy 分支:返回 (n_samples, 0) 空数组
        return _safe_indexing(X, mask, axis=1)   # ⑤ 正常分支:按掩码安全索引裁剪特征列

这段代码展示了零特征选中时的优雅降级:发出 UserWarning 提醒用户,DataFrame 返回 iloc[:, :0] 保留列名结构,NumPy 数组返回 shape=(n_samples, 0) 保持二维形状,避免下游流程因维度塌陷报错。

12.4.2.3 inverse_transform():稠密/稀疏对偶实现

源码路径sklearn/feature_selection/_base.py - SelectorMixin.inverse_transform()(第135-171行)

    def inverse_transform(self, X):
        """Reverse the transformation operation."""
        if issparse(X):                          # ① 稀疏分支:转 CSC 再重建 indptr
            X = X.tocsc()
            # 递归调用逆变换 np.diff(X.indptr) 得到原始列非零计数
            it = self.inverse_transform(np.diff(X.indptr).reshape(1, -1))
            col_nonzeros = it.ravel()
            indptr = np.concatenate([[0], np.cumsum(col_nonzeros)])  # ② 累积和重建 indptr
            Xt = csc_matrix(
                (X.data, X.indices, indptr),
                shape=(X.shape[0], len(indptr) - 1),
                dtype=X.dtype,
            )
            return Xt

        # ③ 稠密分支:校验形状后用布尔掩码填充零矩阵
        support = self.get_support()
        X = check_array(X, dtype=None)
        if support.sum() != X.shape[1]:
            raise ValueError("X has a different shape than during fitting.")

        if X.ndim == 1:
            X = X[None, :]
        Xt = np.zeros((X.shape[0], support.size), dtype=X.dtype)
        Xt[:, support] = X
        return Xt

稀疏矩阵 indptr 重建原理

  • CSC 格式中 indptr[i+1] - indptr[i] 表示第 i 列的非零元素个数

  • np.diff(X.indptr) 得到变换后各列的非零计数(被选中的列保留原计数,被删列隐含为 0)

  • 递归调用 inverse_transform 将这个计数向量"逆向还原"为原始矩阵的列非零计数

  • np.cumsum 累积和生成新的 indptr,数据和 indices 保持不变

12.4.2.4 VarianceThreshold.fit():方差计算与浮点误差消除

源码路径sklearn/feature_selection/_variance_threshold.py - VarianceThreshold.fit()(第40-74行)

    @_fit_context(prefer_skip_nested_validation=True)
    def fit(self, X, y=None):
        """Learn empirical variances from X."""
        X = validate_data(
            self,
            X,
            accept_sparse=("csr", "csc"),
            dtype=np.float64,
            ensure_all_finite="allow-nan",       # ① 允许 NaN 存在
        )

        if hasattr(X, "toarray"):                # ② 稀疏矩阵分支
            _, self.variances_ = mean_variance_axis(X, axis=0)
            if self.threshold == 0:
                mins, maxes = min_max_axis(X, axis=0)
                peak_to_peaks = maxes - mins
        else:                                    # ③ 稠密矩阵分支
            self.variances_ = np.nanvar(X, axis=0)   # 忽略 NaN 计算方差
            if self.threshold == 0:
                peak_to_peaks = np.ptp(X, axis=0)    # 峰峰值(极差)

        if self.threshold == 0:
            # ④ 取方差与极差的最小值消除浮点误差
            compare_arr = np.array([self.variances_, peak_to_peaks])
            self.variances_ = np.nanmin(compare_arr, axis=0)

        # ⑤ 全特征不达标时抛 ValueError,单样本附加说明
        if np.all(~np.isfinite(self.variances_) | (self.variances_ <= self.threshold)):
            msg = "No feature in X meets the variance threshold {0:.5f}"
            if X.shape[0] == 1:
                msg += " (X contains only one sample)"
            raise ValueError(msg.format(self.threshold))

        return self

峰峰值修正浮点误差的原理

  • 常量特征的理论方差为 0,但浮点运算可能产生 1e-17 等极小非零值

  • 常量特征的极差严格为 0,取 min(var, ptp) 即可将浮点噪声归零

  • np.nanmin 同时处理含 NaN 特征(方差为 NaN,极差为 NaN,结果为 NaN,随后在阈值比较中被视为不达标)

12.4.2.5 VarianceThreshold._get_support_mask() 与标签系统

源码路径sklearn/feature_selection/_variance_threshold.py - _get_support_mask()(第76-80行)与 __sklearn_tags__()(第83-87行)

    def _get_support_mask(self):
        check_is_fitted(self)
        return self.variances_ > self.threshold  # ① 简单阈值比较生成布尔掩码

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True         # ② 标记允许 NaN 输入
        tags.input_tags.sparse = True            # ③ 标记支持稀疏矩阵
        return tags

这段代码定义了选择器的能力边界:通过标签系统向元估计器(如 Pipeline)声明"我支持 NaN"和"我支持稀疏矩阵",使得元数据路由能正确传递这些特性。


12.4.3 数据流图

graph TD A[fit X, y] --> B[validate_data 校验输入] B --> C{稀疏/稠密分支} C -->|稀疏| D[mean_variance_axis + min_max_axis] C -->|稠密| E[np.nanvar + np.ptp] D --> F[threshold=0 时取 min(var, ptp)] E --> F F --> G[检查是否有特征达标] G -->|无| H[抛出 ValueError] G -->|有| I[存储 variances_] I --> J[_get_support_mask: variances_ > threshold] J --> K[transform: _safe_indexing 裁剪列] K --> L[inverse_transform: 稠密填充/稀疏 indptr 重建]

12.5 单变量统计评分函数 —— 特征筛选的"精密量测仪器组"

12.5.1 核心函数解析

12.5.1.1 _clean_nans():NaN 标准化处理

源码路径sklearn/feature_selection/_univariate_selection.py - _clean_nans()(第25-33行)

def _clean_nans(scores):
    """
    Fixes Issue #1240: NaNs can't be properly compared, so change them to the
    smallest value of scores's dtype. -inf seems to be unreliable.
    """
    scores = as_float_array(scores, copy=True)      # ① 转为浮点数组并复制
    scores[np.isnan(scores)] = np.finfo(scores.dtype).min  # ② NaN 替换为 dtype 最小值
    return scores

为什么不用 -np.inf 在某些平台/NumPy 版本下,-np.inf 的排序行为不稳定(尤其是稳定排序 mergesort),而 dtype 最小有限值(如 float64-1.79e308)在比较和排序中行为确定,且保证被视为"最低分"。

12.5.1.2 f_oneway() 与 f_classif():ANOVA 方差分析

源码路径sklearn/feature_selection/_univariate_selection.py - f_oneway()(第40-80行)与 f_classif()(第88-132行)

def f_oneway(*args):
    """Perform a 1-way ANOVA."""
    n_classes = len(args)
    args = [as_float_array(a) for a in args]              # ① 统一转为浮点数组
    n_samples_per_class = np.array([a.shape[0] for a in args])
    n_samples = np.sum(n_samples_per_class)

    ss_alldata = sum(safe_sqr(a).sum(axis=0) for a in args)   # ② 总平方和
    sums_args = [np.asarray(a.sum(axis=0)) for a in args]
    square_of_sums_alldata = sum(sums_args) ** 2              # ③ 总和的平方
    square_of_sums_args = [s**2 for s in sums_args]

    sstot = ss_alldata - square_of_sums_alldata / float(n_samples)  # ④ SST
    ssbn = 0.0
    for k, _ in enumerate(args):
        ssbn += square_of_sums_args[k] / n_samples_per_class[k]  # ⑤ 组间平方和 SSB
    ssbn -= square_of_sums_alldata / float(n_samples)
    sswn = sstot - ssbn                                      # ⑥ 组内平方和 SSW
    dfbn = n_classes - 1
    dfwn = n_samples - n_classes
    msb = ssbn / float(dfbn)                                 # ⑦ 组间均方
    msw = sswn / float(dfwn)                                 # ⑧ 组内均方
    constant_features_idx = np.where(msw == 0.0)[0]          # ⑨ 检测常量特征
    if np.nonzero(msb)[0].size != msb.size and constant_features_idx.size:
        warnings.warn("Features %s are constant." % constant_features_idx, UserWarning)
    f = msb / msw                                            # ⑩ F 统计量
    f = np.asarray(f).ravel()
    prob = special.fdtrc(dfbn, dfwn, f)                      # ⑪ F 分布尾部概率
    return f, prob
@validate_params(...)
def f_classif(X, y):
    """Compute the ANOVA F-value for the provided sample."""
    X, y = check_X_y(X, y, accept_sparse=["csr", "csc", "coo"])  # ① 校验输入
    args = [X[safe_mask(X, y == k)] for k in np.unique(y)]       # ② 按类别分组
    return f_oneway(*args)                                        # ③ 调用通用 ANOVA

f_classif 将多类别分类问题转化为单因素 ANOVA:每个类别作为一组,计算特征在组间的方差与组内方差之比。常量特征(组内方差为 0)触发警告但不报错,F 值会变为 inf,后续由 _clean_nans 处理。

12.5.1.3 _chisquare() 与 chi2():卡方检验

源码路径sklearn/feature_selection/_univariate_selection.py - _chisquare()(第135-150行)与 chi2()(第158-220行)

def _chisquare(f_obs, f_exp):
    """Fast replacement for scipy.stats.chisquare."""
    f_obs = np.asarray(f_obs, dtype=np.float64)

    k = len(f_obs)
    chisq = f_obs
    chisq -= f_exp
    chisq **= 2
    with np.errstate(invalid="ignore"):
        chisq /= f_exp
    chisq = chisq.sum(axis=0)
    return chisq, special.chdtrc(k - 1, chisq)
@validate_params(...)
def chi2(X, y):
    """Compute chi-squared stats between each non-negative feature and class."""
    X = check_array(X, accept_sparse="csr", dtype=(np.float64, np.float32))
    if np.any((X.data if issparse(X) else X) < 0):
        raise ValueError("Input X must be non-negative.")

    Y = LabelBinarizer(sparse_output=True).fit_transform(y)  # ① 标签二值化为稀疏矩阵
    if Y.shape[1] == 1:
        Y = Y.toarray()
        Y = np.append(1 - Y, Y, axis=1)                       # ② 二分类扩展为两列

    observed = safe_sparse_dot(Y.T, X)                        # ③ 观测频数:n_classes × n_features
    if issparse(observed):
        observed = observed.toarray()

    feature_count = X.sum(axis=0).reshape(1, -1)              # ④ 特征总计数
    class_prob = Y.mean(axis=0).reshape(1, -1)                # ⑤ 类别先验概率
    expected = np.dot(class_prob.T, feature_count)            # ⑥ 期望频数

    return _chisquare(observed, expected)                     # ⑦ 卡方统计量

稀疏矩阵高效实现LabelBinarizer(sparse_output=True) 生成 CSR 稀疏矩阵 Ysafe_sparse_dot(Y.T, X) 利用稀疏矩阵乘法计算观测频数,避免显式构建稠密共现矩阵。二分类时 Y 只有一列,扩展为 [1-Y, Y] 保证自由度正确。

12.5.1.4 r_regression() 与 f_regression():皮尔逊相关与 F 检验

源码路径sklearn/feature_selection/_univariate_selection.py - r_regression()(第228-290行)与 f_regression()(第298-370行)

@validate_params(...)
def r_regression(X, y, *, center=True, force_finite=True):
    """Compute Pearson's r for each features and the target."""
    X, y = check_X_y(X, y, accept_sparse=["csr", "csc", "coo"], dtype=np.float64)
    n_samples = X.shape[0]

    if center:
        y = y - np.mean(y)
        X_means = X.mean(axis=0)
        X_means = X_means.getA1() if isinstance(X_means, np.matrix) else X_means
        X_norms = np.sqrt(row_norms(X.T, squared=True) - n_samples * X_means**2)
    else:
        X_norms = row_norms(X.T)

    correlation_coefficient = safe_sparse_dot(y, X)
    with np.errstate(divide="ignore", invalid="ignore"):
        correlation_coefficient /= X_norms
        correlation_coefficient /= np.linalg.norm(y)

    if force_finite and not np.isfinite(correlation_coefficient).all():
        nan_mask = np.isnan(correlation_coefficient)
        correlation_coefficient[nan_mask] = 0.0
    return correlation_coefficient
@validate_params(...)
def f_regression(X, y, *, center=True, force_finite=True):
    """Univariate linear regression tests returning F-statistic and p-values."""
    correlation_coefficient = r_regression(X, y, center=center, force_finite=force_finite)
    deg_of_freedom = y.size - (2 if center else 1)

    corr_coef_squared = correlation_coefficient**2
    with np.errstate(divide="ignore", invalid="ignore"):
        f_statistic = corr_coef_squared / (1 - corr_coef_squared) * deg_of_freedom
        p_values = stats.f.sf(f_statistic, 1, deg_of_freedom)

    if force_finite and not np.isfinite(f_statistic).all():
        mask_inf = np.isinf(f_statistic)
        f_statistic[mask_inf] = np.finfo(f_statistic.dtype).max
        mask_nan = np.isnan(f_statistic)
        f_statistic[mask_nan] = 0.0
        p_values[mask_nan] = 1.0
    return f_statistic, p_values

数学推导:对于单变量线性回归 y = βx + ε,F 统计量等价于平方相关系数的变换:

F = (r² / (1 - r²)) * (n - 2)

其中 r 为 Pearson 相关系数,n-2 为自由度(中心化时估计了截距和斜率两个参数)。force_finite=True 时,常量特征/目标导致 r=NaN 转为 F=0, p=1;完美相关导致 r=±1 转为 F=max_float, p=0


12.5.2 评分函数对比表

以下是单变量评分函数的适用场景与核心差异:

| 评分函数 | 目标类型 | 特征类型 | 核心统计量 | 稀疏支持 | 输出 |

|----------|----------|----------|------------|----------|------|

| f_classif | 离散 | 连续/稀疏 | ANOVA F 值 | ✅ CSR/CSC/COO | (F, p) |

| chi2 | 离散 | 非负/稀疏 | 卡方 χ² | ✅ CSR | (χ², p) |

| r_regression | 连续 | 连续/稀疏 | Pearson r | ✅ CSR/CSC/COO | r |

| f_regression | 连续 | 连续/稀疏 | F = r²/(1-r²)*df | ✅ CSR/CSC/COO | (F, p) |

| mutual_info_classif | 离散 | 混合 | kNN 熵估计 MI | ✅ CSC | MI |

| mutual_info_regression | 连续 | 混合 | kNN 熵估计 MI | ✅ CSC | MI |


12.6 过滤式选择器家族 —— 从 _BaseFilter 到 GenericUnivariateSelect 的"排行榜体系"

12.6.1 _BaseFilter:过滤器的公共骨架

源码路径sklearn/feature_selection/_univariate_selection.py - _BaseFilter(第375-432行)

class _BaseFilter(SelectorMixin, BaseEstimator):
    """Initialize the univariate feature selection."""
    _parameter_constraints: dict = {"score_func": [callable]}

    def __init__(self, score_func):
        self.score_func = score_func

    @_fit_context(prefer_skip_nested_validation=True)
    def fit(self, X, y=None):
        if y is None:
            X = validate_data(self, X, accept_sparse=["csr", "csc"])
        else:
            X, y = validate_data(self, X, y, accept_sparse=["csr", "csc"], multi_output=True)

        self._check_params(X, y)
        score_func_ret = self.score_func(X, y)
        if isinstance(score_func_ret, (list, tuple)):
            self.scores_, self.pvalues_ = score_func_ret
            self.pvalues_ = np.asarray(self.pvalues_)
        else:
            self.scores_ = score_func_ret
            self.pvalues_ = None

        self.scores_ = np.asarray(self.scores_)
        return self

    def _check_params(self, X, y):
        pass

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.target_tags.required = True
        tags.input_tags.sparse = True
        return tags

_BaseFilter 统一管理评分函数调用与结果存储:fit() 接受 y=None 支持无监督模式,_check_params() 留给子类覆盖,__sklearn_tags__ 标记需要目标变量与支持稀疏输入。

12.6.2 SelectKBest:取前 k 名的排行榜

源码路径sklearn/feature_selection/_univariate_selection.py - SelectKBest._get_support_mask()(第605-620行)

    def _get_support_mask(self):
        check_is_fitted(self)

        if self.k == "all":
            return np.ones(self.scores_.shape, dtype=bool)
        elif self.k == 0:
            return np.zeros(self.scores_.shape, dtype=bool)
        else:
            scores = _clean_nans(self.scores_)
            mask = np.zeros(scores.shape, dtype=bool)
            # ① 稳定排序处理平局:mergesort 保证相同分数特征按原序排列
            mask[np.argsort(scores, kind="mergesort")[-self.k :]] = 1
            return mask

稳定排序的重要性:当多个特征分数相同时,mergesort 保证原始特征顺序,使得结果可复现且不随内存布局变化。

12.6.3 SelectPercentile:按百分位画分数线

源码路径sklearn/feature_selection/_univariate_selection.py - SelectPercentile._get_support_mask()(第497-515行)

    def _get_support_mask(self):
        check_is_fitted(self)

        if self.percentile == 100:
            return np.ones(len(self.scores_), dtype=bool)
        elif self.percentile == 0:
            return np.zeros(len(self.scores_), dtype=bool)

        scores = _clean_nans(self.scores_)
        threshold = np.percentile(scores, 100 - self.percentile)  # ① 计算阈值
        mask = scores > threshold
        ties = np.where(scores == threshold)[0]
        if len(ties):
            max_feats = int(len(scores) * self.percentile / 100)  # ② 平局时按最大特征数截断
            kept_ties = ties[: max_feats - mask.sum()]
            mask[kept_ties] = True
        return mask

12.6.4 SelectFpr/SelectFdr/SelectFwe:三类假阳性控制系统

源码路径sklearn/feature_selection/_univariate_selection.py - 对应类的 _get_support_mask()

# 第 12 章 —— SelectFpr: 简单 p 值阈值
def _get_support_mask(self):
    return self.pvalues_ < self.alpha

# 第 12 章 —— SelectFdr: Benjamini-Hochberg 过程
def _get_support_mask(self):
    n_features = len(self.pvalues_)
    sv = np.sort(self.pvalues_)
    selected = sv[sv <= float(self.alpha) / n_features * np.arange(1, n_features + 1)]
    if selected.size == 0:
        return np.zeros_like(self.pvalues_, dtype=bool)
    return self.pvalues_ <= selected.max()

# 第 12 章 —— SelectFwe: Bonferroni 校正
def _get_support_mask(self):
    return self.pvalues_ < self.alpha / len(self.pvalues_)

| 选择器 | 统计学含义 | 控制目标 | 适用场景 |

|--------|------------|----------|----------|

| SelectFpr | False Positive Rate | 单测试 I 类错误率 | 探索性分析,宽松 |

| SelectFdr | False Discovery Rate (Benjamini-Hochberg) | 期望假发现比例 | 高维数据,平衡召回与精确 |

| SelectFwe | Family-Wise Error (Bonferroni) | 至少一个假阳性的概率 | 严格确证性分析,保守 |

12.6.5 GenericUnivariateSelect:mode 委派的泛化封装

源码路径sklearn/feature_selection/_univariate_selection.py - GenericUnivariateSelect(第960-998行)

class GenericUnivariateSelect(_BaseFilter):
    _selection_modes: dict = {
        "percentile": SelectPercentile,
        "k_best": SelectKBest,
        "fpr": SelectFpr,
        "fdr": SelectFdr,
        "fwe": SelectFwe,
    }

    def _make_selector(self):
        selector = self._selection_modes[self.mode](score_func=self.score_func)
        possible_params = selector._get_param_names()
        possible_params.remove("score_func")
        selector.set_params(**{possible_params[0]: self.param})
        return selector

    def _get_support_mask(self):
        check_is_fitted(self)
        selector = self._make_selector()
        selector.pvalues_ = self.pvalues_
        selector.scores_ = self.scores_
        return selector._get_support_mask()

GenericUnivariateSelect 通过 _make_selector() 工厂方法动态实例化具体选择器,将自身的 scores_pvalues_ 注入后委派计算掩码。这实现了策略模式:用户通过 mode 参数在运行时切换选择策略,无需实例化不同类。


12.6.6 过滤器家族数据流图

graph TD A[fit X, y] --> B[_BaseFilter.fit] B --> C[score_func X, y] C --> D[scores_, pvalues_] D --> E{子类策略} E -->|SelectKBest| F[稳定排序取前 k] E -->|SelectPercentile| G[百分位阈值 + 平局截断] E -->|SelectFpr| H[p < alpha] E -->|SelectFdr| I[BH 过程: p <= alpha*i/n] E -->|SelectFwe| J[Bonferroni: p < alpha/n] E -->|GenericUnivariateSelect| K[_make_selector 委派] F --> L[_get_support_mask -> 布尔掩码] G --> L H --> L I --> L J --> L K --> L L --> M[SelectorMixin.transform 裁剪]

12.7 互信息非参数估计 —— 基于 k 近邻熵的"非线性依赖探测器"

12.7.1 三种变量类型的分派机制

源码路径sklearn/feature_selection/_mutual_info.py - _compute_mi()(第133-148行)

def _compute_mi(x, y, x_discrete, y_discrete, n_neighbors=3):
    """Compute mutual information between two variables."""
    if x_discrete and y_discrete:
        return mutual_info_score(x, y)           # ① 离散-离散:直接用 sklearn 实现
    elif x_discrete and not y_discrete:
        return _compute_mi_cd(y, x, n_neighbors) # ② 离散-连续:交换参数调用 cd
    elif not x_discrete and y_discrete:
        return _compute_mi_cd(x, y, n_neighbors) # ③ 连续-离散
    else:
        return _compute_mi_cc(x, y, n_neighbors) # ④ 连续-连续

_estimate_mi() 根据 discrete_features 参数为每个特征标记离散/连续,再逐列调用 _compute_mi 分派计算。

12.7.2 连续-连续:Kraskov 估计器

源码路径sklearn/feature_selection/_mutual_info.py - _compute_mi_cc()(第20-70行)

def _compute_mi_cc(x, y, n_neighbors):
    """Compute mutual information between two continuous variables."""
    n_samples = x.size
    x = x.reshape((-1, 1))
    y = y.reshape((-1, 1))
    xy = np.hstack((x, y))

    nn = NearestNeighbors(metric="chebyshev", n_neighbors=n_neighbors)
    nn.fit(xy)
    radius = nn.kneighbors()[0]
    radius = np.nextafter(radius[:, -1], 0)      # ① 关键:半径微调至下一个可表示浮点数

    kd = KDTree(x, metric="chebyshev")
    nx = kd.query_radius(x, radius, count_only=True, return_distance=False)
    nx = np.array(nx) - 1.0                      # ② 排除自身

    kd = KDTree(y, metric="chebyshev")
    ny = kd.query_radius(y, radius, count_only=True, return_distance=False)
    ny = np.array(ny) - 1.0

    mi = (
        digamma(n_samples)
        + digamma(n_neighbors)
        - np.mean(digamma(nx + 1))
        - np.mean(digamma(ny + 1))
    )
    return max(0, mi)

np.nextafter(radius[:, -1], 0) 的作用kneighbors 返回的第 k 近邻距离包含边界点,Kraskov 估计器要求开球邻域(不含边界)。nextafter(val, 0) 将半径向 0 方向微调到下一个可表示的浮点数,确保落在边界上的点不被计入邻域计数。

12.7.3 连续-离散:Ross 估计器

源码路径sklearn/feature_selection/_mutual_info.py - _compute_mi_cd()(第73-130行)

def _compute_mi_cd(c, d, n_neighbors):
    """Compute mutual information between continuous and discrete variables."""
    n_samples = c.shape[0]
    c = c.reshape((-1, 1))

    radius = np.empty(n_samples)
    label_counts = np.empty(n_samples)
    k_all = np.empty(n_samples)
    nn = NearestNeighbors()
    for label in np.unique(d):
        mask = d == label
        count = np.sum(mask)
        if count > 1:
            k = min(n_neighbors, count - 1)      # ① k 不超过该类样本数-1
            nn.set_params(n_neighbors=k)
            nn.fit(c[mask])
            r = nn.kneighbors()[0]
            radius[mask] = np.nextafter(r[:, -1], 0)
            k_all[mask] = k
        label_counts[mask] = count

    mask = label_counts > 1                       # ② 忽略唯一标签的点(count=1)
    n_samples = np.sum(mask)
    label_counts = label_counts[mask]
    k_all = k_all[mask]
    c = c[mask]
    radius = radius[mask]

    kd = KDTree(c)
    m_all = kd.query_radius(c, radius, count_only=True, return_distance=False)
    m_all = np.array(m_all)

    mi = (
        digamma(n_samples)
        + np.mean(digamma(k_all))
        - np.mean(digamma(label_counts))
        - np.mean(digamma(m_all))
    )
    return max(0, mi)

为什么 k = min(n_neighbors, count - 1) 若某类别仅有 1 个样本(count=1),无法构建邻域(至少需要自身+1个邻居),该点被标记 label_counts=1 后在 mask = label_counts > 1 中被忽略,不参与 MI 估计。

12.7.4 _estimate_mi:公共入口与预处理

源码路径sklearn/feature_selection/_mutual_info.py - _estimate_mi()(第178-280行)

def _estimate_mi(...):
    X, y = check_X_y(X, y, accept_sparse="csc", y_numeric=not discrete_target)
    n_samples, n_features = X.shape

    # ① discrete_features="auto":稀疏视为离散,稠密视为连续
    if isinstance(discrete_features, (str, bool)):
        if discrete_features == "auto":
            discrete_features = issparse(X)
        ...
        discrete_mask = np.empty(n_features, dtype=bool)
        discrete_mask.fill(discrete_features)
    ...

    continuous_mask = ~discrete_mask
    if np.any(continuous_mask) and issparse(X):
        raise ValueError("Sparse matrix `X` can't have continuous features.")

    rng = check_random_state(random_state)
    if np.any(continuous_mask):
        X = X.astype(np.float64, copy=copy)
        X[:, continuous_mask] = scale(X[:, continuous_mask], with_mean=False, copy=False)
        # ② 连续特征标准化 + 注入 1e-10 噪声
        means = np.maximum(1, np.mean(np.abs(X[:, continuous_mask]), axis=0))
        X[:, continuous_mask] += (
            1e-10 * means * rng.standard_normal(size=(n_samples, np.sum(continuous_mask)))
        )

    if not discrete_target:
        y = scale(y, with_mean=False)
        y += 1e-10 * np.maximum(1, np.mean(np.abs(y))) * rng.standard_normal(size=n_samples)

    # ③ 并行逐列计算
    mi = Parallel(n_jobs=n_jobs)(
        delayed(_compute_mi)(x, y, discrete_feature, discrete_target, n_neighbors)
        for x, discrete_feature in zip(_iterate_columns(X), discrete_mask)
    )
    return np.array(mi)

噪声注入的目的:Kraskov/Ross 估计器要求连续变量无重复值(重复值导致距离为 0,邻域计数异常)。1e-10 * means * N(0,1) 以极小幅度打破平局,不改变分布本质特征。


12.7.5 互信息计算流程图

graph TD A[mutual_info_classif/regression X, y] --> B[_estimate_mi] B --> C[discrete_features 解析为布尔掩码] C --> D{连续特征?} D -->|是| E[scale + 1e-10 噪声] D -->|否| F[保持原样] E --> G[并行逐列 _iterate_columns] F --> G G --> H[_compute_mi 分派] H -->|离散-离散| I[mutual_info_score] H -->|连续-离散| J[_compute_mi_cd Ross] H -->|连续-连续| K[_compute_mi_cc Kraskov] I --> L[返回 MI 数组] J --> L K --> L

12.8 RFE 与 RFECV —— 模型重要性驱动的"淘汰赛与自动定员"

12.8.1 RFE:递归淘汰循环

源码路径sklearn/feature_selection/_rfe.py - RFE._fit()(第262-340行)

    def _fit(self, X, y, step_score=None, **fit_params):
        X, y = validate_data(self, X, y, accept_sparse="csc", ensure_min_features=2, ensure_all_finite=False, multi_output=True)

        # ① 初始化目标特征数
        n_features = X.shape[1]
        if self.n_features_to_select is None:
            n_features_to_select = n_features // 2
        elif isinstance(self.n_features_to_select, Integral):
            n_features_to_select = self.n_features_to_select
        else:
            n_features_to_select = int(n_features * self.n_features_to_select)

        # ② 步长计算
        if 0.0 < self.step < 1.0:
            step = int(max(1, self.step * n_features))
        else:
            step = int(self.step)

        support_ = np.ones(n_features, dtype=bool)
        ranking_ = np.ones(n_features, dtype=int)

        if step_score:
            self.step_n_features_ = []
            self.step_scores_ = []
            self.step_support_ = []
            self.step_ranking_ = []

        # ③ 核心淘汰循环
        while np.sum(support_) > n_features_to_select:
            features = np.arange(n_features)[support_]
            estimator = clone(self.estimator)
            if self.verbose > 0:
                print("Fitting estimator with %d features." % np.sum(support_))
            estimator.fit(X[:, features], y, **fit_params)

            if step_score:
                self.step_n_features_.append(len(features))
                self.step_scores_.append(step_score(estimator, features))
                self.step_support_.append(list(support_))
                self.step_ranking_.append(list(ranking_))

            # ④ 计算重要性并排名
            importances = _get_feature_importances(estimator, self.importance_getter, transform_func="square")
            ranks = np.argsort(importances)          # ⑤ 升序:最不重要在前
            ranks = np.ravel(ranks)                  # 稀疏时 ranks 可能是矩阵

            # ⑥ 淘汰阈值:不超过 step,且不淘汰过多导致低于目标数
            threshold = min(step, np.sum(support_) - n_features_to_select)
            support_[features[ranks][:threshold]] = False
            ranking_[np.logical_not(support_)] += 1

        # ⑦ 最终在选中特征上重新拟合
        features = np.arange(n_features)[support_]
        self.estimator_ = clone(self.estimator)
        self.estimator_.fit(X[:, features], y, **fit_params)

        if step_score:
            self.step_n_features_.append(len(features))
            self.step_scores_.append(step_score(self.estimator_, features))
            self.step_support_.append(support_)
            self.step_ranking_.append(ranking_)
        self.n_features_ = support_.sum()
        self.support_ = support_
        self.ranking_ = ranking_
        return self

关键设计点

  1. 每轮克隆估计器:避免副作用污染,保证每轮独立训练

  2. transform_func="square":对重要性平方求和(处理多维 coef_),等价于 L2 范数

  3. threshold = min(step, ...):防止最后一轮淘汰过多特征导致少于目标数

  4. ranking_ 累加:被淘汰特征排名递增,最终选中特征排名为 1

12.8.2 RFECV:交叉验证自动定数

源码路径sklearn/feature_selection/_rfe.py - RFECV.fit()(第623-700行)

    def fit(self, X, y, **params):
        X, y = validate_data(self, X, y, accept_sparse="csr", ensure_min_features=2, ensure_all_finite=False, multi_output=True)

        cv = check_cv(self.cv, y, classifier=is_classifier(self.estimator))
        scorer = self._get_scorer()

        n_features = X.shape[1]
        rfe = RFE(estimator=self.estimator, n_features_to_select=min(self.min_features_to_select, n_features), ...)

        if effective_n_jobs(self.n_jobs) == 1:
            parallel, func = list, _rfe_single_fit
        else:
            parallel = Parallel(n_jobs=self.n_jobs)
            func = delayed(_rfe_single_fit)

        # ① 并行逐折 RFE
        step_results = parallel(
            func(clone(rfe), self.estimator, X, y, train, test, scorer, routed_params)
            for train, test in cv.split(X, y, **routed_params.splitter.split)
        )
        scores, supports, rankings, step_n_features = zip(*step_results)

        # ② 反转数组:最少特征在前
        step_n_features_rev = np.array(step_n_features[0])[::-1]
        scores = np.array(scores)
        scores_sum_rev = np.sum(scores, axis=0)[::-1]  # ③ 反转后求和

        # ④ argmax 实现"最少特征优先破平局"
        n_features_to_select = step_n_features_rev[np.argmax(scores_sum_rev)]

        # ⑤ 全量数据重新执行最优特征数的 RFE
        rfe = RFE(estimator=self.estimator, n_features_to_select=n_features_to_select, ...)
        rfe.fit(X, y, **routed_params.estimator.fit)

        self.support_ = rfe.support_
        self.n_features_ = rfe.n_features_
        self.ranking_ = rfe.ranking_
        self.estimator_ = clone(self.estimator)
        self.estimator_.fit(self._transform(X), y, **routed_params.estimator.fit)

        # ⑥ 构建 cv_results_(反转回升序特征数)
        scores_rev = scores[:, ::-1]
        self.cv_results_ = {
            "mean_test_score": np.mean(scores_rev, axis=0),
            "std_test_score": np.std(scores_rev, axis=0),
            **{f"split{i}_test_score": scores_rev[i] for i in range(scores.shape[0])},
            **{f"split{i}_ranking": rankings_rev[i] for i in range(rankings.shape[0])},
            **{f"split{i}_support": supports_rev[i] for i in range(supports.shape[0])},
            "n_features": step_n_features_rev,
        }
        return self

反转数组 [::-1] 的深层含义

  • RFE 从全特征开始逐步减少,step_n_features 记录为 [10, 9, 8, ..., 1](降序)

  • scores 对应每折每步的得分,形状 (n_folds, n_steps)

  • scores[:, ::-1] 将步骤维度反转为升序 [1, 2, ..., 10]

  • np.argmax(scores_sum_rev) 在平局时返回最小索引,对应最少特征数

  • 这实现了奥卡姆剃刀原则:同等性能下优先选择更简约的模型


12.8.3 RFE/RFECV 架构与元数据路由

graph TD A[RFE.fit X, y] --> B[_routing_enabled?] B -->|是| C[process_routing -> routed_params] B -->|否| D[Bunch(fit=fit_params)] C --> E[_fit X, y, **routed_params.estimator.fit] D --> E E --> F[循环: clone estimator + fit + 重要性排序 + 淘汰] F --> G[最终 estimator_ 在选中特征上重拟合] G --> H[support_, ranking_, n_features_] I[RFECV.fit] --> J[check_cv + _get_scorer] J --> K[并行 _rfe_single_fit 每折] K --> L[聚合 scores/supports/rankings] L --> M[反转数组 + argmax 选最优特征数] M --> N[全量数据 RFE 重拟合] N --> O[cv_results_ 记录全过程]

元数据路由设计RFE.get_metadata_routing()RFECV.get_metadata_routing() 分别声明路由 estimatorfit/predict/scoreRFECV 还额外路由 splitter.splitscorer.score。这使得 sample_weightgroups 等元数据能正确传递到内部估计器、CV 分割器和评分器。


12.9 SelectFromModel 与 SequentialFeatureSelector —— 嵌入式与包裹式选择的"双雄"

12.9.1 SelectFromModel:基于模型重要性的嵌入式选择

12.9.1.1 _calculate_threshold():阈值字符串解析与 L1 默认值

源码路径sklearn/feature_selection/_from_model.py - _calculate_threshold()(第30-80行)

def _calculate_threshold(estimator, importances, threshold):
    """Interpret the threshold value"""
    if threshold is None:
        est_name = estimator.__class__.__name__
        # ① 检测 L1 惩罚模型
        is_l1_penalized = hasattr(estimator, "penalty") and estimator.penalty == "l1"
        is_lasso = "Lasso" in est_name
        is_elasticnet_l1_penalized = est_name == "ElasticNet" and (
            hasattr(estimator, "l1_ratio") and np.isclose(estimator.l1_ratio, 1.0)
        )
        is_elasticnetcv_l1_penalized = est_name == "ElasticNetCV" and (
            hasattr(estimator, "l1_ratio_") and np.isclose(estimator.l1_ratio_, 1.0)
        )
        is_logreg_l1_penalized = est_name == "LogisticRegression" and (
            hasattr(estimator, "l1_ratio") and np.isclose(estimator.l1_ratio, 1.0)
        )
        is_logregcv_l1_penalized = est_name == "LogisticRegressionCV" and (
            hasattr(estimator, "l1_ratio_")
            and np.all(np.isclose(estimator.l1_ratio_, 1.0))
        )
        if (is_l1_penalized or is_lasso or is_elasticnet_l1_penalized
            or is_elasticnetcv_l1_penalized or is_logreg_l1_penalized
            or is_logregcv_l1_penalized):
            threshold = 1e-5
        else:
            threshold = "mean"

    if isinstance(threshold, str):
        if "*" in threshold:
            scale, reference = threshold.split("*")
            scale = float(scale.strip())
            reference = reference.strip()
            if reference == "median":
                reference = np.median(importances)
            elif reference == "mean":
                reference = np.mean(importances)
            else:
                raise ValueError("Unknown reference: " + reference)
            threshold = scale * reference
        elif threshold == "median":
            threshold = np.median(importances)
        elif threshold == "mean":
            threshold = np.mean(importances)
        else:
            raise ValueError("Expected threshold='mean' or threshold='median' got %s" % threshold)
    else:
        threshold = float(threshold)
    return threshold

L1 默认阈值 1e-5 的由来:L1 正则化会产生精确为 0 的系数,但浮点数值计算中这些系数往往是 1e-10 量级的极小非零值。1e-5 作为经验阈值,既保留真正非零系数,又过滤数值噪声。这一设计体现了领域知识注入 API 默认值的工程智慧。

12.9.1.2 _get_support_mask():重要性规范化 + 阈值过滤 + max_features 截断

源码路径sklearn/feature_selection/_from_model.py - _get_support_mask()(第177-215行)

    def _get_support_mask(self):
        estimator = getattr(self, "estimator_", self.estimator)
        max_features = getattr(self, "max_features_", self.max_features)

        if self.prefit:
            try:
                check_is_fitted(self.estimator)
            except NotFittedError as exc:
                raise NotFittedError("When `prefit=True`, `estimator` is expected to be a fitted estimator.") from exc
        if callable(max_features):
            raise NotFittedError("When `prefit=True` and `max_features` is a callable, call `fit` before calling `transform`.")
        elif max_features is not None and not isinstance(max_features, Integral):
            raise ValueError(f"`max_features` must be an integer. Got `max_features={max_features}` instead.")

        # ① 重要性提取与规范化
        scores = _get_feature_importances(
            estimator=estimator,
            getter=self.importance_getter,
            transform_func="norm",
            norm_order=self.norm_order,
        )
        # ② 阈值解析
        threshold = _calculate_threshold(estimator, scores, self.threshold)
        # ③ max_features 截断:先取 top-k 再应用阈值
        if self.max_features is not None:
            mask = np.zeros_like(scores, dtype=bool)
            candidate_indices = np.argsort(-scores, kind="mergesort")[:max_features]
            mask[candidate_indices] = True
        else:
            mask = np.ones_like(scores, dtype=bool)
        # ④ 阈值过滤
        mask[scores < threshold] = False
        return mask

执行顺序:先按 max_features 取 Top-K,再应用阈值过滤。这意味着 max_features 是硬上限,阈值是软过滤。

12.9.1.3 fit() 与 partial_fit():双路径拟合与元数据路由

源码路径sklearn/feature_selection/_from_model.py - fit()(第232-280行)与 partial_fit()(第294-350行)

    @_fit_context(prefer_skip_nested_validation=False)
    def fit(self, X, y=None, **fit_params):
        self._check_max_features(X)

        if self.prefit:
            self.estimator_ = deepcopy(self.estimator)  # ① prefit: 深拷贝已训练模型
        else:
            if _routing_enabled():
                routed_params = process_routing(self, "fit", **fit_params)
                self.estimator_ = clone(self.estimator)
                self.estimator_.fit(X, y, **routed_params.estimator.fit)
            else:
                self.estimator_ = clone(self.estimator)
                self.estimator_.fit(X, y, **fit_params)

        if hasattr(self.estimator_, "feature_names_in_"):
            self.feature_names_in_ = self.estimator_.feature_names_in_
        else:
            _check_feature_names(self, X, reset=True)
        return self
    @available_if(_estimator_has("partial_fit"))
    @_fit_context(prefer_skip_nested_validation=False)
    def partial_fit(self, X, y=None, **partial_fit_params):
        first_call = not hasattr(self, "estimator_")

        if first_call:
            self._check_max_features(X)

        if self.prefit:
            if first_call:
                self.estimator_ = deepcopy(self.estimator)
            return self

        if first_call:
            self.estimator_ = clone(self.estimator)  # ② 首次调用时克隆
        if _routing_enabled():
            routed_params = process_routing(self, "partial_fit", **partial_fit_params)
            self.estimator_.partial_fit(X, y, **routed_params.estimator.partial_fit)
        else:
            self.estimator_.partial_fit(X, y, **partial_fit_params)

        if hasattr(self.estimator_, "feature_names_in_"):
            self.feature_names_in_ = self.estimator_.feature_names_in_
        else:
            _check_feature_names(self, X, reset=first_call)

        return self

partial_fit 的增量学习设计:首次调用克隆估计器,后续复用同一 estimator_ 实例累积训练。这支持流式数据场景,避免重复初始化模型参数。


12.9.2 SequentialFeatureSelector:贪心搜索的包裹式选择

源码路径sklearn/feature_selection/_sequential.py - SequentialFeatureSelector.fit()(第132-210行)与 _get_best_new_feature_score()(第212-235行)

    @_fit_context(prefer_skip_nested_validation=False)
    def fit(self, X, y=None, **params):
        _raise_for_params(params, self, "fit")
        tags = self.__sklearn_tags__()
        X = validate_data(self, X, accept_sparse="csc", ensure_min_features=2, ensure_all_finite=not tags.input_tags.allow_nan)
        n_features = X.shape[1]

        # ① 目标特征数初始化
        if self.n_features_to_select == "auto":
            if self.tol is not None:
                self.n_features_to_select_ = n_features - 1
            else:
                self.n_features_to_select_ = n_features // 2
        elif isinstance(self.n_features_to_select, Integral):
            self.n_features_to_select_ = self.n_features_to_select
        elif isinstance(self.n_features_to_select, Real):
            self.n_features_to_select_ = int(n_features * self.n_features_to_select)

        if self.tol is not None and self.tol < 0 and self.direction == "forward":
            raise ValueError("tol must be strictly positive when doing forward selection")

        cv = check_cv(self.cv, y, classifier=is_classifier(self.estimator))
        cloned_estimator = clone(self.estimator)

        # ② current_mask 语义随方向变化
        current_mask = np.zeros(shape=n_features, dtype=bool)
        n_iterations = (self.n_features_to_select_ if self.n_features_to_select == "auto" or self.direction == "forward" else n_features - self.n_features_to_select_)

        old_score = -np.inf
        is_auto_select = self.tol is not None and self.n_features_to_select == "auto"

        for _ in range(n_iterations):
            # ③ 评估每个候选特征
            new_feature_idx, new_score = self._get_best_new_feature_score(cloned_estimator, X, y, cv, current_mask, **params)
            if is_auto_select and ((new_score - old_score) < self.tol):
                break
            old_score = new_score
            current_mask[new_feature_idx] = True

        # ④ 后向选择最终取反
        if self.direction == "backward":
            current_mask = ~current_mask

        self.support_ = current_mask
        self.n_features_to_select_ = self.support_.sum()
        return self
    def _get_best_new_feature_score(self, estimator, X, y, cv, current_mask, **params):
        candidate_feature_indices = np.flatnonzero(~current_mask)
        scores = {}
        for feature_idx in candidate_feature_indices:
            candidate_mask = current_mask.copy()
            candidate_mask[feature_idx] = True
            if self.direction == "backward":
                candidate_mask = ~candidate_mask  # ⑤ 后向方向取反评估
            X_new = X[:, candidate_mask]
            scores[feature_idx] = cross_val_score(estimator, X_new, y, cv=cv, scoring=self.scoring, n_jobs=self.n_jobs, params=params).mean()
        new_feature_idx = max(scores, key=lambda feature_idx: scores[feature_idx])
        return new_feature_idx, scores[new_feature_idx]

后向选择的 ~current_mask 技巧

  • 前向:current_mask 标记"已选特征",候选特征加入后直接评估

  • 后向:current_mask 标记"已排除特征",候选特征加入后取反得到"保留特征"再评估

  • 统一了"当前掩码 + 候选特征 -> 评估掩码"的逻辑

tol 早停机制

  • n_features_to_select="auto"tol 不为 None 时启用

  • 前向要求 tol > 0(得分必须显著提升),后向允许 tol < 0(接受轻微下降换取简化)

  • is_auto_select 标记控制是否检查早停条件


12.9.3 选择器对比表

| 维度 | SelectFromModel | SequentialFeatureSelector | RFE/RFECV |

|------|-----------------|---------------------------|-----------|

| 范式 | 嵌入式 | 包裹式 | 包裹式(递归) |

| 核心依据 | 模型内部重要性 | CV 得分 | 模型内部重要性 |

| 计算模式 | 单次/增量拟合 | 贪心逐轮 CV | 递归逐轮拟合 |

| 特征数控制 | threshold + max_features | n_features_to_select + tol | n_features_to_select / CV 选优 |

| 并行支持 | 无(单估计器) | CV 折并行 | CV 折并行 |

| 适用场景 | 树模型/线性模型快速筛选 | 小特征集/需 CV 验证 | 需要完整排名/自动定数 |


12.10 设计中的取舍

12.10.1 为什么不用统一的 _get_support_mask 签名强制所有选择器返回 (mask, scores) 元组?

答案SelectorMixin 只关心"选什么"(布尔掩码),不关心"分数多少"。评分函数输出异构(有的返回 F/p,有的仅返回 MI,有的返回系数范数),强制统一会增加不必要的包装开销。当前设计让 _BaseFilter 存储 scores_/pvalues_SelectFromModel 存储 estimator_RFE 存储 ranking_,各自按需暴露,符合接口隔离原则

12.10.2 这种设计的 trade-off 是什么?

优点

  • SelectorMixin 极其轻量,任何实现 _get_support_mask 的类即可获得完整的 transform/inverse_transform/get_feature_names_out 能力

  • 评分函数与选择策略解耦,新增评分函数(如自定义 MI)无需修改选择器代码

  • 稀疏/稠密/NaN 处理集中在基类,子类无需重复实现

缺点

  • get_support(indices=True) 返回整数索引时,稀疏矩阵切片可能触发格式转换(CSR->CSC),有性能损耗

  • inverse_transform 稀疏分支的递归调用在超大稀疏矩阵上可能栈溢出(极少见)

  • GenericUnivariateSelect_make_selector 每次 _get_support_mask 都实例化新选择器,有微小开销


12.11 动手练习

12.11.1 练习 1:深入理解 SelectorMixin 的 transform/inverse_transform 流程

阅读 sklearn/feature_selection/_base.py 第49-171行,理解以下方法:

  1. get_support(indices=False) - 双模式返回机制

  2. transform(X)_transform(X) - 输入校验与核心裁剪

  3. inverse_transform(X) - 稠密掩码填充与稀疏 indptr 重建

回答问题

  • 当没有特征被选中时,_transform() 如何处理 DataFrame 和 NumPy 数组?为什么返回 X.iloc[:, :0] 而不是 None

  • 在稀疏分支的 inverse_transform() 中,为什么递归调用 self.inverse_transform(np.diff(X.indptr).reshape(1, -1))

  • 如果原矩阵有5列,transform 后选中3个非零列,原 indptr 是 [0, 2, 5, 5, 6, 8],transform 后的 indptr 是什么?逆变换后 indptr 如何变化?

12.11.2 练习 2:单变量统计评分函数的数值实现对比

阅读 sklearn/feature_selection/_univariate_selection.py 第63-400行,对比:

  1. f_oneway(*args) 的手工 ANOVA 实现与 scipy.stats.f_oneway 的差异

  2. chi2(X, y) 如何用 LabelBinarizer 和 safe_sparse_dot 处理稀疏矩阵

  3. r_regression(X, y, center=True, force_finite=True) 的 Pearson 相关系数计算

回答问题

  • _clean_nans() 把 NaN 替换为 dtype 最小值的意义是什么?为什么不像某些库替换为 -inf

  • f_regression()corr_coef_squared / (1 - corr_coef_squared) * deg_of_freedom 的数学推导是什么?

  • chi2() 中当 Y.shape[1] == 1 时为什么要拼接 1 - Y

12.11.3 练习 3:互信息估计的 k 近邻熵方法

阅读 sklearn/feature_selection/_mutual_info.py 第20-110行,理解:

  1. _compute_mi_cc() 中 Kraskov 估计器的半径计算与 digamma 求平均

  2. _compute_mi_cd() 中按标签分组计算半径的流程

  3. _estimate_mi() 中连续特征的缩放与噪声注入策略

回答问题

  • 为什么 _compute_mi_cc() 中要用 np.nextafter(radius[:, -1], 0)?nextafter 在这里的作用是什么?

  • 为什么 _compute_mi_cd()k = min(n_neighbors, count - 1)?如果 count=1 会发生什么?

  • _estimate_mi() 添加 1e-10 * means * rng.standard_normal(...) 噪声的目的是什么?

12.11.4 练习 4:RFE 递归淘汰与 RFECV 交叉验证定数

阅读 sklearn/feature_selection/_rfe.py 第250-420行与第480-590行,理解:

  1. RFE._fit() 中的淘汰循环、ranking_ 更新与 step 计算

  2. RFECV.fit() 中逐折 RFE 并行执行、scores 聚合与最优特征数选择

  3. RFE.__sklearn_tags__() 中标签继承与 poor_score 标记

回答问题

  • RFE._fit()threshold = min(step, np.sum(support_) - n_features_to_select) 为什么需要 min?

  • RFECV.fit() 中反转 scores 数组 [::-1] 的原因是什么?为什么说这实现了"最少特征优先"的平局打破?

  • RFE.decision_function()@available_if(_estimator_has("decision_function")) 装饰器如何工作?

12.11.5 练习 5:SelectFromModel 的阈值解析与 max_features 截断

阅读 sklearn/feature_selection/_from_model.py 第30-100行与第210-260行,理解:

  1. _calculate_threshold() 中的 L1 惩罚检测与字符串阈值解析

  2. SelectFromModel._get_support_mask() 中 norm 变换 + 阈值过滤 + max_features 截断

  3. SelectFromModel.fit() 中 prefit 的深拷贝与克隆-拟合两条路径

回答问题

  • 为什么 L1 惩罚模型的默认阈值是 1e-5 而不是 0?

  • _calculate_threshold() 中如何检测 LogisticRegressionCVl1_ratio_ 是否为 L1?

  • SelectFromModel.partial_fit() 中为什么首次调用时 self.estimator_ = clone(self.estimator) 而后续不重新克隆?

12.11.6 练习 6:SequentialFeatureSelector 的贪心搜索与早停

阅读 sklearn/feature_selection/_sequential.py 第100-210行,理解:

  1. fit() 中 current_mask 的初始化与前后向循环逻辑

  2. _get_best_new_feature_score() 中候选特征评估与最大得分选择

  3. n_features_to_select="auto"tol 的协同早停

回答问题

  • 后向选择中 current_mask = ~current_mask 出现在哪一行?为什么需要取反?

  • tol 为负值时后向选择如何工作?为什么前向选择不允许负 tol?

  • _get_best_new_feature_score() 中对每个候选特征都调用 cross_val_score(),时间复杂度是多少?

12.11.7 练习 7:VarianceThreshold 的浮点误差消除与 NaN 处理

阅读 sklearn/feature_selection/_variance_threshold.py 全文,理解:

  1. fit() 中稠密与稀疏分支的方差计算差异

  2. threshold=0 时 peak-to-peak 修正的逻辑

  3. 全特征不达标时的 ValueError 与单样本附加提示

回答问题

  • 为什么 np.var 在浮点精度下可能对常量特征返回非零值?peak_to_peak 如何避免这个问题?

  • 稀疏矩阵中 mean_variance_axis 与稠密矩阵 np.nanvar 的 NaN 处理语义是否一致?

  • __sklearn_tags__() 中设置 tags.input_tags.allow_nan = True 的含义是什么?


12.12 本章小结

这一章中我们学习/了解/讨论了 scikit-learn 特征选择模块的完整架构。首先,我们深入剖析了 SelectorMixin 这一统一基座,它通过模板方法模式将"选什么"(_get_support_mask 抽象方法)与"怎么裁/怎么还原"(transform/inverse_transform 通用实现)彻底解耦,并优雅处理了零特征选中、DataFrame 保持、稀疏矩阵 indptr 重建等工程细节。其次,我们详细解读了单变量统计评分函数家族:ANOVA F 值(f_classif)、卡方检验(chi2)、皮尔逊相关(r_regression)及其 F 变换(f_regression),理解了它们在稀疏矩阵上的高效实现与边界情况处理。接着,我们梳理了过滤式选择器体系:_BaseFilter 统一骨架,SelectKBest/SelectPercentile 基于排名/百分位,SelectFpr/SelectFdr/SelectFwe 实现三种假阳性控制,GenericUnivariateSelect 通过策略模式动态委派。随后,我们深入互信息非参数估计的核心:Kraskov(连续-连续)、Ross(连续-离散)两大 k 近邻熵估计器,以及噪声注入、离散特征自动检测等工程细节。然后,我们剖析了包裹式选择的双雄:RFE/RFECV 的递归淘汰与交叉验证自动定数(反转数组实现最少特征优先),SelectFromModel 的阈值解析(L1 默认 1e-5)、max_features 截断与 prefit/partial_fit 双路径。最后,我们探讨了 SequentialFeatureSelector 的贪心前向/后向搜索、tol 早停机制,以及 VarianceThreshold 用峰峰值消除浮点误差的精妙设计。贯穿始终的是元数据路由、标签继承、稀疏/NaN 支持等工程化基础设施的无缝集成。

本章我们一起学习了以下概念:

| 概念 | 解释 |

|------|------|

| SelectorMixin.get_support() | 布尔掩码与整数索引双模式返回,服务直接索引与位置展示两种场景 |

| SelectorMixin._get_support_mask() | 抽象方法,子类定义特征选择规则的核心契约 |

| SelectorMixin.transform()/_transform() | 输入校验 + 按掩码安全索引裁剪特征列,零特征选中时发 UserWarning 并返回空列数据 |

| SelectorMixin.inverse_transform() | 逆向还原,稠密用布尔掩码填充,稀疏用 np.diff(indptr) 重建 CSC 列索引 |

| SelectorMixin.get_feature_names_out() | 用支持掩码过滤特征名数组,无输入特征名时生成默认名 x0, x1, ... |

| _get_feature_importances() | 从估计器提取特征重要性,支持 auto/字符串路径/callable 三种 getter,可做 norm 或 square 聚合 |

| f_oneway/f_classif | ANOVA 方差分析,通过组间/组内均方比计算 F 统计量 |

| chi2/_chisquare | 卡方统计量,基于 LabelBinarizer 与安全稀疏点积计算观测/期望频数 |

| r_regression/f_regression | 皮尔逊相关系数及其 F 值转换,force_finite 处理常量/完美相关边界 |

| _BaseFilter | 单变量过滤基类,统一管理 score_func 调用与 scores_/pvalues_ 存储 |

| SelectKBest/SelectPercentile | 基于分数排名或百分位阈值选择特征,稳定排序处理平局 |

| SelectFpr/SelectFdr/SelectFwe | 基于 p 值的三种多重检验校正策略:FPR、Benjamini-Hochberg FDR、Bonferroni FWE |

| GenericUnivariateSelect | 通过 mode 参数动态委派给具体选择器的泛化封装 |

| _compute_mi_cc/_compute_mi_cd | 基于 k 近邻熵估计的连续-连续与连续-离散互信息计算方法 |

| _estimate_mi | 互信息估计公共入口,处理离散特征掩码、噪声注入与并行计算 |

| RFE | 递归特征消除,每轮克隆估计器训练后按重要性淘汰最弱特征,记录 ranking_/support_ |

| RFECV | 带交叉验证的 RFE,逐折评分后自动选择最优特征数,输出 cv_results_ |

| _calculate_threshold | 解析阈值字符串(mean/median/N*mean),L1 惩罚模型默认阈值 1e-5 |

| SelectFromModel | 基于模型重要性按阈值和 max_features 筛选特征,支持 prefit/partial_fit |

| SequentialFeatureSelector | 贪心前向/后向搜索,以交叉验证得分为准逐步增删特征,tol 控制早停 |

| VarianceThreshold | 基于方差阈值的选择器,threshold=0 时用 peak-to-peak 消除浮点误差 |

| feature_selection/__init__.py | 模块统一出口,从七个子模块显式导入,all 界定公共 API |

下一章中,我们将学习递归特征消除(RFE)与基于模型的选择(SelectFromModel)如何在元估计器框架下实现参数路由、条件方法暴露与交叉验证集成,以及它们在实际建模流水线中的最佳实践。

第 13 章 —— 朴素贝叶斯 —— 体验“概率世界的极简主义”

13.1 学习目标

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础

  • 理解朴素贝叶斯分类器的概率推理骨架(_BaseNB)与统一预测流程

  • 掌握高斯朴素贝叶斯的在线增量学习机制(Chan-Golub-LeVeque算法)

  • 熟悉离散朴素贝叶斯基座(_BaseDiscreteNB)的计数、平滑与先验策略

  • 能区分并实现MultinomialNB、ComplementNB、BernoulliNB、CategoricalNB四种离散变体

  • 了解scikit-learn中Array API兼容性设计与稀疏矩阵支持

  • 掌握测试辅助函数与概率评分验证方法

13.2 生活类比

想象朴素贝叶斯分类器是一位经验丰富的医生做诊断先验概率 = 医生对不同疾病的先验知识(如流感到流感季节更常见);似然概率 = 症状在某种疾病下出现的可能性(如发烧在流感中更常见);联合对数似然 = 医生综合先验和症状的原始评分;log-sum-exp归一化 = 将所有疾病评分统一到同一尺度,确保概率和为1;增量学习 = 医生不断积累新病例,只更新充分统计量(均值、计数)而无需记住所有历史病例;拉普拉斯平滑 = 对从未见过的症状组合留有余地,避免概率为0的绝对否定;二值化 = 医生将连续指标简化为“是否”的二元判断(如体温是否超过38℃);补集策略 = 不只关注“哪些症状支持该疾病”,还要看“哪些症状在其他疾病中更常见”来反向纠偏;测试辅助函数 = 医生的“标准化病例生成器”,每次生成症状模式相同但具体数值不同的患者。

13.3 源码地图

sklearn/naive_bayes.py
├── __main__  # 全局代码:模块导入与__all__定义
├── _BaseNB(第44-177行)
│   ├── _joint_log_likelihood()  # 抽象方法:计算联合对数似然
│   ├── _check_X()               # 抽象方法:输入验证
│   ├── predict_joint_log_proba() # 返回未归一化的联合对数概率
│   ├── predict()                # argmax得到类别标签
│   ├── predict_log_proba()      # log-sum-exp归一化
│   └── predict_proba()          # 指数变换得到概率
├── GaussianNB(第180-458行)
│   ├── __init__()              # 初始化priors和var_smoothing
│   ├── fit()                   # 调用_partial_fit完成拟合
│   ├── _check_X()              # 输入验证
│   ├── _update_mean_variance() # 在线更新均值与方差
│   ├── partial_fit()           # 增量拟合入口
│   ├── _partial_fit()          # 核心拟合逻辑(初始化、逐类更新)
│   ├── _joint_log_likelihood() # 高斯对数似然计算
│   └── __sklearn_tags__()      # Array API兼容标签
├── _BaseDiscreteNB(第461-532行)
│   ├── __init__()              # 初始化alpha、fit_prior、class_prior等
│   ├── _count()                # 抽象方法:收集计数
│   ├── _update_feature_log_prob() # 抽象方法:平滑并取对数
│   ├── _check_X()              # 稀疏矩阵输入验证
│   ├── _check_X_y()            # fit方法中的输入验证
│   ├── _update_class_log_prior() # 三类先验策略统一调度
│   ├── _check_alpha()          # 平滑参数安全阀
│   ├── partial_fit()           # 增量拟合(label_binarize)
│   ├── fit()                   # 使用LabelBinarizer拟合
│   ├── _init_counters()        # 初始化计数矩阵
│   └── __sklearn_tags__()      # 稀疏支持与poor_score标签
├── MultinomialNB(第535-766行)
│   ├── __init__()              # 参数初始化
│   ├── __sklearn_tags__()      # positive_only标签
│   ├── _count()                # safe_sparse_dot高效计数
│   ├── _update_feature_log_prob() # 拉普拉斯平滑
│   └── _joint_log_likelihood() # 矩阵乘法+先验
├── ComplementNB(第769-876行)
│   ├── __init__()              # 参数初始化(含norm)
│   ├── __sklearn_tags__()      # positive_only标签
│   ├── _count()                # 额外计算feature_all_
│   ├── _update_feature_log_prob() # 补集权重计算(含norm选项)
│   └── _joint_log_likelihood() # 补集评分
├── BernoulliNB(第879-1037行)
│   ├── __init__()              # 参数初始化(含binarize)
│   ├── _check_X()              # 二值化输入
│   ├── _check_X_y()            # fit中的二值化处理
│   ├── _count()                # safe_sparse_dot计数
│   ├── _update_feature_log_prob() # 二元平滑公式
│   └── _joint_log_likelihood() # 使用neg_prob技巧
└── CategoricalNB(第1040-1210行)
    ├── __init__()             # 参数初始化(含min_categories)
    ├── fit()                  # 继承基类,提供文档
    ├── partial_fit()          # 继承基类,提供文档
    ├── __sklearn_tags__()     # categorical标签,禁用sparse
    ├── _check_X()             # 整数输入验证
    ├── _check_X_y()           # fit中的整数输入验证
    ├── _init_counters()       # 初始化category_count_列表
    ├── _validate_n_categories() # 确定每特征的类别数
    ├── _count()               # np.bincount按类别计数
    ├── _update_feature_log_prob() # 逐特征独立平滑
    └── _joint_log_likelihood() # 高级索引累加
sklearn/tests/test_naive_bayes.py
├── get_random_normal_x_binary_y() # 生成二分类高斯测试数据
├── get_random_integer_x_three_classes_y() # 生成三分类整数测试数据
├── test_gnb()                    # 高斯NB基本拟合与预测
├── test_gnb_prior()             # 先验正确性验证
├── test_gnb_sample_weight()     # 样本权重增量拟合一致性
├── test_gnb_neg_priors()       # 负数先验错误处理
├── test_gnb_priors()           # 先验覆盖与验证
├── test_gnb_priors_sum_isclose() # 10类先验求和近似1
├── test_gnb_wrong_nb_priors()  # 先验数量不匹配
├── test_gnb_prior_greater_one() # 先验和大于1
├── test_gnb_prior_large_bias() # 先验严重偏置时的预测
├── test_gnb_check_update_with_no_data() # 空数据在线更新
├── test_gnb_partial_fit()      # 增量拟合一致性验证
├── test_gnb_naive_bayes_scale_invariance() # 尺度不变性
├── test_discretenb_prior()      # 离散NB先验验证
├── test_discretenb_partial_fit() # 离散NB增量拟合
├── test_NB_partial_fit_no_first_classes() # 首次partial_fit缺少classes
├── test_discretenb_predict_proba() # 离散NB概率评分
├── test_discretenb_uniform_prior() # fit_prior=False均匀先验
├── test_discretenb_provide_prior() # 用户指定先验
├── test_discretenb_provide_prior_with_partial_fit() # 增量拟合中的先验
├── test_discretenb_sample_weight_multiclass() # 多样本权重多分类
├── test_discretenb_degenerate_one_class_case() # 单类别退化场景
├── test_mnnb()                  # 多项NB稠密/稀疏输入
├── test_mnb_prior_unobserved_targets() # 未观测类别先验平滑
├── test_bnb()                  # 伯努利NB教科书例子验证
├── test_bnb_feature_log_prob() # 伯努利特征对数概率手工验证
├── test_cnb()                  # 补集NB权重手工验证
├── test_categoricalnb()        # 类别NB计数与预测
├── test_categoricalnb_with_min_categories() # 最小类别数
├── test_categoricalnb_min_categories_errors() # min_categories错误
├── test_alpha()                # 平滑参数边界行为
├── test_alpha_vector()         # 向量alpha支持
├── test_check_accuracy_on_digits() # 真实数据集性能基准
├── test_predict_joint_proba()  # 联合对数概率与logsumexp一致性
├── test_categorical_input_tag() # 类别输入标签验证
└── test_gnb_array_api_compliance() # Array API后端一致性

13.4 概率推理骨架 —— 所有朴素贝叶斯分类器的“共用引擎”

13.4.1 为什么需要 _BaseNB 抽象基类?

所有朴素贝叶斯变体共享相同的预测流程:先计算联合对数似然,再归一化为概率。通过抽象方法 _joint_log_likelihood_check_X 强制子类实现各自的核心逻辑,继承 ClassifierMixinBaseEstimator 获得统一的评分接口与参数管理能力。模块级 __main__ 通过 __all__ 导出五个公开估计器类。

我们先从模块入口看起,理解公共 API 的组织方式。

# 第 13 章 —— sklearn/naive_bayes.py (第1-53行)
"""Naive Bayes algorithms.

These are supervised learning methods based on applying Bayes' theorem with strong
(naive) feature independence assumptions.
"""

# 第 13 章 —— Authors: The scikit-learn developers
# 第 13 章 —— SPDX-License-Identifier: BSD-3-Clause

import warnings
from abc import ABCMeta, abstractmethod
from numbers import Integral, Real

import numpy as np

import sklearn.externals.array_api_extra as xpx
from sklearn.base import BaseEstimator, ClassifierMixin, _fit_context
from sklearn.preprocessing import LabelBinarizer, binarize, label_binarize
from sklearn.utils._array_api import (
    _average,
    _convert_to_numpy,
    _find_matching_floating_dtype,
    _isin,
    _logsumexp,
    get_namespace,
    get_namespace_and_device,
    size,
)
from sklearn.utils._param_validation import Interval
from sklearn.utils.extmath import safe_sparse_dot
from sklearn.utils.multiclass import _check_partial_fit_first_call
from sklearn.utils.validation import (
    _check_n_features,
    _check_sample_weight,
    check_is_fitted,
    check_non_negative,
    validate_data,
)

__all__ = [
    "BernoulliNB",
    "CategoricalNB",
    "ComplementNB",
    "GaussianNB",
    "MultinomialNB",
]

这段代码定义了模块的公共导出接口,引入了核心依赖:Array API 兼容层(xpxget_namespace 等)、参数验证工具、稀疏矩阵运算(safe_sparse_dot)以及基类 BaseEstimatorClassifierMixin


13.4.2 _BaseNB 类型定义:预测流程的“设计图纸”

# 第 13 章 —— sklearn/naive_bayes.py (第44-177行)
class _BaseNB(ClassifierMixin, BaseEstimator, metaclass=ABCMeta):
    """Abstract base class for naive Bayes estimators"""

    @abstractmethod
    def _joint_log_likelihood(self, X):
        """Compute the unnormalized posterior log probability of X
        ...
        """

    @abstractmethod
    def _check_X(self, X):
        """To be overridden in subclasses with the actual checks.
        Only used in predict* methods.
        """

    def predict_joint_log_proba(self, X):
        check_is_fitted(self)
        X = self._check_X(X)
        return self._joint_log_likelihood(X)

    def predict(self, X):
        check_is_fitted(self)
        xp, _ = get_namespace(X)
        X = self._check_X(X)
        jll = self._joint_log_likelihood(X)
        pred_indices = xp.argmax(jll, axis=1)
        if isinstance(self.classes_[0], str):
            pred_indices = _convert_to_numpy(pred_indices, xp=xp)
        return self.classes_[pred_indices]

    def predict_log_proba(self, X):
        check_is_fitted(self)
        xp, _ = get_namespace(X)
        X = self._check_X(X)
        jll = self._joint_log_likelihood(X)
        # normalize by P(x) = P(f_1, ..., f_n)
        log_prob_x = _logsumexp(jll, axis=1, xp=xp)
        return jll - xpx.atleast_nd(log_prob_x, ndim=2).T

    def predict_proba(self, X):
        xp, _ = get_namespace(X)
        return xp.exp(self.predict_log_proba(X))

这段代码实现了所有朴素贝叶斯分类器共享的四步预测流水线

  1. predict_joint_log_proba:直接返回未归一化的联合对数概率 \(\log P(y) + \log P(x|y)\)

  2. predict:对联合对数似然按行取 argmax 得到类别索引,处理字符串标签时需转回 NumPy 再索引 classes_

  3. predict_log_proba:核心归一化步骤,使用 _logsumexp 计算 \(\log P(x) = \log \sum_y \exp(\text{jll})\),再通过广播相减得到 \(\log P(y|x)\)。关键行 xpx.atleast_nd(log_prob_x, ndim=2).T 将形状从 (n_samples,) 扩展为 (n_samples, 1) 再转置为 (1, n_samples),使其能与形状为 (n_samples, n_classes)jll 正确广播相减。

  4. predict_proba:对对数概率取指数,得到最终概率矩阵,每行和为 1。


13.4.3 test_predict_joint_proba 验证概率一致性

# 第 13 章 —— sklearn/tests/test_naive_bayes.py (第510-518行)
@pytest.mark.parametrize("Estimator", ALL_NAIVE_BAYES_CLASSES)
def test_predict_joint_proba(Estimator, global_random_seed):
    X2, y2 = get_random_integer_x_three_classes_y(global_random_seed)
    est = Estimator().fit(X2, y2)
    jll = est.predict_joint_log_proba(X2)
    log_prob_x = logsumexp(jll, axis=1)
    log_prob_x_y = jll - np.atleast_2d(log_prob_x).T
    assert_allclose(est.predict_log_proba(X2), log_prob_x_y, atol=1e-12)

该测试使用 SciPy 的 logsumexp 作为参考实现,验证所有估计器的 predict_log_proba 与手工归一化结果在数值上完全一致,保证了概率推理骨架的数学正确性。


13.5 高斯朴素贝叶斯 —— 连续特征的“在线贝叶斯引擎”

13.5.1 核心假设与参数设计

GaussianNB 假设每个类别的每个特征服从独立的高斯分布,只存储均值 theta_ 和方差 var_,忽略特征间协方差。__init__ 通过 _parameter_constraints 声明参数约束:priors 可为数组或 None,var_smoothing 为非负实数。

# 第 13 章 —— sklearn/naive_bayes.py (第218-222行)
def __init__(self, *, priors=None, var_smoothing=1e-9):
    self.priors = priors
    self.var_smoothing = var_smoothing

13.5.2 fit_partial_fit:增量学习的统一入口

# 第 13 章 —— sklearn/naive_bayes.py (第224-252行)
@_fit_context(prefer_skip_nested_validation=True)
def fit(self, X, y, sample_weight=None):
    y = validate_data(self, y=y)
    xp_y, _ = get_namespace(y)
    return self._partial_fit(
        X, y, xp_y.unique_values(y), _refit=True, sample_weight=sample_weight
    )

fit 先用 validate_data 验证 y(同时设置 n_features_in_ 等属性),再调用 _partial_fit,传入 _refit=True 表示重新初始化。_partial_fit 是增量学习的核心实现,fit 只是其特例。


13.5.3 _update_mean_variance:Chan-Golub-LeVeque 在线更新算法

这是高斯 NB 支持增量学习的数学核心,只需维护三个充分统计量:样本数 n_past、均值 mu、方差 var

# 第 13 章 —— sklearn/naive_bayes.py (第260-318行)
@staticmethod
def _update_mean_variance(n_past, mu, var, X, sample_weight=None):
    xp, _ = get_namespace(X)
    if X.shape[0] == 0:
        return mu, var

    if sample_weight is not None:
        n_new = float(xp.sum(sample_weight))
        if np.isclose(n_new, 0.0):
            return mu, var
        new_mu = _average(X, axis=0, weights=sample_weight, xp=xp)
        new_var = _average((X - new_mu) ** 2, axis=0, weights=sample_weight, xp=xp)
    else:
        n_new = X.shape[0]
        new_var = xp.var(X, axis=0)
        new_mu = xp.mean(X, axis=0)

    if n_past == 0:
        return new_mu, new_var

    n_total = float(n_past + n_new)
    total_mu = (n_new * new_mu + n_past * mu) / n_total

    old_ssd = n_past * var
    new_ssd = n_new * new_var
    total_ssd = old_ssd + new_ssd + (n_new * n_past / n_total) * (mu - new_mu) ** 2
    total_var = total_ssd / n_total

    return total_mu, total_var

逐行解析

  1. 获取 Array API 命名空间 xp,支持跨后端计算。

  2. 若新数据为空,直接返回旧统计量(test_gnb_check_update_with_no_data 验证此行为)。

  3. 加权均值/方差:若有 sample_weightn_new 为权重之和,用 _average 计算加权均值与方差;权重和接近 0 时视为无新数据。

  4. 无权重情况:直接用 xp.mean/var 计算新批次统计量。

  5. 首次初始化n_past == 0 时直接返回新统计量,这是 _partial_fit 首次调用的初始化路径。

  6. 合并均值:加权平均公式 \(\mu_{total} = \frac{n_{new}\mu_{new} + n_{past}\mu_{past}}{n_{total}}\)

  7. 合并方差(核心公式):利用离差平方和(SSD)可加性,\(SSD_{total} = SSD_{old} + SSD_{new} + \frac{n_{new}n_{past}}{n_{total}}(\mu_{past} - \mu_{new})^2\)。最后一项是均值差异修正项,反映新旧数据中心偏移带来的额外方差。


13.5.4 _partial_fit:逐类别更新统计量

# 第 13 章 —— sklearn/naive_bayes.py (第346-444行)
def _partial_fit(self, X, y, classes=None, _refit=False, sample_weight=None):
    if _refit:
        self.classes_ = None

    first_call = _check_partial_fit_first_call(self, classes)
    X, y = validate_data(self, X, y, reset=first_call)
    xp, _, device_ = get_namespace_and_device(X)
    float_dtype = _find_matching_floating_dtype(X, xp=xp)
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X, dtype=float_dtype)

    xp_y, _ = get_namespace(y)
    self.epsilon_ = self.var_smoothing * xp.max(xp.var(X, axis=0))

    if first_call:
        n_features = X.shape[1]
        n_classes = self.classes_.shape[0]
        self.theta_ = xp.zeros((n_classes, n_features), dtype=float_dtype, device=device_)
        self.var_ = xp.zeros((n_classes, n_features), dtype=float_dtype, device=device_)
        self.class_count_ = xp.zeros(n_classes, dtype=float_dtype, device=device_)

        if self.priors is not None:
            priors = xp.asarray(self.priors, dtype=float_dtype, device=device_)
            if priors.shape[0] != n_classes:
                raise ValueError("Number of priors must match number of classes.")
            if not xpx.isclose(xp.sum(priors), 1.0):
                raise ValueError("The sum of the priors should be 1.")
            if xp.any(priors < 0):
                raise ValueError("Priors must be non-negative.")
            self.class_prior_ = priors
        else:
            self.class_prior_ = xp.zeros(self.classes_.shape[0], dtype=float_dtype, device=device_)
    else:
        if X.shape[1] != self.theta_.shape[1]:
            raise ValueError("Number of features %d does not match previous data %d." % (X.shape[1], self.theta_.shape[1]))
        self.var_[:, :] -= self.epsilon_

    classes = self.classes_
    unique_y = xp_y.unique_values(y)
    unique_y_in_classes = _isin(unique_y, classes, xp=xp_y)

    if not xp_y.all(unique_y_in_classes):
        raise ValueError("The target label(s) %s in y do not exist in the initial classes %s" % (unique_y[~unique_y_in_classes], classes))

    for y_i in unique_y:
        i = int(xp_y.searchsorted(classes, y_i))
        y_i_mask = xp.asarray(y == y_i, device=device_)
        X_i = X[y_i_mask]

        if sample_weight is not None:
            sw_i = sample_weight[y_i_mask]
            N_i = xp.sum(sw_i)
        else:
            sw_i = None
            N_i = X_i.shape[0]

        new_theta, new_sigma = self._update_mean_variance(
            self.class_count_[i], self.theta_[i, :], self.var_[i, :], X_i, sw_i
        )

        self.theta_[i, :] = new_theta
        self.var_[i, :] = new_sigma
        self.class_count_[i] += N_i

    self.var_[:, :] += self.epsilon_

    if self.priors is None:
        self.class_prior_ = self.class_count_ / xp.sum(self.class_count_)

    return self

关键流程

  1. 首次调用初始化:分配 theta_var_class_count_ 零矩阵;处理用户提供的 priors(验证形状、和为 1、非负),否则先验初始化为 0 待后续计算。

  2. 非首次调用:检查特征数一致性,暂时减去 epsilon_ 以获取原始方差用于合并。

  3. 逐类别循环:对 y 中出现的每个类别 y_i,用布尔掩码提取该类样本 X_i 及对应权重,调用 _update_mean_variance 合并统计量,累加 class_count_

  4. 恢复平滑:循环结束后统一加回 epsilon_

  5. 经验先验更新:若未提供先验,按 class_count_ 比例计算 class_prior_


13.5.5 _joint_log_likelihood:高斯对数似然计算

# 第 13 章 —— sklearn/naive_bayes.py (第446-458行)
def _joint_log_likelihood(self, X):
    xp, _ = get_namespace(X)
    joint_log_likelihood = []
    for i in range(size(self.classes_)):
        jointi = xp.log(self.class_prior_[i])
        n_ij = -0.5 * xp.sum(xp.log(2.0 * xp.pi * self.var_[i, :]))
        n_ij = n_ij - 0.5 * xp.sum(
            ((X - self.theta_[i, :]) ** 2) / (self.var_[i, :]), axis=1
        )
        joint_log_likelihood.append(jointi + n_ij)

    joint_log_likelihood = xp.stack(joint_log_likelihood).T
    return joint_log_likelihood

对每个类别 \(c\),计算 \(\log P(c) + \sum_f [-\frac{1}{2}\log(2\pi\sigma_{cf}^2) - \frac{(x_f - \mu_{cf})^2}{2\sigma_{cf}^2}]\),最后用 xp.stack 堆叠并转置为 (n_samples, n_classes)


13.5.6 __sklearn_tags__:声明 Array API 支持

# 第 13 章 —— sklearn/naive_bayes.py (第460-463行)
def __sklearn_tags__(self):
    tags = super().__sklearn_tags__()
    tags.array_api_support = True
    return tags

显式设置 array_api_support=True,使 GaussianNB 能在 CuPy、PyTorch 等 Array API 后端上运行,test_gnb_array_api_compliance 会验证这一行为。


13.5.7 测试验证:从基础拟合到边界场景

# 第 13 章 —— sklearn/tests/test_naive_bayes.py (第48-194行,节选)
def test_gnb():
    clf = GaussianNB()
    y_pred = clf.fit(X, y).predict(X)
    assert_array_equal(y_pred, y)
    assert_array_almost_equal(np.log(clf.predict_proba(X)), clf.predict_log_proba(X), 8)

def test_gnb_prior(global_random_seed):
    clf = GaussianNB().fit(X, y)
    assert_array_almost_equal(np.array([3, 3]) / 6.0, clf.class_prior_, 8)
    X1, y1 = get_random_normal_x_binary_y(global_random_seed)
    clf = GaussianNB().fit(X1, y1)
    assert_array_almost_equal(clf.class_prior_.sum(), 1)

def test_gnb_sample_weight(global_random_seed):
    sw = np.ones(6)
    clf = GaussianNB().fit(X, y)
    clf_sw = GaussianNB().fit(X, y, sw)
    assert_array_almost_equal(clf.theta_, clf_sw.theta_)
    # ... 验证分批权重等价于全量权重、重复样本等价于权重

def test_gnb_neg_priors():
    clf = GaussianNB(priors=np.array([-1.0, 2.0]))
    with pytest.raises(ValueError, match="Priors must be non-negative"):
        clf.fit(X, y)

def test_gnb_priors():
    clf = GaussianNB(priors=np.array([0.3, 0.7])).fit(X, y)
    assert_array_almost_equal(clf.predict_proba([[-0.1, -0.1]]), np.array([[0.8253, 0.1747]]), 8)

def test_gnb_priors_sum_isclose():
    priors = np.array([0.08, 0.14, 0.03, 0.16, 0.11, 0.16, 0.07, 0.14, 0.11, 0.0])
    clf = GaussianNB(priors=priors).fit(X, Y)  # 10 classes, sum ~= 1

def test_gnb_wrong_nb_priors():
    clf = GaussianNB(priors=np.array([0.25]*4))
    with pytest.raises(ValueError, match="Number of priors must match number of classes"):
        clf.fit(X, y)

def test_gnb_prior_greater_one():
    clf = GaussianNB(priors=np.array([2.0, 1.0]))
    with pytest.raises(ValueError, match="The sum of the priors should be 1"):
        clf.fit(X, y)

def test_gnb_prior_large_bias():
    clf = GaussianNB(priors=np.array([0.01, 0.99]))
    clf.fit(X, y)
    assert clf.predict([[-0.1, -0.1]]) == np.array([2])  # 偏向先验大的类别

def test_gnb_check_update_with_no_data():
    tmean, tvar = GaussianNB._update_mean_variance(100, 0.0, 1.0, np.empty((0, 2)))
    assert tmean == 0.0 and tvar == 1.0

def test_gnb_partial_fit(global_dtype):
    clf = GaussianNB().fit(X_, y)
    clf_pf = GaussianNB().partial_fit(X_, y, np.unique(y))
    for attr in ("class_prior_", "theta_", "var_"):
        assert_array_almost_equal(getattr(clf, attr), getattr(clf_pf, attr))
    # 分两批 partial_fit 结果一致

这些测试覆盖了:基本拟合预测、经验先验正确性、样本权重与重复样本等价性、先验参数的各类错误处理(负数、数量不匹配、和大于 1)、严重偏置先验下的预测行为、空数据增量更新的幂等性、增量拟合与全量拟合的一致性、以及数据缩放不变性。


13.5.8 数据流图:高斯 NB 增量学习流程

flowchart TD A[fit/partial_fit 调用] --> B{_refit 或首次调用?} B -- 是 --> C[初始化 theta_, var_, class_count_ 为零矩阵] B -- 否 --> D[var_ 减去 epsilon_] C --> E[验证/设置 priors] D --> F[逐类别 unique_y 循环] E --> F F --> G[布尔掩码提取类别样本 X_i] G --> H[提取对应 sample_weight] H --> I[_update_mean_variance 合并统计量] I --> J[更新 theta_[i], var_[i], class_count_[i]] J --> K{遍历完所有类别?} K -- 否 --> F K -- 是 --> L[var_ 加回 epsilon_] L --> M{priors 为 None?} M -- 是 --> N[class_prior_ = class_count_ / sum] M -- 否 --> O[保留用户 priors] N --> P[返回 self] O --> P

13.6 离散朴素贝叶斯基座 —— 计数与平滑的“共享流水线”

13.6.1 _BaseDiscreteNB:四个离散变体的公共抽象

# 第 13 章 —— sklearn/naive_bayes.py (第461-532行,节选)
class _BaseDiscreteNB(_BaseNB):
    _parameter_constraints: dict = {
        "alpha": [Interval(Real, 0, None, closed="left"), "array-like"],
        "fit_prior": ["boolean"],
        "class_prior": ["array-like", None],
        "force_alpha": ["boolean"],
    }

    def __init__(self, alpha=1.0, fit_prior=True, class_prior=None, force_alpha=True):
        self.alpha = alpha
        self.fit_prior = fit_prior
        self.class_prior = class_prior
        self.force_alpha = force_alpha

    @abstractmethod
    def _count(self, X, Y):
        """Update counts: class_count_ and feature_count_ must be updated here."""

    @abstractmethod
    def _update_feature_log_prob(self, alpha):
        """Apply smoothing to raw counts and recompute log probabilities."""

    def _check_X(self, X):
        return validate_data(self, X, accept_sparse="csr", reset=False)

    def _check_X_y(self, X, y, reset=True):
        return validate_data(self, X, y, accept_sparse="csr", reset=reset)

_BaseDiscreteNB 定义了离散 NB 的公共参数:alpha(平滑参数,可为标量或数组)、fit_prior(是否学习先验)、class_prior(用户指定先验)、force_alpha(是否强制保留极小 alpha)。两个抽象方法 _count_update_feature_log_prob 强制子类实现特定的计数与平滑逻辑。输入验证接受 CSR 稀疏矩阵。


13.6.2 fitpartial_fit:标签二值化的分叉入口

# 第 13 章 —— sklearn/naive_bayes.py (第553-650行,节选)
@_fit_context(prefer_skip_nested_validation=True)
def fit(self, X, y, sample_weight=None):
    X, y = self._check_X_y(X, y)
    _, n_features = X.shape

    labelbin = LabelBinarizer()
    Y = labelbin.fit_transform(y)
    self.classes_ = labelbin.classes_
    if Y.shape[1] == 1:
        if len(self.classes_) == 2:
            Y = np.concatenate((1 - Y, Y), axis=1)
        else:
            Y = np.ones_like(Y)

    if sample_weight is not None:
        Y = Y.astype(np.float64, copy=False)
        sample_weight = _check_sample_weight(sample_weight, X)
        sample_weight = np.atleast_2d(sample_weight)
        Y *= sample_weight.T

    class_prior = self.class_prior
    n_classes = Y.shape[1]
    self._init_counters(n_classes, n_features)
    self._count(X, Y)
    alpha = self._check_alpha()
    self._update_feature_log_prob(alpha)
    self._update_class_log_prior(class_prior=class_prior)
    return self

@_fit_context(prefer_skip_nested_validation=True)
def partial_fit(self, X, y, classes=None, sample_weight=None):
    first_call = not hasattr(self, "classes_")
    X, y = self._check_X_y(X, y, reset=first_call)
    _, n_features = X.shape

    if _check_partial_fit_first_call(self, classes):
        n_classes = len(classes)
        self._init_counters(n_classes, n_features)

    Y = label_binarize(y, classes=self.classes_)
    if Y.shape[1] == 1:
        if len(self.classes_) == 2:
            Y = np.concatenate((1 - Y, Y), axis=1)
        else:
            Y = np.ones_like(Y)

    if X.shape[0] != Y.shape[0]:
        raise ValueError("X.shape[0]=%d and y.shape[0]=%d are incompatible." % (X.shape[0], y.shape[0]))

    Y = Y.astype(np.float64, copy=False)
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
        sample_weight = np.atleast_2d(sample_weight)
        Y *= sample_weight.T

    class_prior = self.class_prior
    self._count(X, Y)
    alpha = self._check_alpha()
    self._update_feature_log_prob(alpha)
    self._update_class_log_prior(class_prior=class_prior)
    return self

关键差异

  • fit 使用 LabelBinarizer 自动发现类别,partial_fit 要求首次调用必须显式传入 classes(由 _check_partial_fit_first_call 强制),后续调用复用 self.classes_

  • 二分类特殊处理:LabelBinarizer 仅输出 1 列,需拼接 (1-Y, Y) 形成两列;单类别退化情况全置 1(test_discretenb_degenerate_one_class_case 验证)。

  • 样本权重通过 Y *= sample_weight.T 广播乘法注入计数矩阵,实现加权计数。


13.6.3 _update_class_log_prior:三类先验策略统一调度

# 第 13 章 —— sklearn/naive_bayes.py (第511-528行)
def _update_class_log_prior(self, class_prior=None):
    n_classes = len(self.classes_)
    if class_prior is not None:
        if len(class_prior) != n_classes:
            raise ValueError("Number of priors must match number of classes.")
        self.class_log_prior_ = np.log(class_prior)
    elif self.fit_prior:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", RuntimeWarning)
            log_class_count = np.log(self.class_count_)
        self.class_log_prior_ = log_class_count - np.log(self.class_count_.sum())
    else:
        self.class_log_prior_ = np.full(n_classes, -np.log(n_classes))

三种策略优先级:用户指定 class_prior > 经验先验(fit_prior=True)> 均匀先验(fit_prior=False)。经验先验计算时用 warnings.catch_warnings 静默 log(0) 产生的 RuntimeWarning(针对未观测类别,test_mnb_prior_unobserved_targets 验证此场景)。


13.6.4 _check_alpha:平滑参数的“安全阀”

# 第 13 章 —— sklearn/naive_bayes.py (第530-551行)
def _check_alpha(self):
    alpha = np.asarray(self.alpha) if not isinstance(self.alpha, Real) else self.alpha
    alpha_min = np.min(alpha)
    if isinstance(alpha, np.ndarray):
        if not alpha.shape[0] == self.n_features_in_:
            raise ValueError("When alpha is an array, it should contains `n_features`. "
                             f"Got {alpha.shape[0]} elements instead of {self.n_features_in_}.")
        if alpha_min < 0:
            raise ValueError("All values in alpha must be greater than 0.")
    alpha_lower_bound = 1e-10
    if alpha_min < alpha_lower_bound and not self.force_alpha:
        warnings.warn(
            "alpha too small will result in numeric errors, setting alpha ="
            f" {alpha_lower_bound:.1e}. Use `force_alpha=True` to keep alpha"
            " unchanged."
        )
        return np.maximum(alpha, alpha_lower_bound)
    return alpha
  • 统一将 alpha 转为数组,检查维度与非负性。

  • alpha < 1e-10force_alpha=False,自动提升至 1e-10 并发警告(test_alphatest_alpha_vector 验证)。

  • force_alpha=True 时保留原始值,数值风险由用户自担。


13.6.5 _init_counters__sklearn_tags__

# 第 13 章 —— sklearn/naive_bayes.py (第652-660行)
def _init_counters(self, n_classes, n_features):
    self.class_count_ = np.zeros(n_classes, dtype=np.float64)
    self.feature_count_ = np.zeros((n_classes, n_features), dtype=np.float64)

def __sklearn_tags__(self):
    tags = super().__sklearn_tags__()
    tags.input_tags.sparse = True
    tags.classifier_tags.poor_score = True
    return tags

初始化两个核心计数矩阵。标签声明支持稀疏输入(sparse=True),并标记 poor_score=True 提示该分类器在某些数据集上评分可能较差(如概率校准不佳)。


13.6.6 测试验证:离散 NB 通用行为

# 第 13 章 —— sklearn/tests/test_naive_bayes.py (第196-352行,节选)
@pytest.mark.parametrize("DiscreteNaiveBayes", DISCRETE_NAIVE_BAYES_CLASSES)
def test_discretenb_prior(DiscreteNaiveBayes, global_random_seed):
    X2, y2 = get_random_integer_x_three_classes_y(global_random_seed)
    clf = DiscreteNaiveBayes().fit(X2, y2)
    assert_array_almost_equal(np.log(np.array([2, 2, 2]) / 6.0), clf.class_log_prior_, 8)

def test_discretenb_partial_fit(DiscreteNaiveBayes):
    clf1 = DiscreteNaiveBayes().fit([[0, 1], [1, 0], [1, 1]], [0, 1, 1])
    clf2 = DiscreteNaiveBayes().partial_fit([[0, 1], [1, 0], [1, 1]], [0, 1, 1], classes=[0, 1])
    assert_array_equal(clf1.class_count_, clf2.class_count_)
    # CategoricalNB 特殊比较 category_count_ 形状与列和

def test_NB_partial_fit_no_first_classes(NaiveBayes, global_random_seed):
    X2, y2 = get_random_integer_x_three_classes_y(global_random_seed)
    with pytest.raises(ValueError, match="classes must be passed on the first call"):
        NaiveBayes().partial_fit(X2, y2)

def test_discretenb_predict_proba():
    # 二分类:predict_proba 形状 (1,2),行和为 1
    # 多分类:形状 (n,3),行和为 1
    # 验证 exp(class_log_prior_) 求和为 1

13.7 离散特征模型 —— 四种“计数哲学家”的朴素贝叶斯变体

13.7.1 MultinomialNB:词袋模型的“经典代表”

# 第 13 章 —— sklearn/naive_bayes.py (第735-765行)
class MultinomialNB(_BaseDiscreteNB):
    def __init__(self, *, alpha=1.0, force_alpha=True, fit_prior=True, class_prior=None):
        super().__init__(alpha=alpha, fit_prior=fit_prior, class_prior=class_prior, force_alpha=force_alpha)

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.input_tags.positive_only = True
        return tags

    def _count(self, X, Y):
        check_non_negative(X, "MultinomialNB (input X)")
        self.feature_count_ += safe_sparse_dot(Y.T, X)
        self.class_count_ += Y.sum(axis=0)

    def _update_feature_log_prob(self, alpha):
        smoothed_fc = self.feature_count_ + alpha
        smoothed_cc = smoothed_fc.sum(axis=1)
        self.feature_log_prob_ = np.log(smoothed_fc) - np.log(smoothed_cc.reshape(-1, 1))

    def _joint_log_likelihood(self, X):
        return safe_sparse_dot(X, self.feature_log_prob_.T) + self.class_log_prior_
  • 计数safe_sparse_dot(Y.T, X) 高效计算 (class, feature) 加权计数,支持稀疏矩阵。

  • 平滑:拉普拉斯/利德斯通平滑 \(\log(\frac{N_{cf} + \alpha}{\sum_f N_{cf} + \alpha \cdot n_{features}})\)

  • 预测:矩阵乘法 X · feature_log_prob_.T + class_log_prior_

  • 标签positive_only=True 声明输入必须非负。


13.7.2 ComplementNB:不平衡数据集的“纠偏专家”

# 第 13 章 —— sklearn/naive_bayes.py (第834-879行)
class ComplementNB(_BaseDiscreteNB):
    _parameter_constraints: dict = {
        **_BaseDiscreteNB._parameter_constraints,
        "norm": ["boolean"],
    }

    def __init__(self, *, alpha=1.0, force_alpha=True, fit_prior=True, class_prior=None, norm=False):
        super().__init__(alpha=alpha, force_alpha=force_alpha, fit_prior=fit_prior, class_prior=class_prior)
        self.norm = norm

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.input_tags.positive_only = True
        return tags

    def _count(self, X, Y):
        check_non_negative(X, "ComplementNB (input X)")
        self.feature_count_ += safe_sparse_dot(Y.T, X)
        self.class_count_ += Y.sum(axis=0)
        self.feature_all_ = self.feature_count_.sum(axis=0)  # 额外维护:所有类别的特征总计数

    def _update_feature_log_prob(self, alpha):
        comp_count = self.feature_all_ + alpha - self.feature_count_  # 补集计数
        logged = np.log(comp_count / comp_count.sum(axis=1, keepdims=True))
        if self.norm:
            summed = logged.sum(axis=1, keepdims=True)
            feature_log_prob = logged / summed  # L1 归一化
        else:
            feature_log_prob = -logged  # 负对数似然,argmax 变 argmin
        self.feature_log_prob_ = feature_log_prob

    def _joint_log_likelihood(self, X):
        jll = safe_sparse_dot(X, self.feature_log_prob_.T)
        if len(self.classes_) == 1:
            jll += self.class_log_prior_
        return jll

核心思想:标准多项式 NB 计算 \(P(f|c)\),而补集 NB 计算 \(P(f|\neg c)\),用“其他类别的特征分布”来惩罚在多数类中频繁出现的特征,适合类别不平衡场景。norm=False 时取负对数(原始论文做法),norm=True 时按行 L1 归一化(Mahout/Weka 默认行为)。单类别退化时直接加上先验。


13.7.3 BernoulliNB:二元特征的“布尔逻辑家”

# 第 13 章 —— sklearn/naive_bayes.py (第972-1028行,节选)
class BernoulliNB(_BaseDiscreteNB):
    _parameter_constraints: dict = {
        **_BaseDiscreteNB._parameter_constraints,
        "binarize": [None, Interval(Real, 0, None, closed="left")],
    }

    def __init__(self, *, alpha=1.0, force_alpha=True, binarize=0.0, fit_prior=True, class_prior=None):
        super().__init__(alpha=alpha, fit_prior=fit_prior, class_prior=class_prior, force_alpha=force_alpha)
        self.binarize = binarize

    def _check_X(self, X):
        X = super()._check_X(X)
        if self.binarize is not None:
            X = binarize(X, threshold=self.binarize)
        return X

    def _check_X_y(self, X, y, reset=True):
        X, y = super()._check_X_y(X, y, reset=reset)
        if self.binarize is not None:
            X = binarize(X, threshold=self.binarize)
        return X, y

    def _count(self, X, Y):
        self.feature_count_ += safe_sparse_dot(Y.T, X)
        self.class_count_ += Y.sum(axis=0)

    def _update_feature_log_prob(self, alpha):
        smoothed_fc = self.feature_count_ + alpha
        smoothed_cc = self.class_count_ + alpha * 2  # 二元特征:每类每特征有“出现/不出现”两种情况
        self.feature_log_prob_ = np.log(smoothed_fc) - np.log(smoothed_cc.reshape(-1, 1))

    def _joint_log_likelihood(self, X):
        n_features = self.feature_log_prob_.shape[1]
        n_features_X = X.shape[1]
        if n_features_X != n_features:
            raise ValueError("Expected input with %d features, got %d instead" % (n_features, n_features_X))

        neg_prob = np.log(1 - np.exp(self.feature_log_prob_))
        # neg_prob · (1 - X).T = sum(neg_prob) - X · neg_prob.T
        jll = safe_sparse_dot(X, (self.feature_log_prob_ - neg_prob).T)
        jll += self.class_log_prior_ + neg_prob.sum(axis=1)
        return jll
  • 二值化binarize 参数控制阈值,_check_X/_check_X_y 在验证后自动二值化。

  • 平滑分母class_count_ + 2*alpha,因为每个二元特征每类有两个可能取值(0/1),等价于每特征两个类别的多项分布。

  • neg_prob 技巧:避免显式计算 \((1-X)\) 矩阵,利用 \(\log(1-p) = \log(1-\exp(\log p))\) 与线性代数恒等式 \(\sum \log(1-p_f) - X \cdot \log(1-p_f)^T\) 高效计算。


13.7.4 CategoricalNB:类别特征的“字典编码器”

# 第 13 章 —— sklearn/naive_bayes.py (第1112-1267行,节选)
class CategoricalNB(_BaseDiscreteNB):
    _parameter_constraints: dict = {
        **_BaseDiscreteNB._parameter_constraints,
        "min_categories": [None, "array-like", Interval(Integral, 1, None, closed="left")],
        "alpha": [Interval(Real, 0, None, closed="left")],
    }

    def __init__(self, *, alpha=1.0, force_alpha=True, fit_prior=True, class_prior=None, min_categories=None):
        super().__init__(alpha=alpha, force_alpha=force_alpha, fit_prior=fit_prior, class_prior=class_prior)
        self.min_categories = min_categories

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.input_tags.categorical = True
        tags.input_tags.sparse = False
        tags.input_tags.positive_only = True
        return tags

    def _check_X(self, X):
        X = validate_data(self, X, dtype="int", accept_sparse=False, ensure_all_finite=True, reset=False)
        check_non_negative(X, "CategoricalNB (input X)")
        return X

    def _init_counters(self, n_classes, n_features):
        self.class_count_ = np.zeros(n_classes, dtype=np.float64)
        self.category_count_ = [np.zeros((n_classes, 0)) for _ in range(n_features)]  # 列表:每特征一个矩阵

    def _validate_n_categories(self, X, min_categories):
        n_categories_X = X.max(axis=0) + 1
        if min_categories is not None:
            min_categories_ = np.array(min_categories)
            if not np.issubdtype(min_categories_.dtype, np.signedinteger):
                raise ValueError("'min_categories' should have integral type...")
            n_categories_ = np.maximum(n_categories_X, min_categories_, dtype=np.int64)
            if n_categories_.shape != n_categories_X.shape:
                raise ValueError("'min_categories' should have shape ({X.shape[1]},)...")
            return n_categories_
        else:
            return n_categories_X

    def _count(self, X, Y):
        def _update_cat_count_dims(cat_count, highest_feature):
            diff = highest_feature + 1 - cat_count.shape[1]
            if diff > 0:
                return np.pad(cat_count, [(0, 0), (0, diff)], "constant")
            return cat_count

        def _update_cat_count(X_feature, Y, cat_count, n_classes):
            for j in range(n_classes):
                mask = Y[:, j].astype(bool)
                weights = None if Y.dtype.type == np.int64 else Y[mask, j]
                counts = np.bincount(X_feature[mask], weights=weights)
                indices = np.nonzero(counts)[0]
                cat_count[j, indices] += counts[indices]

        self.class_count_ += Y.sum(axis=0)
        self.n_categories_ = self._validate_n_categories(X, self.min_categories)
        for i in range(self.n_features_in_):
            X_feature = X[:, i]
            self.category_count_[i] = _update_cat_count_dims(self.category_count_[i], self.n_categories_[i] - 1)
            _update_cat_count(X_feature, Y, self.category_count_[i], self.class_count_.shape[0])

    def _update_feature_log_prob(self, alpha):
        feature_log_prob = []
        for i in range(self.n_features_in_):
            smoothed_cat_count = self.category_count_[i] + alpha
            smoothed_class_count = smoothed_cat_count.sum(axis=1)
            feature_log_prob.append(
                np.log(smoothed_cat_count) - np.log(smoothed_class_count.reshape(-1, 1))
            )
        self.feature_log_prob_ = feature_log_prob

    def _joint_log_likelihood(self, X):
        _check_n_features(self, X, reset=False)
        jll = np.zeros((X.shape[0], self.class_count_.shape[0]))
        for i in range(self.n_features_in_):
            indices = X[:, i]
            jll += self.feature_log_prob_[i][:, indices].T  # 高级索引累加
        total_ll = jll + self.class_log_prior_
        return total_ll
  • 数据结构category_count_ 为列表,第 i 个元素形状 (n_classes, n_categories_i),每个特征独立维护类别计数。

  • 动态扩展_update_cat_count_dimsnp.pad 动态扩展类别维度,应对增量学习中出现新类别值的情况。

  • 逐特征平滑:每个特征独立做拉普拉斯平滑,feature_log_prob_ 也是列表,第 i 个元素形状 (n_classes, n_categories_i)

  • 高级索引预测feature_log_prob_[i][:, indices].T 直接按特征取值索引对应对数概率并累加,无需稀疏矩阵乘法。

  • 标签categorical=Truesparse=Falsepositive_only=True


13.7.5 测试验证:四种变体的手工计算与边界场景

# 第 13 章 —— sklearn/tests/test_naive_bayes.py (第329-470行,节选)
def test_bnb():
    # 教科书例子验证
    X = np.array([[1,1,0,0,0,0],[0,1,0,0,1,0],[0,1,0,1,0,0],[0,1,1,0,0,1]])
    Y = np.array([0,0,0,1])
    clf = BernoulliNB(alpha=1.0).fit(X, Y)
    assert_array_almost_equal(np.exp(clf.class_log_prior_), [0.75, 0.25])
    assert_array_almost_equal(np.exp(clf.feature_log_prob_), [[0.4,0.8,...], [1/3,2/3,...]])

def test_bnb_feature_log_prob():
    # 手工验证 BernoulliNB 公式:num = log(fc+1), denom = log(cc+2)
    num = np.log(clf.feature_count_ + 1.0)
    denom = np.tile(np.log(clf.class_count_ + 2.0), (X.shape[1], 1)).T
    assert_array_almost_equal(clf.feature_log_prob_, (num - denom))

def test_cnb():
    # 补集 NB 手工验证权重计算
    theta = np.array([...])  # 补集概率
    weights = -np.log(theta)
    normed_weights = weights / weights.sum(axis=1, keepdims=True)
    clf = ComplementNB(alpha=1.0).fit(X, Y)
    assert_array_almost_equal(clf.feature_log_prob_, weights)
    clf_norm = ComplementNB(alpha=1.0, norm=True).fit(X, Y)
    assert_array_almost_equal(clf_norm.feature_log_prob_, normed_weights)

def test_categoricalnb(global_random_seed):
    clf = CategoricalNB().fit(X2, y2)
    assert_array_equal(clf.predict(X2), y2)
    # 负数输入报错、alpha=1 时概率计算、sample_weight 验证

@pytest.mark.parametrize("min_categories, exp_X1_count, exp_X2_count, new_X, exp_n_categories_", [...])
def test_categoricalnb_with_min_categories(min_categories, exp_X1_count, exp_X2_count, new_X, exp_n_categories_):
    # 验证 min_categories 为 int/list 时的类别数扩展与计数矩阵形状
    clf = CategoricalNB(alpha=1, fit_prior=False, min_categories=min_categories)
    clf.fit(X_n_categories, y_n_categories)
    assert_array_equal(clf.category_count_[0], exp_X1_count)
    assert_array_equal(clf.category_count_[1], exp_X2_count)
    assert_array_equal(clf.n_categories_, exp_n_categories_)

def test_categoricalnb_min_categories_errors():
    # min_categories 形状错误
    clf = CategoricalNB(min_categories=[[3,2],[2,4]])
    with pytest.raises(ValueError, match="'min_categories' should have shape"):
        clf.fit(X, y)

def test_mnb_prior_unobserved_targets():
    # 未观测类别先验平滑避免 RuntimeWarning
    clf = MultinomialNB()
    with warnings.catch_warnings():
        warnings.simplefilter("error", RuntimeWarning)
        clf.partial_fit(X, y, classes=[0,1,2])  # 类别 2 无训练样本
    # 新增类别 2 样本后预测正确
    clf.partial_fit([[1,1]], [2])
    assert clf.predict([[1,1]]) == 2

13.7.6 对比表:四种离散 NB 的核心差异

以下是四种离散朴素贝叶斯变体的核心差异对比:

| 维度 | MultinomialNB | ComplementNB | BernoulliNB | CategoricalNB |

|------|---------------|--------------|-------------|---------------|

| 适用数据 | 词频/计数(非负) | 词频/计数,不平衡数据 | 二元/布尔特征 | 类别编码整数 (0..n-1) |

| 计数对象 | feature_count_[c,f] | feature_count_ + feature_all_ | feature_count_[c,f] | category_count_[i][c,cat] |

| 平滑分母 | \(\sum_f N_{cf} + \alpha n_f\) | 补集计数归一化 | \(N_c + 2\alpha\) | \(\sum_{cat} N_{c,cat} + \alpha n_{cat}\) |

| 平滑策略 | 全局拉普拉斯 | 补集权重(可选 L1 norm) | 二元拉普拉斯 | 逐特征独立拉普拉斯 |

| 预测核心 | X @ logP^T + logPrior | X @ logP^T (argmin) | X @ (logP - log(1-P))^T + sum(log(1-P)) | \(\sum_i logP_i[:, x_i]^T + logPrior\) |

| 稀疏支持 | ✅ CSR | ✅ CSR | ✅ CSR | ❌ 仅稠密 int |

posted @ 2026-09-04 08:54  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报