Sklearn-源码解析-书-v1-0-五-
Sklearn 源码解析(书)v1.0(五)
11.18 设计中的取舍
为什么采用当前方案,而不是更复杂的替代方案? 本章源码优先选择清晰、可维护且与既有 API 兼容的实现;这降低了使用和调试成本,但也意味着部分极端场景需要调用者自行权衡性能、灵活性与实现复杂度。
11.19 动手练习
11.19.1 阅读 BaseLibSVM.fit() 的 gamma 三态解析逻辑
阅读 sklearn/svm/_base.py 中 BaseLibSVM.fit() 方法中 gamma 部分:
-
找到
self.gamma为 'scale'、'auto'、浮点数三种情况的处理代码 -
找到稀疏矩阵方差
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.py 的 fit() 末尾和 _decision_function() 中找到以下代码:
-
self.intercept_ *= -1和self.dual_coef_ = -self.dual_coef_ -
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() 方法:
-
画出
lru_head双向循环链表在插入和删除时的指针变化 -
计算当缓存满时,哪一列被淘汰
-
解释
size = max(size, 2 * (long int) l)为什么必须保证至少能存两列
思考:
缓存大小参数 cache_size 如何影响 SMO 的执行效率?在 SVC_Q::get_Q 中被如何使用?
11.19.4 对比 OVR 与 OvO 的预测投票差异
阅读 svm.cpp 中 predict_values() 方法的投票循环:
-
找到多分类时
vote数组的更新代码 -
找到
decision_function_shape='ovr'在_base.py中如何从 OvO 决策函数转换 -
画出三类问题(n_classes=3)下
sv_coef的稀疏布局示意图
回答问题:
-
对于 3 类问题,共训练多少个二分类器?预测时投票如何处理平局?
-
break_ties=True与默认处理有何不同?
11.19.5 复现 l1_min_c 的边界计算
阅读 sklearn/svm/_bounds.py 的 l1_min_c() 函数:
-
用手工数据 X=[[1,0],[0,1],[1,1]], y=[0,1,1] 计算
den的值 -
分别计算 loss='squared_hinge' 和 loss='log' 时的 l1_min_c
-
运行
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() 方法:
-
找到
fit()中如何调用父类fit(X, np.ones(_num_samples(X))) -
找到
offset_的计算方式 -
对比
decision_function()与score_samples()的区别
思考:
-
为什么 OneClassSVM 给所有样本的标签都设为 1?这与二分类 SVM 有何不同?
-
在
svm.cpp中solve_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()。这是模板方法模式的经典应用:父类定义算法骨架(transform、inverse_transform、get_support、get_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 直接继承 SelectorMixin 和 BaseEstimator,只需实现 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 数据流图
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 稀疏矩阵 Y,safe_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 过滤器家族数据流图
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 互信息计算流程图
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
关键设计点:
-
每轮克隆估计器:避免副作用污染,保证每轮独立训练
-
transform_func="square":对重要性平方求和(处理多维coef_),等价于 L2 范数 -
threshold = min(step, ...):防止最后一轮淘汰过多特征导致少于目标数 -
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 架构与元数据路由
元数据路由设计:RFE.get_metadata_routing() 与 RFECV.get_metadata_routing() 分别声明路由 estimator 的 fit/predict/score,RFECV 还额外路由 splitter.split 与 scorer.score。这使得 sample_weight、groups 等元数据能正确传递到内部估计器、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行,理解以下方法:
-
get_support(indices=False)- 双模式返回机制 -
transform(X)与_transform(X)- 输入校验与核心裁剪 -
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行,对比:
-
f_oneway(*args)的手工 ANOVA 实现与 scipy.stats.f_oneway 的差异 -
chi2(X, y)如何用 LabelBinarizer 和 safe_sparse_dot 处理稀疏矩阵 -
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行,理解:
-
_compute_mi_cc()中 Kraskov 估计器的半径计算与 digamma 求平均 -
_compute_mi_cd()中按标签分组计算半径的流程 -
_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行,理解:
-
RFE._fit()中的淘汰循环、ranking_ 更新与 step 计算 -
RFECV.fit()中逐折 RFE 并行执行、scores 聚合与最优特征数选择 -
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行,理解:
-
_calculate_threshold()中的 L1 惩罚检测与字符串阈值解析 -
SelectFromModel._get_support_mask()中 norm 变换 + 阈值过滤 + max_features 截断 -
SelectFromModel.fit()中 prefit 的深拷贝与克隆-拟合两条路径
回答问题:
-
为什么 L1 惩罚模型的默认阈值是 1e-5 而不是 0?
-
_calculate_threshold()中如何检测LogisticRegressionCV的l1_ratio_是否为 L1? -
SelectFromModel.partial_fit()中为什么首次调用时self.estimator_ = clone(self.estimator)而后续不重新克隆?
12.11.6 练习 6:SequentialFeatureSelector 的贪心搜索与早停
阅读 sklearn/feature_selection/_sequential.py 第100-210行,理解:
-
fit()中 current_mask 的初始化与前后向循环逻辑 -
_get_best_new_feature_score()中候选特征评估与最大得分选择 -
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 全文,理解:
-
fit()中稠密与稀疏分支的方差计算差异 -
threshold=0时 peak-to-peak 修正的逻辑 -
全特征不达标时的 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 强制子类实现各自的核心逻辑,继承 ClassifierMixin 和 BaseEstimator 获得统一的评分接口与参数管理能力。模块级 __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 兼容层(xpx、get_namespace 等)、参数验证工具、稀疏矩阵运算(safe_sparse_dot)以及基类 BaseEstimator 与 ClassifierMixin。
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))
这段代码实现了所有朴素贝叶斯分类器共享的四步预测流水线:
-
predict_joint_log_proba:直接返回未归一化的联合对数概率 \(\log P(y) + \log P(x|y)\)。 -
predict:对联合对数似然按行取argmax得到类别索引,处理字符串标签时需转回 NumPy 再索引classes_。 -
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正确广播相减。 -
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
逐行解析:
-
获取 Array API 命名空间
xp,支持跨后端计算。 -
若新数据为空,直接返回旧统计量(
test_gnb_check_update_with_no_data验证此行为)。 -
加权均值/方差:若有
sample_weight,n_new为权重之和,用_average计算加权均值与方差;权重和接近 0 时视为无新数据。 -
无权重情况:直接用
xp.mean/var计算新批次统计量。 -
首次初始化:
n_past == 0时直接返回新统计量,这是_partial_fit首次调用的初始化路径。 -
合并均值:加权平均公式 \(\mu_{total} = \frac{n_{new}\mu_{new} + n_{past}\mu_{past}}{n_{total}}\)。
-
合并方差(核心公式):利用离差平方和(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
关键流程:
-
首次调用初始化:分配
theta_、var_、class_count_零矩阵;处理用户提供的priors(验证形状、和为 1、非负),否则先验初始化为 0 待后续计算。 -
非首次调用:检查特征数一致性,暂时减去
epsilon_以获取原始方差用于合并。 -
逐类别循环:对
y中出现的每个类别y_i,用布尔掩码提取该类样本X_i及对应权重,调用_update_mean_variance合并统计量,累加class_count_。 -
恢复平滑:循环结束后统一加回
epsilon_。 -
经验先验更新:若未提供先验,按
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 增量学习流程
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 fit 与 partial_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-10且force_alpha=False,自动提升至1e-10并发警告(test_alpha、test_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_dims用np.pad动态扩展类别维度,应对增量学习中出现新类别值的情况。 -
逐特征平滑:每个特征独立做拉普拉斯平滑,
feature_log_prob_也是列表,第i个元素形状(n_classes, n_categories_i)。 -
高级索引预测:
feature_log_prob_[i][:, indices].T直接按特征取值索引对应对数概率并累加,无需稀疏矩阵乘法。 -
标签:
categorical=True、sparse=False、positive_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 |

浙公网安备 33010602011771号