Sklearn-源码解析-书-v1-0-七-
Sklearn 源码解析(书)v1.0(七)
15.17 设计中的取舍
为什么不用 middleware 链处理副作用?
Pipeline 的元数据路由采用声明式映射而非 middleware 链。Middleware 链复杂度高,适用于多种副作用类型需要插拔的场景;而 Pipeline 的副作用类型固定(参数路由、缓存、transform_input 变换),声明式映射复杂度低、可读性强、易于静态分析和调试。get_metadata_routing 返回完整的路由图,便于工具链(如可视化、验证)消费。
为什么 transform_input 只对 fit 生效?
transform_input 设计初衷是解决多验证集场景:fit 阶段需要同步变换验证集(X_val、y_val),而 predict/transform 等推理阶段通常不需要变换元数据。若在推理阶段也启用,会引入不必要的计算开销和复杂性。测试 test_transform_input_pipeline 仅验证 fit/fit_transform 方法的元数据变换。
为什么 FeatureUnion 的 _hstack 优先使用容器适配器?
保留 DataFrame/polars 等容器类型能保持列名、索引等元信息,提升下游可用性。稀疏矩阵次之,最后才是通用 Array API concat。这种分层策略体现了“特化优于通用”的工程智慧。
为什么 sklearn_is_fitted 只检查最后一步?
如果最后一个非 passthrough 步骤已拟合,前置步骤必然已拟合(否则数据无法流到最后)。单步检查比全步骤遍历快得多,且语义等价。FeatureUnion 无顺序依赖,需全检查。
15.18 动手练习
-
阅读 Pipeline 的 fit 执行流程
阅读
sklearn/pipeline.py第 507-614 行,理解_fit和fit的协作方式:-
_fit中的self.steps = list(self.steps)为什么是浅拷贝? -
cloned_transformer在什么条件下才执行clone(transformer)? -
fit_transform_one_cached是如何被memory.cache包裹的? -
fit中最终估计器的last_step_params如何经过_get_metadata_for_step处理?
回答问题:
-
不启用缓存时,变换器对象会被克隆吗?为什么?
-
启用缓存后,第二次
fit时DummyTransf.timestamp_是否变化?为什么? -
fit_predict与fit在处理最终步骤参数时有何不同?
-
-
追踪元数据路由的双模式分支
阅读
sklearn/pipeline.py第 378-402 行和 684-723 行,对比_check_method_params和predict的双模式逻辑:-
_routing_enabled()为 False 时,s__p格式参数如何被拆分? -
为 True 时,
process_routing返回的routed_params结构是什么? -
predict中两个分支分别如何向中间变换器传递参数? -
score方法在旧模式下如何处理sample_weight?
回答问题:
-
旧模式下为什么裸参数
sample_weight会抛出 ValueError? -
新模式下
routed_params[name].transform中的transform键从哪来? -
decision_function为什么只支持新路由模式?
-
-
深入 transform_input 的元数据变换机制
阅读
sklearn/pipeline.py第 404-467 行和_cached_transform第 40-70 行:-
_get_metadata_for_step在什么条件下直接返回step_params? -
sub_metadata_routing.consumes()如何筛选出需要变换的参数? -
_cached_transform如何处理 tuple 类型的参数值? -
阅读测试
test_transform_input_pipeline和test_transform_tuple_input:-
为什么 step 3 的 sample_weight 是
sample_weight + 2? -
alias 参数
other_weights如何被正确路由?
-
回答问题:
-
为什么
step_idx == 0时不需要变换? -
同一个元数据参数被多个步骤的多个方法使用时,缓存如何避免重复变换?
-
如果 transform_input 包含一个没有被任何步骤请求的参数,会发生什么?
-
-
分析 FeatureUnion 的并行拼接与特征名管理
阅读
sklearn/pipeline.py第 1332-1450 行,理解_iter、_parallel_func和_hstack的协作:-
_iter中passthrough被替换成什么对象?返回的元素是什么? -
_parallel_func中每个任务接收哪些参数? -
_hstack判断拼接策略的优先级是什么? -
_update_transformer_list如何将已拟合的变换器放回列表?
回答问题:
-
所有变换器输出都是 sparse 矩阵时,
_hstack返回什么类型? -
配置了 pandas 输出且所有输出都支持容器适配器时,
_hstack如何保留 DataFrame? -
为什么
_update_transformer_list中 drop 步骤保持不变?
-
-
探究特征名冲突检测逻辑
阅读
sklearn/pipeline.py第 1368-1393 行和测试test_feature_union_passthrough_get_feature_names_out_false_errors:-
verbose_feature_names_out=True时,特征名如何生成? -
为 False 时,如何检测重名特征?
-
超过 5 个重名特征时,错误消息如何展示?
-
阅读
test_feature_union_passthrough_get_feature_names_out_false_errors_overlap_over_5:- 10 个重名特征时,错误消息展示哪 5 个?
回答问题:
-
为什么冲突检测只展示前 5 个重名特征?
-
两个变换器都输出名为 'a' 的特征时,错误消息格式是什么?
-
如何通过
set_params绕过冲突检测?
-
-
理解元数据路由测试基础设施
阅读
sklearn/tests/metadata_routing_common.py全文和test_metadata_routing_for_pipeline:-
_Registry为什么需要重写__deepcopy__和__copy__? -
record_metadata如何记录元数据传递层级? -
ConsumingTransformer为什么需要手动实现fit_transform? -
check_recorded_metadata如何验证记录与期望一致?
回答问题:
-
ConsumingNoFitTransformTransformer与ConsumingTransformer的区别是什么? -
record_metadata_not_default为什么跳过默认值? -
在
test_metadata_routing_for_pipeline中,为什么 transformer 只收到 fit 和 transform 的元数据?
-
-
剖析 passthrough 与步骤替换机制
阅读
sklearn/pipeline.py第 312-327 行和测试test_set_pipeline_step_passthrough:-
_iter的filter_passthrough参数如何影响遍历? -
_fit中 passthrough 步骤如何被处理? -
set_params(m3=passthrough)后,fit_transform的结果如何变化? -
阅读
test_set_pipeline_steps和test_pipeline_correctly_adjusts_steps:- 直接设置
pipeline.steps与set_params有何异同?
- 直接设置
回答问题:
-
passthrough 步骤在
_fit中是否被替换为实际对象? -
为什么 passthrough 步骤不参与
__sklearn_is_fitted__的检查? -
将中间步骤设置为 passthrough 后,
inverse_transform能否正常工作?
-
15.19 本章小结
这一章中我们学习了 scikit-learn 中 Pipeline 与 FeatureUnion 两大元估计器的核心实现。首先我们理解了 Pipeline 如何继承 _BaseComposition 实现嵌套参数管理与步骤校验,__init__ 不克隆估计器,fit 时按需克隆。其次我们深入剖析了 _iter 惰性迭代器、__getitem__ 切片语义、named_steps 属性式访问等核心骨架。接着我们详细解读了 _fit 顺序拟合引擎、fit/fit_transform/fit_predict 的协作方式,以及 memory.cache 缓存机制与克隆策略的交互。然后我们重点分析了元数据路由的新旧双模式:旧模式解析 s__p 前缀,新模式使用 process_routing 智能分拣,并对比了 predict/score 等方法的双模式分支实现。随后我们探究了 transform_input 机制如何让元数据也经过前置变换器处理,以及 _cached_transform 缓存策略对多验证集 tuple 输入的支持。之后我们转向 FeatureUnion,理解了 _iter 处理 drop/passthrough、_parallel_func joblib 并行调度、_hstack 三策略拼接(容器适配器/稀疏矩阵/Array API)的协作细节。我们还深入了特征名生成的前缀模式与冲突检测逻辑,以及 get_metadata_routing 在 Pipeline 与 FeatureUnion 中的不同映射策略。最后我们了解了标签传播规则、拟合状态判定优化、预测变换方法全景,以及元数据路由测试基础设施(_Registry、ConsumingTransformer、SimplePipeline)的构建方式。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| _BaseComposition | 提供嵌套参数管理与命名校验的公共骨架 |
| __init__ 不克隆 | 构造时保留对象引用,fit 时才做浅拷贝/克隆 |
| _validate_steps | 中间步骤需 fit+transform,最终步骤仅需 fit |
| _iter(with_final) | 惰性迭代器,控制是否包含最终步骤与 passthrough |
| __getitem__ 切片 | 返回浅拷贝子 Pipeline,共享步骤对象引用 |
| named_steps | Bunch 属性式访问,比 dict 更简洁 |
| _check_method_params | 新旧路由双模式:s__p 前缀拆分 vs process_routing |
| _get_metadata_for_step | transform_input 机制,用子流水线变换元数据并缓存 |
| _cached_transform | 避免同一元数据被重复变换,支持 tuple 多验证集 |
| memory.cache | joblib 缓存变换器拟合结果,命中时跳过计算 |
| get_metadata_routing | 声明 Pipeline 各方法到子步骤方法的路由映射 |
| __sklearn_tags__ | 从首步继承 pairwise/sparse,从末步继承 estimator_type |
| __sklearn_is_fitted__ | 仅检查最后一个非 passthrough 步骤是否拟合 |
| FeatureUnion._iter | drop 跳过,passthrough 转为 FunctionTransformer,返回权重三元组 |
| _parallel_func | joblib Parallel 并行执行 fit/fit_transform/transform |
| _hstack | 容器适配器/稀疏矩阵/Array API 三策略拼接 |
| _add_prefix_for_feature_names_out | 真模式加前缀,假模式用 Counter 检测冲突 |
| verbose_feature_names_out | 控制特征名是否带变换器名前缀 |
| transform_input | 1.6 新增,允许对 fit 的元数据也执行前置变换 |
| make_pipeline/make_union | 基于类型名小写自动生成步骤名的便捷构造器 |
| ConsumingTransformer | 测试用元数据消费变换器,记录 fit/transform 收到的参数 |
| _Registry | 深拷贝时保持同一列表引用,用于追踪克隆后的子估计器 |
| check_recorded_metadata | 验证元数据是否通过正确的调用链传递给子估计器 |
| MetadataRouter | 组合多个子估计器的路由规则,提供合并后的路由视图 |
| SimplePipeline | 测试用极简元估计器,手动实现路由分发以验证嵌套路由 |
| Mult/Transf/FitParamT | 测试辅助类,分别验证乘法变换、可逆变换与参数传递 |
| _fit_context | 装饰器,控制 fit 期间的上下文验证行为 |
| available_if | 描述符,根据最终估计器能力动态决定方法可见性 |
下一章中,我们将学习神经网络模块,探索多层感知机的前向传播与反向传播、RBM 的能量模型与对比散度,以及底层随机优化器的实现细节。
第 16 章 —— 神经网络 —— 拥抱“深度学习的轻量入口”
16.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 MLP 中激活函数、损失函数与优化器三大组件的职责划分与协作关系
-
掌握前向传播与反向传播在 BaseMultilayerPerceptron 中的具体实现,包括稀疏输入的处理
-
理解 LBFGS 与 SGD/Adam 两类求解器在参数管理方式上的根本差异(pack/unpack vs. 原地更新)
-
掌握 Random 求解器中的小批量采样、学习率调度与早停机制
-
了解 BernoulliRBM 的能量模型、对比散度训练与伪似然评估
-
能阅读并修改 MLP/RBM 的测试用例,使用数值梯度检验等工具验证实现正确性
16.2 生活类比
想象神经网络训练是一家多层协作的精密工厂。激活函数是车间里的加工机器(切割机、抛光机、喷涂机),in-place 操作意味着直接在传送带上的工件上加工,不搬来搬去,避免了临时堆栈的内存分配开销;前向传播是原料从第一层车间逐级加工传送到最后一层,每层记录中间产物的"生产日志"(激活值缓存),供后续质检使用;反向传播是质检员从成品车间出发,拿着"误差清单"(delta)沿生产线逆向行走,逐层告知工人该调整多少参数;LBFGS 求解器像一位"全局优化大师",把所有车间的所有机器旋钮都拆下来,统一编号打包成一根长杆,由 SciPy 的 L-BFGS-B 算法在高维空间中寻找最优位置;SGD/Adam 求解器则是每个车间配一位"现场工头",看到一个小批次的误差就立即微调本车间的旋钮,原地更新,无需打包解包;RBM 的对比散度好比画家不断比对实物(正样本)与自己的默写稿(负样本),通过"真实与梦境的差异"来修正画技(权重);伪似然评估则是随机遮挡画作的一笔,根据周围笔触推测这笔该是什么,以此评估整幅画的合理性,避开不可计算的配分函数。
16.3 源码地图
sklearn/neural_network/
├── init.py # 公共 API 导出:MLPClassifier, MLPRegressor, BernoulliRBM
├── _base.py # 激活函数、导数、损失函数的 in-place 实现与注册字典
├── _multilayer_perceptron.py # MLP 核心实现:BaseMultilayerPerceptron, MLPClassifier, MLPRegressor
├── _stochastic_optimizers.py # SGD/Adam 优化器实现
├── _rbm.py # BernoulliRBM 实现
└── tests/
├── test_base.py # 激活/损失函数边界与数值一致性测试
├── test_mlp.py # MLP 功能、梯度检验、稀疏/增量/早停等全维度测试
├── test_stochastic_optimizers.py # 优化器更新公式与停止策略单元测试
└── test_rbm.py # RBM 训练、采样、伪似然、稀疏支持测试
16.4 激活函数工具箱 —— 神经网络神经元的"开关面板"
激活函数是神经网络引入非线性的关键。在 _base.py 中,所有激活函数都采用 in-place 实现,直接在输入数组上修改数据,避免分配临时数组,减少内存带宽压力。这在训练过程中反复调用激活函数时至关重要。同时,反向传播中 delta 信号也直接在原数组上修改,无需返回新对象。
ACTIVATIONS 字典提供了统一的前向激活入口,包含 identity、exp、logistic、tanh、relu、softmax 六种激活函数。每层前向传播时根据配置动态选择对应实现。
类型定义详解:
源码路径:sklearn/neural_network/_base.py - 激活函数(第12-92行)
def inplace_identity(X):
"""Simply leave the input array unchanged."""
# Nothing to do // 恒等激活:直接返回输入,不做任何操作
def inplace_exp(X):
"""Compute the exponential inplace."""
np.exp(X, out=X) // 原地计算指数:out=X 将结果直接写回输入数组
def inplace_logistic(X):
"""Compute the logistic function inplace."""
logistic_sigmoid(X, out=X) // 原地计算 Sigmoid:调用 SciPy 的 expit,结果写回 X
def inplace_tanh(X):
"""Compute the hyperbolic tan function inplace."""
np.tanh(X, out=X) // 原地计算双曲正切:结果写回 X
def inplace_relu(X):
"""Compute the rectified linear unit function inplace."""
np.maximum(X, 0, out=X) // 原地计算 ReLU:将负值置零,正值保留,结果写回 X
def inplace_softmax(X):
"""Compute the K-way softmax function inplace."""
tmp = X - X.max(axis=1)[:, np.newaxis] // 减去行最大值防止 exp 上溢,数值稳定技巧
np.exp(tmp, out=X) // 原地指数运算
X /= X.sum(axis=1)[:, np.newaxis] // 原地归一化,得到概率分布
ACTIVATIONS = {
"identity": inplace_identity,
"exp": inplace_exp,
"tanh": inplace_tanh,
"logistic": inplace_logistic,
"relu": inplace_relu,
"softmax": inplace_softmax,
}
这段代码定义了六个 in-place 激活函数并注册到 ACTIVATIONS 字典。inplace_softmax 通过减去行最大值实现数值稳定性,防止指数运算上溢;inplace_logistic 和 inplace_tanh 底层调用 SciPy 的 C/Fortran 实现,性能优于纯 Python。
架构图:
16.5 激活函数的导数 —— 反向传播的"梯度传输带"
反向传播需要激活函数的导数。巧妙之处在于利用前向输出直接计算导数,避免重复计算:logistic 导数 f'(x)=f(x)(1-f(x)),利用前向缓存 Z 计算;tanh 导数 f'(x)=1-f(x)^2;relu 导数是阶跃函数,Z==0 处置零。
DERIVATIVES 字典与反向传播绑定:隐藏层 delta 传播时通过 inplace_derivative(activations[i], deltas[i-1]) 调用。与 ACTIVATIONS 对应关系:identity、tanh、logistic、relu 四种有导数,exp 和 softmax 不作为隐藏层。
逐行解析关键函数:
源码路径:sklearn/neural_network/_base.py - inplace_identity_derivative()(第95-105行)
def inplace_identity_derivative(Z, delta):
"""Apply the derivative of the identity function: do nothing."""
# Nothing to do // 恒等函数导数为 1,delta 乘以 1 即不变,无需操作
源码路径:sklearn/neural_network/_base.py - inplace_logistic_derivative()(第107-124行)
def inplace_logistic_derivative(Z, delta):
"""Apply the derivative of the logistic sigmoid function."""
delta *= Z // delta = delta * Z (利用前向输出 Z = sigmoid(x))
delta *= 1 - Z // delta = delta * (1 - Z) => 最终 delta * Z * (1-Z),即 f'(x)=f(x)(1-f(x))
源码路径:sklearn/neural_network/_base.py - inplace_tanh_derivative()(第127-144行)
def inplace_tanh_derivative(Z, delta):
"""Apply the derivative of the hyperbolic tanh function."""
delta *= 1 - Z**2 // 利用前向输出 Z 直接计算 1 - tanh^2(x),避免重新计算 tanh
源码路径:sklearn/neural_network/_base.py - inplace_relu_derivative()(第147-166行)
def inplace_relu_derivative(Z, delta):
"""Apply the derivative of the relu function."""
delta[Z == 0] = 0 // ReLU 导数:Z>0 处为 1(保持 delta 不变),Z<=0 处为 0(梯度截断)
这三个函数实现了隐藏层激活函数的 in-place 导数计算,直接修改传入的 delta 数组,体现了"零拷贝"的工程哲学。
架构图:
16.6 损失函数家族 —— 模型优化的"度量衡"
损失函数衡量预测与真实的差距,驱动参数更新。_base.py 实现了四种核心损失函数,统一支持 sample_weight 样本权重。
squared_loss:回归的经典选择,0.5 * average((y_true - y_pred)^2, weights=sample_weight, axis=0).mean()。0.5 系数使梯度形式更简洁(抵消平方导数的 2)。np.average(axis=0).mean() 适配多输出场景。
poisson_loss:计数型回归的对数链接损失,xlogy(y_true, y_true/y_pred) - y_true + y_pred。使用 xlogy 处理 y_pred=0 的边界。与 _loss 模块中 HalfPoissonLoss 的 constant_to_optimal_zero 保持一致性。
log_loss / binary_log_loss:分类的交叉熵双胞胎。概率裁剪到 [eps, 1-eps] 防止 log(0) 产生 -inf。binary_log_loss 针对多标签场景:对每个标签独立计算 sigmoid 交叉熵。当输出层是 logistic 且损失是 log_loss 时自动切换为 binary_log_loss。
逐行解析关键函数:
源码路径:sklearn/neural_network/_base.py - squared_loss()(第169-185行)
def squared_loss(y_true, y_pred, sample_weight=None):
"""Compute the squared loss for regression."""
return (
0.5 * np.average((y_true - y_pred) ** 2, weights=sample_weight, axis=0).mean()
) // 0.5 系数抵消平方导数的 2;axis=0 后再 mean 适配多输出
源码路径:sklearn/neural_network/_base.py - poisson_loss()(第188-210行)
def poisson_loss(y_true, y_pred, sample_weight=None):
"""Compute (half of the) Poisson deviance loss for regression."""
return np.average(
xlogy(y_true, y_true / y_pred) - y_true + y_pred, weights=sample_weight, axis=0
).sum() // xlogy 安全处理 0*log(0);包含常数项以便与底层 _loss 模块一致
源码路径:sklearn/neural_network/_base.py - log_loss()(第213-235行)
def log_loss(y_true, y_prob, sample_weight=None):
"""Compute Logistic loss for classification."""
eps = np.finfo(y_prob.dtype).eps // 获取数据类型的机器精度
y_prob = np.clip(y_prob, eps, 1 - eps) // 裁剪概率,防止 log(0) 产生 -inf
if y_prob.shape[1] == 1: // 二分类扩展为两列 [1-p, p]
y_prob = np.append(1 - y_prob, y_prob, axis=1)
if y_true.shape[1] == 1:
y_true = np.append(1 - y_true, y_true, axis=1)
return -np.average(xlogy(y_true, y_prob), weights=sample_weight, axis=0).sum()
源码路径:sklearn/neural_network/_base.py - binary_log_loss()(第238-260行)
def binary_log_loss(y_true, y_prob, sample_weight=None):
"""Compute binary logistic loss for classification."""
eps = np.finfo(y_prob.dtype).eps
y_prob = np.clip(y_prob, eps, 1 - eps)
return -np.average(
xlogy(y_true, y_prob) + xlogy(1 - y_true, 1 - y_prob), // 多标签:每个标签独立 sigmoid CE
weights=sample_weight,
axis=0,
).sum()
这段代码实现了分类任务的核心损失函数,通过概率裁剪保证数值稳定性,xlogy 安全处理 0*log(0) 边界情况。
架构图:
16.7 损失函数的测试验证 —— 数值正确性的"质检台"
测试确保损失函数在边界情况下的数值稳定性,以及与底层 _loss 模块的数学一致性。
逐行解析关键测试:
源码路径:sklearn/neural_network/tests/test_base.py - test_binary_log_loss_1_prob_finite()(第12-20行)
def test_binary_log_loss_1_prob_finite():
# y_proba is equal to one should result in a finite logloss
y_true = np.array([[0, 0, 1]]).T
y_prob = np.array([[0.9, 1.0, 1.0]]).T
loss = binary_log_loss(y_true, y_prob)
assert np.isfinite(loss) // 验证概率=1 时经过裁剪后返回有限值,而非 -inf
源码路径:sklearn/neural_network/tests/test_base.py - test_log_loss_1_prob_finite()(第23-39行)
@pytest.mark.parametrize(
"y_true, y_prob",
[
(
np.array([[1, 0, 0], [0, 1, 0]]),
np.array([[0.0, 1.0, 0.0], [0.9, 0.05, 0.05]]),
),
(np.array([[0, 0, 1]]).T, np.array([[0.9, 1.0, 1.0]]).T),
],
)
def test_log_loss_1_prob_finite(y_true, y_prob):
# y_proba is equal to 1 should result in a finite logloss
loss = log_loss(y_true, y_prob)
assert np.isfinite(loss) // 确保概率=1 时经过裁剪后返回有限值
源码路径:sklearn/neural_network/tests/test_base.py - test_poisson_loss()(第42-65行)
def test_poisson_loss(global_random_seed):
"""Test Poisson loss against well tested HalfPoissonLoss."""
n = 1000
rng = np.random.default_rng(global_random_seed)
y_true = rng.integers(low=0, high=10, size=n).astype(float)
y_raw = rng.standard_normal(n)
y_pred = np.exp(y_raw)
sw = rng.uniform(low=0.1, high=10, size=n)
assert 0 in y_true // 确保包含零值测试边界
loss = poisson_loss(y_true=y_true, y_pred=y_pred, sample_weight=sw)
pl = HalfPoissonLoss()
loss_ref = (
pl(y_true=y_true, raw_prediction=y_raw, sample_weight=sw)
+ pl.constant_to_optimal_zero(y_true=y_true, sample_weight=sw).mean()
/ sw.mean()
)
assert loss == pytest.approx(loss_ref, rel=1e-12) // 与底层实现数学一致
这段测试代码验证了损失函数在极端概率值下的鲁棒性,以及高层 API 与底层 _loss 模块实现的数学一致性。
16.8 MLP 骨架:BaseMultilayerPerceptron 的层叠架构 —— 神经网络的"装配蓝图"
BaseMultilayerPerceptron 是 MLPClassifier 和 MLPRegressor 的共同基类,定义了参数约束、初始化、前向/反向传播、求解器集成等核心逻辑。
为什么需要 pack/unpack 机制? LBFGS 优化器要求参数是一维向量,_pack 将所有层的权重和偏置展开拼接;_unpack 根据预先记录的 _coef_indptr 和 _intercept_indptr 还原为层结构。
参数约束系统:activation 限定 identity/logistic/tanh/relu,solver 限定 lbfgs/sgd/adam,alpha、learning_rate_init 等数值参数使用 Interval 约束范围,n_iter_no_change 允许整数或 np.inf。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - _pack()(第53-56行)
def _pack(coefs_, intercepts_):
"""Pack the parameters into a single vector."""
return np.hstack([l.ravel() for l in coefs_ + intercepts_]) // 将所有层参数展平拼接为一维向量
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron.__init__()(第75-134行)
def __init__(self, hidden_layer_sizes, activation, solver, alpha, batch_size, ...):
# 所有超参数直接赋值给实例属性,延迟到 fit 时验证
self.activation = activation
self.solver = solver
self.alpha = alpha
...
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._unpack()(第137-146行)
def _unpack(self, packed_parameters):
"""Extract the coefficients and intercepts from packed_parameters."""
for i in range(self.n_layers_ - 1):
start, end, shape = self._coef_indptr[i]
self.coefs_[i] = np.reshape(packed_parameters[start:end], shape) // 还原权重矩阵
start, end = self._intercept_indptr[i]
self.intercepts_[i] = packed_parameters[start:end] // 还原偏置向量
这段代码实现了参数打包/解包机制,是 LBFGS 求解器与层式参数结构之间的桥梁。
架构图:
16.9 前向传播的双实现 —— 训练与预测的"快慢车道"
_forward_pass:训练用的"全记录"版本 逐层执行 safe_sparse_dot(activation, coef) + intercept,记录每层激活值供反向传播使用。隐藏层应用 hidden_activation,输出层应用 out_activation。
_forward_pass_fast:预测用的"精简"版本 不缓存中间激活值,仅返回最终输出,显著降低内存占用。check_input 参数跳过数据验证,适合内部调用和评分场景。safe_sparse_dot 同时支持稠密 ndarray 和 CSR/CSC 稀疏矩阵输入。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._forward_pass()(第148-169行)
def _forward_pass(self, activations):
hidden_activation = ACTIVATIONS[self.activation]
for i in range(self.n_layers_ - 1):
activations[i + 1] = safe_sparse_dot(activations[i], self.coefs_[i]) // 稀疏/稠密通用矩阵乘法
activations[i + 1] += self.intercepts_[i] // 加上偏置
if (i + 1) != (self.n_layers_ - 1): // 隐藏层
hidden_activation(activations[i + 1]) // 原地激活
output_activation = ACTIVATIONS[self.out_activation_]
output_activation(activations[i + 1]) // 输出层激活
return activations // 返回包含所有层激活值的列表
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._forward_pass_fast()(第171-200行)
def _forward_pass_fast(self, X, check_input=True):
if check_input:
X = validate_data(self, X, accept_sparse=["csr", "csc"], reset=False)
activation = X
hidden_activation = ACTIVATIONS[self.activation]
for i in range(self.n_layers_ - 1):
activation = safe_sparse_dot(activation, self.coefs_[i]) // 矩阵乘法
activation += self.intercepts_[i] // 加偏置
if i != self.n_layers_ - 2:
hidden_activation(activation) // 隐藏层激活
output_activation = ACTIVATIONS[self.out_activation_]
output_activation(activation) // 输出层激活
return activation // 仅返回最终输出,不缓存中间层
这两个函数体现了训练与推理的不同需求:训练需要完整激活缓存用于反向传播,推理只需最终输出。
流程图:
16.10 反向传播核心 —— 梯度信号的"倒流引擎"
反向传播是 MLP 训练的核心。_backprop 实现了完整的梯度计算流程。
为什么最后一层的 delta 如此简洁? 采用"canonical loss-link"组合(sigmoid+二分类交叉熵、softmax+多分类交叉熵、identity+平方误差),此时 delta[last] = activations[-1] - y。sample_weight 通过 reshape(-1, 1) 广播到每个输出维度。
反向迭代:从输出层向输入层传播误差。deltas[i-1] = safe_sparse_dot(deltas[i], self.coefs_[i].T) 将误差传递到前一层,再乘以当前层激活值的导数进行链式法则计算。
_compute_loss_grad:权重梯度的"加法器"。coef_grad = activations[layer].T @ deltas[layer],支持稀疏输入。添加 L2 正则化梯度 alpha * coefs,再除以样本权重和。intercept_grad = sum(deltas, axis=0) / sw_sum。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._compute_loss_grad()(第202-211行)
def _compute_loss_grad(self, layer, sw_sum, activations, deltas, coef_grads, intercept_grads):
coef_grads[layer] = safe_sparse_dot(activations[layer].T, deltas[layer]) // 权重梯度: A^T @ delta
coef_grads[layer] += self.alpha * self.coefs_[layer] // 加上 L2 正则化梯度
coef_grads[layer] /= sw_sum // 除以样本权重和归一化
intercept_grads[layer] = np.sum(deltas[layer], axis=0) / sw_sum // 偏置梯度: delta 在样本维度求和
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._backprop()(第311-363行)
def _backprop(self, X, y, sample_weight, activations, deltas, coef_grads, intercept_grads):
n_samples = X.shape[0]
activations = self._forward_pass(activations) // 前向传播并缓存所有层激活值
# 计算损失
loss_func_name = self.loss
if loss_func_name == "log_loss" and self.out_activation_ == "logistic":
loss_func_name = "binary_log_loss" // 多分类log_loss+logistic -> binary_log_loss
loss = LOSS_FUNCTIONS[loss_func_name](y, activations[-1], sample_weight)
# L2 正则项
values = sum(np.dot(s.ravel(), s.ravel()) for s in self.coefs_)
sw_sum = sample_weight.sum() if sample_weight is not None else n_samples
loss += (0.5 * self.alpha) * values / sw_sum
# 反向传播:最后一层 delta
last = self.n_layers_ - 2
deltas[last] = activations[-1] - y // 核心公式:输出 - 目标 (canonical loss-link 组合)
if sample_weight is not None:
deltas[last] *= sample_weight.reshape(-1, 1) // 广播样本权重
self._compute_loss_grad(last, sw_sum, activations, deltas, coef_grads, intercept_grads)
inplace_derivative = DERIVATIVES[self.activation]
for i in range(last, 0, -1): // 逆序遍历隐藏层
deltas[i - 1] = safe_sparse_dot(deltas[i], self.coefs_[i].T) // 误差回传: delta @ W^T
inplace_derivative(activations[i], deltas[i - 1]) // 乘以激活导数 (链式法则)
self._compute_loss_grad(i - 1, sw_sum, activations, deltas, coef_grads, intercept_grads)
return loss, coef_grads, intercept_grads
这段代码实现了完整的反向传播算法,利用 canonical loss-link 组合简化了输出层梯度计算,通过 safe_sparse_dot 统一处理稠密/稀疏输入。
流程图:
16.11 LBFGS 求解器集成 —— 参数空间的"拟牛顿探险家"
LBFGS 是批量优化方法,需要将所有参数打包为一维向量。
为什么需要 _loss_grad_lbfgs 包装函数? scipy.optimize.minimize 要求目标函数接收一维参数向量并返回 (loss, grad) 元组。内部先 unpack 参数、执行 _backprop、再用 _pack 将梯度拼接为一维向量。
索引指针 _coef_indptr/_intercept_indptr 的设计:预计算每层参数在展平向量中的 start/end 位置和 shape,避免每次 unpack 时重复计算偏移量。
max_fun、max_iter、gtol 三种收敛条件:_get_additional_lbfgs_options_dict 处理 iprint 选项在不同 scipy 版本间的兼容性。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._fit_lbfgs()(第431-477行)
def _fit_lbfgs(self, X, y, sample_weight, activations, deltas, coef_grads, intercept_grads, layer_units):
# 预计算索引指针
self._coef_indptr = []
self._intercept_indptr = []
start = 0
for i in range(self.n_layers_ - 1):
n_fan_in, n_fan_out = layer_units[i], layer_units[i + 1]
end = start + (n_fan_in * n_fan_out)
self._coef_indptr.append((start, end, (n_fan_in, n_fan_out))) // 记录权重切片位置和形状
start = end
for i in range(self.n_layers_ - 1):
end = start + layer_units[i + 1]
self._intercept_indptr.append((start, end)) // 记录偏置切片位置
start = end
packed_coef_inter = _pack(self.coefs_, self.intercepts_) // 初始参数打包
opt_res = scipy.optimize.minimize(
self._loss_grad_lbfgs,
packed_coef_inter,
method="L-BFGS-B",
jac=True,
options={"maxfun": self.max_fun, "maxiter": self.max_iter, "gtol": self.tol, ...},
args=(X, y, sample_weight, activations, deltas, coef_grads, intercept_grads),
)
self.n_iter_ = _check_optimize_result("lbfgs", opt_res, self.max_iter)
self.loss_ = opt_res.fun
self._unpack(opt_res.x) // 将优化后的一维参数还原为层结构
这段代码将 MLP 的层式参数结构适配给 SciPy 的 LBFGS 优化器,通过索引指针实现高效的参数打包/解包。
流程图:
16.12 网络初始化策略 —— 权重世界的"创世法则"
Glorot 初始化:根据激活函数调整边界。logistic 激活使用 factor=2.0,其他使用 factor=6.0。init_bound = sqrt(factor / (fan_in + fan_out)) 平衡前向信号和反向梯度。
输出层激活函数的自动选择:回归 + poisson 损失 → exp;回归 + squared_error 损失 → identity;多分类 → softmax;二分类和多标签 → logistic。
best_coefs/best_intercepts 的副本机制:在 early_stopping 时保存最佳验证分数的参数快照。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._init_coef()(第400-416行)
def _init_coef(self, fan_in, fan_out, dtype):
factor = 6.0
if self.activation == "logistic":
factor = 2.0 // logistic 饱和区梯度小,用更窄的初始化范围
init_bound = np.sqrt(factor / (fan_in + fan_out)) // Glorot/Xavier 初始化边界
coef_init = self._random_state.uniform(-init_bound, init_bound, (fan_in, fan_out))
intercept_init = self._random_state.uniform(-init_bound, init_bound, fan_out)
return coef_init.astype(dtype, copy=False), intercept_init.astype(dtype, copy=False)
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._initialize()(第365-398行)
def _initialize(self, y, layer_units, dtype):
# 根据任务类型自动选择输出层激活函数
if not is_classifier(self):
self.out_activation_ = "exp" if self.loss == "poisson" else "identity"
elif self._label_binarizer.y_type_ == "multiclass":
self.out_activation_ = "softmax"
else:
self.out_activation_ = "logistic"
# 初始化各层权重和偏置
for i in range(self.n_layers_ - 1):
coef_init, intercept_init = self._init_coef(layer_units[i], layer_units[i + 1], dtype)
self.coefs_.append(coef_init)
self.intercepts_.append(intercept_init)
self._best_coefs = [c.copy() for c in self.coefs_] // 保存副本用于 early stopping 恢复
self._best_intercepts = [i.copy() for i in self.intercepts_]
这段代码实现了自适应的权重初始化策略,根据激活函数和任务类型动态调整初始化分布。
16.13 随机求解器训练循环 —— 小批量的"流水线车间"
SGD/Adam 采用小批量随机优化,_fit_stochastic 实现了完整的训练循环。
batch_size 自动选择的工程智慧:auto 模式取 min(200, n_samples),平衡梯度稳定性和内存。显式设置时通过 np.clip 限制在 [1, n_samples] 内,越界发出警告。
shuffle 的索引级实现:只对 sample_idx 数组洗牌而非复制整个数据,降低内存开销。配合 gen_batches 生成小批量切片。
不改进计数器 _update_no_improvement_count 的双模式:early_stopping=True 时基于验证集分数,False 时基于训练损失。连续 n_iter_no_change 次未改善后触发提前停止或学习率衰减。
_check_solver 与 _score_with_function:_check_solver 验证求解器是否为随机类型,非随机求解器禁止 partial_fit。_score_with_function 跳过输入验证,避免特征名丢失。
fit 入口的 _fit_context 装饰器:通过 prefer_skip_nested_validation 优化嵌套验证的性能。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._fit_stochastic()(第479-575行)
def _fit_stochastic(self, X, y, sample_weight, activations, deltas, coef_grads, intercept_grads, layer_units, incremental):
params = self.coefs_ + self.intercepts_
if not incremental or not hasattr(self, "_optimizer"):
if self.solver == "sgd":
self._optimizer = SGDOptimizer(params, self.learning_rate_init, self.learning_rate, ...) // 初始化 SGD
elif self.solver == "adam":
self._optimizer = AdamOptimizer(params, self.learning_rate_init, self.beta_1, self.beta_2, self.epsilon) // 初始化 Adam
# early_stopping 分割验证集
if early_stopping:
X_train, X_val, y_train, y_val, ... = train_test_split(...)
else:
X_train, y_train, sample_weight_train = X, y, sample_weight
n_samples = X_train.shape[0]
sample_idx = np.arange(n_samples, dtype=int)
batch_size = min(200, n_samples) if self.batch_size == "auto" else np.clip(self.batch_size, 1, n_samples)
for it in range(self.max_iter):
if self.shuffle:
sample_idx = shuffle(sample_idx, random_state=self._random_state) // 仅洗牌索引
accumulated_loss = 0.0
for batch_slice in gen_batches(n_samples, batch_size):
batch_idx = sample_idx[batch_slice] if self.shuffle else batch_slice
X_batch = _safe_indexing(X_train, batch_idx)
y_batch = y_train[batch_idx]
# 反向传播计算梯度
batch_loss, coef_grads, intercept_grads = self._backprop(...)
accumulated_loss += batch_loss * (batch_slice.stop - batch_slice.start)
# 参数更新
grads = coef_grads + intercept_grads
self._optimizer.update_params(params, grads) // 优化器原地更新参数
# 学习率调度、早停判断等
self._optimizer.iteration_ends(self.t_)
self._update_no_improvement_count(early_stopping, X_val, y_val, sample_weight_val)
if self._no_improvement_count > self.n_iter_no_change:
is_stopping = self._optimizer.trigger_stopping(msg, self.verbose)
if is_stopping: break
源码路径:sklearn/neural_network/_multilayer_perceptron.py - BaseMultilayerPerceptron._update_no_improvement_count()(第576-601行)
def _update_no_improvement_count(self, early_stopping, X, y, sample_weight):
if early_stopping:
val_score = self._score(X, y, sample_weight=sample_weight)
self.validation_scores_.append(val_score)
if val_score < self.best_validation_score_ + self.tol:
self._no_improvement_count += 1
else:
self._no_improvement_count = 0
if val_score > self.best_validation_score_:
self.best_validation_score_ = val_score
self._best_coefs = [c.copy() for c in self.coefs_] // 保存最佳权重副本
self._best_intercepts = [i.copy() for i in self.intercepts_]
else:
if self.loss_curve_[-1] > self.best_loss_ - self.tol:
self._no_improvement_count += 1
else:
self._no_improvement_count = 0
if self.loss_curve_[-1] < self.best_loss_:
self.best_loss_ = self.loss_curve_[-1]
这段代码实现了随机优化的主循环,包括小批量采样、梯度计算、参数更新、学习率调度和早停机制,体现了工程上的精细考量。
完整数据流/流程图:
16.14 MLPClassifier:分类任务的"概率解码器"
MLPClassifier 继承自 BaseMultilayerPerceptron 和 ClassifierMixin,处理分类特有的标签二值化、概率输出等逻辑。
LabelBinarizer 在分类中的多重角色:fit 阶段将 y 转换为 boolean 二值化矩阵;predict 阶段通过 inverse_transform 将概率矩阵还原为类别标签;predict_proba 对二分类将 sigmoid 输出扩展为 [1-p, p] 两列。
warm_start 与类别一致性:warm_start=True 时检查新旧 y 的类别集合必须完全一致;非 warm_start 时允许 partial_fit 中新增类别。
predict_log_proba 的原地优化:直接对 predict_proba 结果调用 np.log(out=...),避免额外分配数组。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - MLPClassifier._validate_input()(第813-850行)
def _validate_input(self, X, y, incremental, reset):
X, y = validate_data(self, X, y, accept_sparse=["csr", "csc"], multi_output=True, dtype=(np.float64, np.float32), reset=reset)
if y.ndim == 2 and y.shape[1] == 1:
y = column_or_1d(y, warn=True)
if (not hasattr(self, "classes_")) or (not self.warm_start and not incremental):
self._label_binarizer = LabelBinarizer()
self._label_binarizer.fit(y)
self.classes_ = self._label_binarizer.classes_
else:
classes = unique_labels(y)
if self.warm_start and set(classes) != set(self.classes_):
raise ValueError("warm_start requires same classes")
elif len(np.setdiff1d(classes, self.classes_, assume_unique=True)):
raise ValueError("y has classes not in self.classes_")
y = self._label_binarizer.transform(y).astype(bool) // 二值化为 boolean 矩阵
return X, y
源码路径:sklearn/neural_network/_multilayer_perceptron.py - MLPClassifier.predict_proba()(第944-970行)
def predict_proba(self, X):
check_is_fitted(self)
y_pred = self._forward_pass_fast(X)
if self.n_outputs_ == 1:
y_pred = y_pred.ravel()
if y_pred.ndim == 1: // 二分类:sigmoid 输出扩展为 [1-p, p]
return np.vstack([1 - y_pred, y_pred]).T
else: // 多分类/多标签:直接返回 softmax 或 logistic 输出
return y_pred
源码路径:sklearn/neural_network/_multilayer_perceptron.py - MLPClassifier.predict_log_proba()(第926-942行)
def predict_log_proba(self, X):
y_prob = self.predict_proba(X)
return np.log(y_prob, out=y_prob) // 原地取对数,避免额外分配
这段代码实现了分类器特有的标签处理和概率输出逻辑,体现了 scikit-learn 统一 API 设计下的分类任务适配。
16.15 MLPRegressor:回归任务的"连续值映射器"
MLPRegressor 处理连续值预测,1.7 版新增 loss 参数支持 'squared_error' 和 'poisson'。
Poisson 损失与指数输出激活的联动:poisson 损失要求 y >= 0,输出层使用 exp 激活函数确保预测值为正。
多输出回归的矩阵形状管理:predict 时若输出维度为 1 则 ravel() 展平为一维数组。_score 使用 r2_score 作为默认评分函数。
逐行解析关键函数:
源码路径:sklearn/neural_network/_multilayer_perceptron.py - MLPRegressor._validate_input()(第1250-1263行)
def _validate_input(self, X, y, incremental, reset):
X, y = validate_data(self, X, y, accept_sparse=["csr", "csc"], multi_output=True, y_numeric=True, dtype=(np.float64, np.float32), reset=reset)
if y.ndim == 2 and y.shape[1] == 1:
y = column_or_1d(y, warn=True)
return X, y
源码路径:sklearn/neural_network/_multilayer_perceptron.py - MLPRegressor._predict()(第1234-1238行)
def _predict(self, X, check_input=True):
y_pred = self._forward_pass_fast(X, check_input=check_input)
if y_pred.shape[1] == 1:
return y_pred.ravel() // 单输出回归展平为一维
return y_pred
这段代码体现了回归任务相对于分类任务的简化:无需 LabelBinarizer,直接处理数值型目标。
16.16 优化器基类与 SGD 家族 —— 参数更新的"动力系统"
_stochastic_optimizers.py 定义了优化器体系。
BaseOptimizer 的多态接口:__init__ 初始化 learning_rate_init 和当前 learning_rate;update_params 将 _get_updates 的结果累加到参数上;iteration_ends 子类可重写实现学习率调度;trigger_stopping 返回 True 表示训练终止。
SGDOptimizer 的动量机制:__init__ 为每个参数创建对应形状的零速度向量。不带 Nesterov:update = momentum * velocity - lr * grad。带 Nesterov:先使用当前 velocity 计算中间更新,再应用新 velocity 得到更准确的"前瞻"更新。
自适应学习率的 trigger_stopping 策略:lr_schedule != 'adaptive' 时直接停止;adaptive 模式下学习率低于 1e-6 时停止,否则衰减 5 倍后继续训练。
invscaling 的 iteration_ends 调度:learning_rate = learning_rate_init / (time_step + 1)^power_t。
逐行解析关键函数:
源码路径:sklearn/neural_network/_stochastic_optimizers.py - BaseOptimizer.__init__()(第12-19行)
def __init__(self, learning_rate_init=0.1):
self.learning_rate_init = learning_rate_init
self.learning_rate = float(learning_rate_init) // 当前学习率,可被子类调度修改
源码路径:sklearn/neural_network/_stochastic_optimizers.py - BaseOptimizer.update_params()(第21-30行)
def update_params(self, params, grads):
"""Update parameters with given gradients"""
updates = self._get_updates(grads) // 子类实现具体更新规则
for param, update in zip((p for p in params), updates):
param += update // 原地更新参数
源码路径:sklearn/neural_network/_stochastic_optimizers.py - SGDOptimizer.__init__()(第68-83行)
def __init__(self, params, learning_rate_init=0.1, lr_schedule="constant", momentum=0.9, nesterov=True, power_t=0.5):
super().__init__(learning_rate_init)
self.lr_schedule = lr_schedule
self.momentum = momentum
self.nesterov = nesterov
self.power_t = power_t
self.velocities = [np.zeros_like(param) for param in params] // 为每个参数创建同形状零速度向量
源码路径:sklearn/neural_network/_stochastic_optimizers.py - SGDOptimizer._get_updates()(第170-199行)
def _get_updates(self, grads):
"""Get the values used to update params with given gradients"""
updates = [
self.momentum * velocity - self.learning_rate * grad
for velocity, grad in zip(self.velocities, grads)
]
self.velocities = updates // 更新速度向量
if self.nesterov:
updates = [
self.momentum * velocity - self.learning_rate * grad
for velocity, grad in zip(self.velocities, grads)
] // Nesterov: 用新 velocity 再算一次更新 (前瞻)
return updates
源码路径:sklearn/neural_network/_stochastic_optimizers.py - SGDOptimizer.trigger_stopping()(第149-168行)
def trigger_stopping(self, msg, verbose):
if self.lr_schedule != "adaptive":
if verbose: print(msg + " Stopping.")
return True // non-adaptive 直接停止
if self.learning_rate <= 1e-6:
if verbose: print(msg + " Learning rate too small. Stopping.")
return True // 学习率太小停止
self.learning_rate /= 5.0 // adaptive: 学习率除以 5 继续
if verbose: print(msg + " Setting learning rate to %f" % self.learning_rate)
return False
源码路径:sklearn/neural_network/_stochastic_optimizers.py - SGDOptimizer.iteration_ends()(第136-147行)
def iteration_ends(self, time_step):
if self.lr_schedule == "invscaling":
self.learning_rate = (
float(self.learning_rate_init) / (time_step + 1) ** self.power_t
) // invscaling: lr = lr_init / (t+1)^power_t
这段代码实现了 SGD 及其 Nesterov 动量变体,以及自适应学习率的早停策略。
架构图:
16.17 Adam 优化器 —— 自适应学习率的"智慧舵手"
Adam 结合了动量和自适应学习率,通过一阶矩(均值)和二阶矩(非中心方差)估计实现参数级自适应步长。
为什么需要一阶矩和二阶矩估计? ms 追踪梯度的指数移动平均,提供惯性;vs 追踪梯度平方的指数移动平均,自适应调整每个参数的有效学习率。
偏差修正的数学原理:t 较小时,ms 和 vs 的初始估计偏向 0。除以 (1 - beta_1^t) 和 sqrt(1 - beta_2^t) 修正初始阶段的低估。
epsilon 的数值稳定性保护:分母 rs = sqrt(v) + epsilon 防止梯度接近零时除法溢出。
逐行解析关键函数:
源码路径:sklearn/neural_network/_stochastic_optimizers.py - AdamOptimizer.__init__()(第205-217行)
def __init__(self, params, learning_rate_init=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-8):
super().__init__(learning_rate_init)
self.beta_1 = beta_1
self.beta_2 = beta_2
self.epsilon = epsilon
self.t = 0
self.ms = [np.zeros_like(param) for param in params] // 一阶矩向量 (均值估计)
self.vs = [np.zeros_like(param) for param in params] // 二阶矩向量 (非中心方差估计)
源码路径:sklearn/neural_network/_stochastic_optimizers.py - AdamOptimizer._get_updates()(第222-247行)
def _get_updates(self, grads):
"""Get the values used to update params with given gradients"""
self.t += 1
self.ms = [
self.beta_1 * m + (1 - self.beta_1) * grad
for m, grad in zip(self.ms, grads)
] // 一阶矩指数移动平均: m_t = beta_1 * m_{t-1} + (1-beta_1) * g_t
self.vs = [
self.beta_2 * v + (1 - self.beta_2) * (grad**2)
for v, grad in zip(self.vs, grads)
] // 二阶矩指数移动平均: v_t = beta_2 * v_{t-1} + (1-beta_2) * g_t^2
self.learning_rate = (
self.learning_rate_init
* np.sqrt(1 - self.beta_2**self.t)
/ (1 - self.beta_1**self.t)
) // 偏差修正后的学习率: lr_t = lr_init * sqrt(1-beta_2^t) / (1-beta_1^t)
updates = [
-self.learning_rate * m / (np.sqrt(v) + self.epsilon)
for m, v in zip(self.ms, self.vs)
] // 最终更新: -lr_t * m_hat / (sqrt(v_hat) + eps)
return updates
这段代码完整实现了 Adam 优化算法,包含偏差修正和自适应学习率计算。
架构图:
16.18 BernoulliRBM 的采样与变换机制 —— 能量函数的"蒙特卡洛实验室"
BernoulliRBM 实现了受限玻尔兹曼机,包含可见层/隐藏层条件分布、自由能计算、Gibbs 采样等核心组件。
为什么需要 _mean_hiddens 和 _sample_hiddens 分离? _mean_hiddens 提供 P(h=1|v) 的确定性概率;_sample_hiddens 在概率基础上进行伯努利采样,引入随机性。
_sample_visibles 的对称采样:从隐藏层计算 P(v=1|h),使用 expit 原地计算条件概率,使用 rng.uniform 与概率比较完成二值采样。
transform 与 _mean_hiddens 的关系:transform 验证输入后直接调用 _mean_hiddens,输出隐藏层激活概率,作为数据的低维表示。
逐行解析关键函数:
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM._mean_hiddens()(第135-149行)
def _mean_hiddens(self, v):
"""Computes the probabilities P(h=1|v)."""
p = safe_sparse_dot(v, self.components_.T) // 稀疏/稠密通用: v @ W^T
p += self.intercept_hidden_ // 加上隐藏层偏置
return expit(p, out=p) // 原地计算 sigmoid,返回 P(h=1|v)
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM._sample_hiddens()(第152-168行)
def _sample_hiddens(self, v, rng):
"""Sample from the distribution P(h|v)."""
p = self._mean_hiddens(v) // 先计算条件概率
return rng.uniform(size=p.shape) < p // 伯努利采样: uniform < p 即为 1
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM._sample_visibles()(第171-193行)
def _sample_visibles(self, h, rng):
"""Sample from the distribution P(v|h)."""
p = np.dot(h, self.components_) // h @ W
p += self.intercept_visible_ // 加上可见层偏置
expit(p, out=p) // 原地 sigmoid 得到 P(v=1|h)
return rng.uniform(size=p.shape) < p // 伯努利采样
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM.gibbs()(第216-232行)
def gibbs(self, v):
"""Perform one Gibbs sampling step."""
check_is_fitted(self)
if not hasattr(self, "random_state_"):
self.random_state_ = check_random_state(self.random_state)
h_ = self._sample_hiddens(v, self.random_state_) // v -> 采样 h
v_ = self._sample_visibles(h_, self.random_state_) // h -> 采样 v
return v_
这段代码实现了 RBM 的核心采样机制,利用 safe_sparse_dot 和 expit 的 in-place 操作保证计算效率。
流程图:
16.19 BernoulliRBM 的拟合过程 —— 对比散度的"权重雕刻师"
RBM 训练使用持久对比散度(PCD),通过正样本和负样本的对比更新参数。
参数更新公式的工程实现:lr 除以 batch 大小进行归一化。components_ 的更新:正样本外积减去负样本外积。intercept_hidden_ 和 intercept_visible_ 使用正负样本的求和差。
残差采样 h_samples_ 的持久化:每个 batch 结束后从 h_neg 中伯努利采样,作为下一个 batch 的负样本起点。
fit 的批次划分与 verbose 日志:gen_even_slices 将数据均匀分割为 n_batches 个批次。verbose=True 时周期性输出伪似然和迭代耗时。
partial_fit 的增量初始化:首次调用时初始化 components_、intercept_hidden_/visible_、h_samples_;后续调用直接更新已有参数,不重新初始化。
逐行解析关键函数:
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM._fit()(第277-302行)
def _fit(self, v_pos, rng):
"""Inner fit for one mini-batch."""
h_pos = self._mean_hiddens(v_pos) // 正样本隐藏概率 (确定性)
v_neg = self._sample_visibles(self.h_samples_, rng) // 从持久链采样负样本可见层
h_neg = self._mean_hiddens(v_neg) // 负样本隐藏概率
lr = float(self.learning_rate) / v_pos.shape[0] // 学习率归一化到每样本
update = safe_sparse_dot(v_pos.T, h_pos, dense_output=True).T // 正相关: v_pos^T @ h_pos
update -= np.dot(h_neg.T, v_neg) // 减去负相关: h_neg^T @ v_neg
self.components_ += lr * update // 权重更新: 正相关 - 负相关
self.intercept_hidden_ += lr * (h_pos.sum(axis=0) - h_neg.sum(axis=0)) // 隐藏偏置更新
self.intercept_visible_ += lr * (
np.asarray(v_pos.sum(axis=0)).squeeze() - v_neg.sum(axis=0)
) // 可见偏置更新
h_neg[rng.uniform(size=h_neg.shape) < h_neg] = 1.0 // 伯努利采样更新持久链
self.h_samples_ = np.floor(h_neg, h_neg)
这段代码实现了单批次的 PCD 参数更新,体现了"正相-负相"对比学习的核心思想。
流程图:
16.20 伪似然评分 —— 模型质量的"替代探针"
RBM 的配分函数计算复杂度极高,无法直接计算真实似然。伪似然通过随机翻转一个特征,比较自由能变化来近似似然。
score_samples 的单特征扰动策略:每个样本随机选择一个特征进行翻转。稀疏矩阵通过修改 data 数组而非复制整个矩阵。
自由能公式的工程实现:F(v) = -v·b_visible - sum(logaddexp(0, v·W + b_hidden))。logaddexp(0, x) = log(1+exp(x)) 提供数值稳定性。
逐行解析关键函数:
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM._free_energy()(第195-208行)
def _free_energy(self, v):
"""Computes the free energy F(v) = - log sum_h exp(-E(v,h))."""
return -safe_sparse_dot(v, self.intercept_visible_) - np.logaddexp(
0, safe_sparse_dot(v, self.components_.T) + self.intercept_hidden_
).sum(axis=1) // logaddexp(0, x) = log(1+exp(x)) 数值稳定
源码路径:sklearn/neural_network/_rbm.py - BernoulliRBM.score_samples()(第304-344行)
def score_samples(self, X):
"""Compute the pseudo-likelihood of X."""
v = validate_data(self, X, accept_sparse="csr", reset=False)
rng = check_random_state(self.random_state)
# 随机翻转每个样本的一个特征
ind = (np.arange(v.shape[0]), rng.randint(0, v.shape[1], v.shape[0]))
if sp.issparse(v):
data = -2 * v[ind] + 1
if isinstance(data, np.matrix): // v is a sparse matrix
v_ = v + sp.csr_matrix((data.A.ravel(), ind), shape=v.shape)
else: // v is a sparse array
v_ = v + sp.csr_array((data.ravel(), ind), shape=v.shape)
else:
v_ = v.copy()
v_[ind] = 1 - v_[ind] // 翻转单个特征
fe = self._free_energy(v)
fe_ = self._free_energy(v_)
# log(expit(x)) = -np.logaddexp(0, -x)
return -v.shape[1] * np.logaddexp(0, -(fe_ - fe)) // 伪似然近似
这段代码实现了 RBM 的伪似然评估,通过单特征扰动避免配分函数计算,兼顾稠密/稀疏输入。
架构图:
16.21 测试矩阵:从手算验证到稀疏一致性 —— MLP/RBM 的"质量保障体系"
测试套件覆盖了从数学正确性验证到工程鲁棒性的多维度检查。
test_fit 的手工梯度验证:使用固定权重和单个训练样本,手动计算前向传播、反向传播和参数更新的每一步,与 partial_fit 执行一次后的结果对比,验证梯度计算和更新公式的数学正确性。
test_gradient 的数值梯度检验:使用中心差分法(epsilon=1e-5)近似参数梯度,与 _loss_grad_lbfgs 返回的解析梯度对比,验证所有激活函数的导数实现。
稀疏与稠密一致性测试:CSR/CSC/LIL 容器参数化测试确保 MLP 和 RBM 对稀疏输入的输出与稠密一致。
dtype 一致性测试:float32 和 float64 输入下模型参数和预测结果的精度对比,验证 in-place 操作不会意外修改输入数据。
早停、热启动与增量学习的回归测试:test_early_stopping、test_warm_start、test_n_iter_no_change 等覆盖关键训练机制。
逐行解析关键测试:
源码路径:sklearn/neural_network/tests/test_mlp.py - test_gradient()(第180-226行)
def test_gradient():
# Test gradient.
for n_labels in [2, 3]:
n_samples = 5
n_features = 10
random_state = np.random.RandomState(seed=42)
X = random_state.rand(n_samples, n_features)
y = 1 + np.mod(np.arange(n_samples) + 1, n_labels)
Y = LabelBinarizer().fit_transform(y)
for activation in ACTIVATION_TYPES:
mlp = MLPClassifier(
activation=activation,
hidden_layer_sizes=10,
solver="lbfgs",
alpha=1e-5,
learning_rate_init=0.2,
max_iter=1,
random_state=1,
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", ConvergenceWarning)
mlp.fit(X, y)
theta = np.hstack([l.ravel() for l in mlp.coefs_ + mlp.intercepts_])
layer_units = [X.shape[1]] + [mlp.hidden_layer_sizes] + [mlp.n_outputs_]
activations = []
deltas = []
coef_grads = []
intercept_grads = []
activations.append(X)
for i in range(mlp.n_layers_ - 1):
activations.append(np.empty((X.shape[0], layer_units[i + 1])))
deltas.append(np.empty((X.shape[0], layer_units[i + 1])))
fan_in = layer_units[i]
fan_out = layer_units[i + 1]
coef_grads.append(np.empty((fan_in, fan_out)))
intercept_grads.append(np.empty(fan_out))
# analytically compute the gradients
def loss_grad_fun(t):
return mlp._loss_grad_lbfgs(
t, X, Y, None, activations, deltas, coef_grads, intercept_grads
)
[value, grad] = loss_grad_fun(theta)
numgrad = np.zeros(np.size(theta))
n = np.size(theta, 0)
E = np.eye(n)
epsilon = 1e-5
# numerically compute the gradients
for i in range(n):
dtheta = E[:, i] * epsilon
numgrad[i] = (
loss_grad_fun(theta + dtheta)[0] - loss_grad_fun(theta - dtheta)[0]
) / (epsilon * 2.0) // 中心差分法近似梯度
assert_almost_equal(numgrad, grad) // 解析梯度 vs 数值梯度
源码路径:sklearn/neural_network/tests/test_mlp.py - test_sparse_matrices()(第526-537行)
@pytest.mark.parametrize("csr_container", CSR_CONTAINERS)
def test_sparse_matrices(csr_container):
# Test that sparse and dense input matrices output the same results.
X = X_digits_binary[:50]
y = y_digits_binary[:50]
X_sparse = csr_container(X)
mlp = MLPClassifier(solver="lbfgs", hidden_layer_sizes=15, random_state=1)
mlp.fit(X, y)
pred1 = mlp.predict(X)
mlp.fit(X_sparse, y)
pred2 = mlp.predict(X_sparse)
assert_almost_equal(pred1, pred2)
pred1 = mlp.predict(X)
pred2 = mlp.predict(X_sparse)
assert_array_equal(pred1, pred2)
这段测试代码构建了完整的质量保障体系,从数学导数验证到工程边界条件覆盖。
16.22 包入口与模块组织 —— 神经网络模块的"门面"
__init__.py 定义了公共 API,仅暴露三个核心类:MLPClassifier、MLPRegressor、BernoulliRBM。
逐行解析关键文件:
源码路径:sklearn/neural_network/__init__.py(第1-9行)
from sklearn.neural_network._multilayer_perceptron import MLPClassifier, MLPRegressor
from sklearn.neural_network._rbm import BernoulliRBM
__all__ = ["BernoulliRBM", "MLPClassifier", "MLPRegressor"]
源码路径:sklearn/neural_network/tests/__init__.py(空文件)
// 空文件,标记 tests 为 Python 包
这段代码体现了 scikit-learn 模块化设计的原则:内部实现细节隐藏,仅通过 __all__ 暴露稳定的公共接口。
16.23 设计中的取舍
为什么隐藏层不支持 softmax 和 exp 激活?
源码路径:sklearn/neural_network/_base.py - DERIVATIVES 字典(第170-174行)
DERIVATIVES = {
"identity": inplace_identity_derivative,
"tanh": inplace_tanh_derivative,
"logistic": inplace_logistic_derivative,
"relu": inplace_relu_derivative,
}
DERIVATIVES 字典中缺少 softmax 和 exp 的导数实现。softmax 导数涉及雅可比矩阵(非对角元素非零),计算复杂度远高于逐元素导数;exp 导数即其本身,但在隐藏层极易导致数值上溢。设计选择将这两者限制在输出层,隐藏层仅支持 identity、logistic、tanh、relu 四种导数简单、数值稳定的激活函数。
为什么 LBFGS 需要 pack/unpack 而 SGD/Adam 不需要?
源码路径:sklearn/neural_network/_multilayer_perceptron.py - _fit_lbfgs() 与 _fit_stochastic() 对比
LBFGS 基于 scipy.optimize.minimize,其接口要求参数为一维向量,梯度也必须是一维向量。_pack/_unpack 是适配器模式,将层式结构参数与优化器接口解耦。SGD/Adam 直接操作参数列表(coefs_ + intercepts_),原地更新,无需打包,避免了每次迭代的内存拷贝开销。这是批量优化接口标准化与随机优化原地更新的根本差异。
RBM 为什么用伪似然而非真实似然?
源码路径:sklearn/neural_network/_rbm.py - score_samples()(第304-344行)
RBM 的配分函数(归一化常数)涉及对所有隐藏状态的求和,计算复杂度指数级。伪似然通过单特征扰动近似条件概率乘积,将复杂度降为线性。这是统计物理模型在工程落地时的必然妥协——用有偏但可计算的代理指标替代无偏但不可计算的真实指标。
16.24 动手练习
-
阅读前向传播与反向传播实现
-
阅读
sklearn/neural_network/_multilayer_perceptron.py中_forward_pass()和_backprop()的实现,理解:-
_forward_pass()如何逐层计算并缓存激活值 -
_forward_pass_fast()与训练版的差异在哪里 -
_backprop()中最后一层 delta 的计算公式为什么是activations[-1] - y
-
-
回答问题:
-
safe_sparse_dot在稀疏和稠密输入下如何保持行为一致? -
如果隐藏层激活改为
softmax,DERIVATIVES字典中缺少对应项会怎样?
-
-
-
追踪 LBFGS 的参数打包与还原流程
-
阅读
_fit_lbfgs()和_loss_grad_lbfgs()的实现,理解:-
_coef_indptr和_intercept_indptr如何记录每层参数的切片位置 -
_pack()和_unpack()如何配合scipy.optimize.minimize完成参数优化
-
-
回答问题:
-
如果网络结构是
[10, 20, 5, 1],_coef_indptr和_intercept_indptr各包含几个元素? -
为什么
_loss_grad_lbfgs每次被调用时都要先执行_unpack?
-
-
-
比较 SGDOptimizer 与 AdamOptimizer 的更新公式
-
阅读
sklearn/neural_network/_stochastic_optimizers.py,重点理解:-
SGDOptimizer._get_updates()中 Nesterov 动量与非 Nesterov 动量的差异 -
AdamOptimizer._get_updates()中偏差修正项(1 - beta_2**t)和(1 - beta_1**t)的作用
-
-
回答问题:
-
当
momentum=0时,SGD 退化成什么? -
Adam 的
epsilon参数在什么数值条件下对更新量影响最大?
-
-
-
解析 BernoulliRBM 的对比散度训练循环
-
阅读
sklearn/neural_network/_rbm.py中_fit()和score_samples()的实现,理解:-
_fit()中正样本h_pos为什么用_mean_hiddens而负样本v_neg用_sample_visibles? -
h_samples_的持久化策略如何实现 Persistent CD? -
score_samples()中伪似然的计算原理
-
-
回答问题:
-
如果
batch_size大于样本总数,gen_even_slices会如何处理? -
components_更新公式中为什么学习率要除以v_pos.shape[0]?
-
-
16.25 本章小结
这一章中我们学习/了解/讨论了神经网络模块的核心实现。首先深入剖析了激活函数与损失函数的 in-place 实现与数值稳定性技巧,其次详细解读了 BaseMultilayerPerceptron 的层叠架构、前向/反向传播算法与参数打包机制,接着对比了 LBFGS 批量优化与 SGD/Adam 随机优化的参数管理差异与训练循环细节,然后探讨了 BernoulliRBM 的能量模型、对比散度训练与伪似然评估,最后通过测试矩阵理解了数值梯度检验与稀疏一致性验证的质量保障体系。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| inplace_* 激活函数 | 在输入数组上原地修改,避免分配临时内存,适用于训练中的高频调用 |
| logistic 导数利用 f(x)(1-f(x)) | 利用前向输出计算导数,避免重复的指数运算 |
| squared_loss | 回归损失的默认选择,0.5 系数使梯度形式更简洁 |
| poisson_loss | 计数型回归损失,使用 xlogy 处理零边界,输出层需配合 exp 激活 |
| log_loss / binary_log_loss | 分类交叉熵损失,概率裁剪到 [eps, 1-eps] 防止 log(0) |
| _pack / _unpack | 将多层权重展平为一维向量供 LBFGS 使用,通过索引指针高效还原 |
| _forward_pass / _forward_pass_fast | 训练版缓存所有层激活值供反向传播使用,预测版只返回最终输出 |
| _backprop | 反向传播核心,最后一层 delta = 输出 - 目标,逐层传递误差并求梯度 |
| Glorot 初始化 | 根据 fan_in/fan_out 动态调整初始化范围,logistic 使用更窄的边界 |
| SGDOptimizer | 带动量(可选 Nesterov)的随机梯度下降,支持 constant/invscaling/adaptive 学习率 |
| AdamOptimizer | 自适应学习率优化器,通过一阶/二阶矩的偏差修正提升初期稳定性 |
| _update_no_improvement_count | 双模式早停判定:基于验证分数或训练损失,连续未改善则停止或衰减学习率 |
| BernoulliRBM._mean_hiddens | 计算 P(h=1|v),使用 expit 在输出数组上原地计算 |
| BernoulliRBM._fit | 单批次对比散度更新,正样本外积减负样本外积,学习率按批次大小归一化 |
| score_samples 伪似然 | 通过随机翻转一个特征并比较自由能变化来近似对数似然,规避配分函数计算 |
| test_gradient 数值梯度检验 | 用中心差分法近似梯度,与解析梯度对比验证所有激活函数的导数实现 |
| transform() / _mean_hiddens | RBM 变换输出隐藏层激活概率,与 _mean_hiddens 底层实现一致 |
| _sample_visibles | 从隐藏层采样可见层,使用 expit 原地计算条件概率 P(v=1|h) |
| partial_fit | 增量训练接口,首次调用时初始化参数,后续调用更新已有参数 |
| predict_log_proba | 对 predict_proba 结果原地取对数,避免额外数组分配 |
| _check_solver | 验证求解器是否为随机类型,非随机求解器禁止 partial_fit |
下一章中,我们将学习半监督学习 —— 利用"未标注数据的隐藏价值"。
第 17 章 —— 半监督学习 —— 利用“未标注数据的隐藏价值”
17.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解标签传播与标签扩散的图构建差异:概率转移矩阵 vs 归一化图拉普拉斯
-
掌握 BaseLabelPropagation 的核函数调度、迭代传播与硬/软钳制策略的源码实现
-
能推导 LabelPropagation 与 LabelSpreading 的闭式解,并验证迭代结果与解析解的一致性
-
理解 SelfTrainingClassifier 的伪标注选择逻辑(threshold 与 k_best)与三种终止条件的管理
-
掌握自训练分类器的条件方法暴露机制(available_if)与元数据路由实现
-
能阅读并修改半监督学习模块的源码,独立验证数据集切分与全局变量的边界处理
17.2 生活类比
想象半监督学习是一场社交网络中的知识扩散运动:已标注样本是掌握正确答案的种子员工,未标注样本是等待被培训的新人,相似度图则是员工之间的社交网络(关系越近信息传递越快)。标签传播(LabelPropagation)就像种子员工无论如何都不会改变观点的硬钳制,标签扩散(LabelSpreading)则是种子员工保留 alpha 比例固执的软钳制。自训练(SelfTraining)则像每次考试后把最有把握的新人提拔为下一轮培训师。就像公司需要控制谣言的传播边界,半监督学习也需要在信任初始标注与接纳未标注信息之间找到平衡。
17.3 源码地图
sklearn/semi_supervised/__init__.py
├── __all__ # 公共 API:LabelPropagation、LabelSpreading、SelfTrainingClassifier
sklearn/semi_supervised/_label_propagation.py
├── BaseLabelPropagation(ClassifierMixin, BaseEstimator, metaclass=ABCMeta)
│ ├── __init__() # 核函数/钳制因子/迭代参数初始化
│ ├── _get_kernel(X, y) # RBF/KNN/callable 核函数调度
│ ├── _build_graph() # 抽象方法,子类实现图矩阵构造
│ ├── predict(X) # 归纳式推理:argmax(proba) 映射到 classes_
│ ├── predict_proba(X) # 跨图传播 + 行归一化生成概率
│ ├── fit(X, y) # 主循环:传播迭代 + 硬/软钳制 + 收敛判据
│ └── __sklearn_tags__() # 标记 sparse=True 支持稀疏输入
├── LabelPropagation(BaseLabelPropagation)
│ ├── __init__() # 固定 alpha=None,默认 max_iter=1000
│ ├── _build_graph() # P = D⁻¹W 行归一化概率转移矩阵
│ └── fit(X, y) # 直接委托父类 fit
└── LabelSpreading(BaseLabelPropagation)
├── __init__() # alpha=0.2,默认 max_iter=30
└── _build_graph() # S = -L_norm = D⁻¹ᐟ² W D⁻¹ᐟ² 对角线置零
sklearn/semi_supervised/_self_training.py
├── SelfTrainingClassifier(ClassifierMixin, MetaEstimatorMixin, BaseEstimator)
│ ├── __init__() # threshold/criterion/k_best/max_iter 约束
│ ├── _get_estimator() # clone 底层估计器避免污染原对象
│ ├── fit(X, y, **params) # 迭代伪标注主循环 + 最终重拟合
│ ├── predict(X, **params) # available_if 条件暴露
│ ├── predict_proba(X, **params) # 委托底层 predict_proba
│ ├── decision_function(X, **params) # 委托底层 decision_function
│ ├── predict_log_proba(X, **params) # 委托底层 predict_log_proba
│ ├── score(X, y, **params) # 委托底层 score
│ ├── get_metadata_routing() # MethodMapping 7 组 callee→caller 映射
│ └── __sklearn_tags__() # 从 estimator 透传 sparse 标签
sklearn/semi_supervised/tests/test_label_propagation.py
├── test_fit_transduction() # 直推标签正确性
├── test_distribution() # 对称结构下概率接近 [0.5, 0.5]
├── test_predict() # 归纳式推理验证
├── test_predict_proba() # 概率输出验证
├── test_label_spreading_closed_form() # Zhou et al. 2004 闭式解
├── test_label_propagation_closed_form() # Zhu et al. 2002 闭式解
├── test_sparse_input_types() # CSR/CSC 数组与 int32/int64 索引
├── test_label_propagation_build_graph_normalized() # 行归一化验证
├── test_convergence_speed() # RBF 核 10 轮内收敛
├── test_convergence_warning() # max_iter 耗尽时告警
├── test_label_propagation_non_zero_normalizer() # 零归一化器防护
└── test_predict_sparse_callable_kernel() # 自定义稀疏核 score ≥ 0.9
sklearn/semi_supervised/tests/test_self_training.py
├── __main__ # 全局测试数据准备:iris 数据切分、字符串标签映射
├── test_warns_k_best() # k_best 过大时警告
├── test_classification() # 数值/字符串标签一致性 + labeled_iter 校验
├── test_k_best() # 每轮恰好添加 10 个样本
├── test_sanity_classification() # 自训练优于纯监督基线
├── test_none_iter() # max_iter=None 时 10 轮内收敛
├── test_zero_iterations() # max_iter=0 等价纯监督
├── test_prefitted_throws_error() # 传入已拟合估计器抛 NotFittedError
├── test_labeled_iter() # 迭代 0 样本数与初始标注数一致
├── test_no_unlabeled() # 全标注数据警告 + 等价纯监督
├── test_early_stopping() # no_change 终止条件
├── test_strings_dtype() # 字符串 dtype 拒绝
├── test_verbose() # verbose 输出控制
├── test_verbose_k_best() # k_best 模式 verbose 输出
├── test_k_best_selects_best() # 独立复现选择过程确认最优
├── test_estimator_meta_estimator() # 拟合后才暴露 predict_proba 的元估计器
├── test_self_training_estimator_attribute_error() # 缺失方法的 AttributeError
└── test_routing_passed_metadata_not_supported() # 路由未启用时拒绝元数据
17.4 半监督学习模块入口 —— 打开“未标注数据宝库”的钥匙
scikit-learn 的 semi_supervised 模块通过极简的 __init__.py 暴露三个核心算法:LabelPropagation(标签传播)、LabelSpreading(标签扩散)和 SelfTrainingClassifier(自训练分类器)。这种“最小暴露”原则避免了命名空间污染,同时为用户提供了清晰的入口。测试子包的 __init__.py 为空文件,仅用于 pytest 的包发现机制。
我们来看看这个模块的“门面”设计:
源码路径:sklearn/semi_supervised/__init__.py - 模块级(第1-8行)
"""Semi-supervised learning algorithms.
These algorithms utilize small amounts of labeled data and large amounts of unlabeled
data for classification tasks.
"""
# 第 17 章 —— Authors: The scikit-learn developers
# 第 17 章 —— SPDX-License-Identifier: BSD-3-Clause
from sklearn.semi_supervised._label_propagation import LabelPropagation, LabelSpreading
from sklearn.semi_supervised._self_training import SelfTrainingClassifier
__all__ = ["LabelPropagation", "LabelSpreading", "SelfTrainingClassifier"]
这段代码定义了半监督学习模块的公共 API。它仅导入并导出三个核心类,体现了 scikit-learn “模块化、最小暴露”的设计哲学。
源码路径:sklearn/semi_supervised/tests/__init__.py - 模块级(第1行)
测试包的初始化文件为空,仅作为 Python 包的标记存在,供 pytest 自动发现测试模块。
17.5 标签传播基类架构 —— 理解“信息扩散的中央调度台”
BaseLabelPropagation 是 LabelPropagation 和 LabelSpreading 的抽象基类,它集成了核函数调度、参数校验、分类器混入等核心能力。通过 ABCMeta 元类强制子类实现 _build_graph(),实现了“模板方法模式”:父类定义迭代骨架,子类填充图构建细节。
17.5.1 类型定义详解
BaseLabelPropagation 同时继承 ClassifierMixin(提供 score 等分类器通用方法)和 BaseEstimator(提供 get_params/set_params 等元估计器能力),并声明 _parameter_constraints 字典实现声明式参数验证。
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation.__init__()(第86-108行)
_parameter_constraints: dict = {
"kernel": [StrOptions({"knn", "rbf"}), callable], # 核函数类型约束
"gamma": [Interval(Real, 0, None, closed="left")], # RBF 核带宽,必须 > 0
"n_neighbors": [Interval(Integral, 0, None, closed="neither")], # KNN 邻居数,必须 > 0
"alpha": [None, Interval(Real, 0, 1, closed="neither")], # 钳制因子,LabelSpreading 专用 (0,1)
"max_iter": [Interval(Integral, 0, None, closed="neither")], # 最大迭代次数 > 0
"tol": [Interval(Real, 0, None, closed="left")], # 收敛容差 ≥ 0
"n_jobs": [None, Integral], # 并行作业数
}
def __init__(
self,
kernel="rbf",
*,
gamma=20,
n_neighbors=7,
alpha=1,
max_iter=30,
tol=1e-3,
n_jobs=None,
):
self.max_iter = max_iter
self.tol = tol
# kernel parameters
self.kernel = kernel
self.gamma = gamma
self.n_neighbors = n_neighbors
# clamping factor
self.alpha = alpha
self.n_jobs = n_jobs
这段代码定义了基类的参数约束与初始化逻辑。_parameter_constraints 使用 StrOptions、Interval 等验证器声明式地描述了每个参数的合法取值范围,validate_params 装饰器(通过 _fit_context 间接启用)会在 fit 时自动校验。alpha 参数在基类中默认为 1,但 LabelPropagation 会在子类中将其固定为 None,LabelSpreading 则将其约束为 (0, 1) 开区间。
17.5.2 核函数调度核心:_get_kernel
_get_kernel 是核函数的“调度中心”,根据 self.kernel 的类型分发到 RBF、KNN 或用户自定义可调用对象。RBF 生成稠密全连接亲和矩阵(\(O(N^2)\)),KNN 惰性创建 NearestNeighbors 返回稀疏图(\(O(kN)\)),可调用核直接调用用户函数。
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation._get_kernel()(第110-131行)
def _get_kernel(self, X, y=None):
if self.kernel == "rbf":
if y is None:
return rbf_kernel(X, X, gamma=self.gamma) # 训练时:X 到 X,生成 N×N 稠密矩阵
else:
return rbf_kernel(X, y, gamma=self.gamma) # 预测时:X_train 到 X_test,生成 N×M 矩阵
elif self.kernel == "knn":
if self.nn_fit is None: # 惰性初始化 NearestNeighbors
self.nn_fit = NearestNeighbors(
n_neighbors=self.n_neighbors, n_jobs=self.n_jobs
).fit(X)
if y is None:
return self.nn_fit.kneighbors_graph( # 训练时:返回稀疏连通图
self.nn_fit._fit_X, self.n_neighbors, mode="connectivity"
)
else:
return self.nn_fit.kneighbors(y, return_distance=False) # 预测时:返回邻居索引
elif callable(self.kernel):
if y is None:
return self.kernel(X, X) # 用户自定义核:训练
else:
return self.kernel(X, y) # 用户自定义核:预测
这段代码实现了核函数的三路分发。注意 KNN 分支的惰性初始化:self.nn_fit 只有在首次调用 _get_kernel 时才创建,这避免了在 __init__ 阶段就进行昂贵的近邻索引构建。预测阶段(y 不为 None)时,KNN 返回的是邻居索引数组而非权重矩阵,这在 predict_proba 中会被特殊处理。
17.5.3 抽象图构建接口
_build_graph() 被声明为抽象方法,强制子类实现具体的图矩阵构造逻辑。父类 fit() 会调用此方法获取传播算子。
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation._build_graph()(第133-138行)
@abstractmethod
def _build_graph(self):
raise NotImplementedError(
"Graph construction must be implemented to fit a label propagation model."
)
这是一个标准的模板方法模式钩子:BaseLabelPropagation.fit() 负责迭代循环、钳制、收敛判断等通用流程,而 _build_graph() 将“如何构建图矩阵”这一差异点下放给子类。LabelPropagation 返回行归一化的概率转移矩阵 \(P=D^{-1}W\),LabelSpreading 返回负归一化图拉普拉斯 \(-L_{norm}\)。
17.6 标签分布初始化与迭代主循环 —— 模拟“标签扩散的潮汐运动”
fit 方法是整个算法的心脏:它完成数据校验、标签分布初始化、迭代传播、钳制策略执行、收敛判断,最后生成直推预测 transduction_。
17.6.1 fit 方法的数据校验与标签分布初始化
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation.fit()(第198-293行)
@_fit_context(prefer_skip_nested_validation=True)
def fit(self, X, y):
"""Fit a semi-supervised label propagation model to X."""
# ① 数据校验:支持 CSR/CSC 稀疏矩阵,reset=True 表示重新拟合
X, y = validate_data(
self,
X,
y,
accept_sparse=["csr", "csc"],
reset=True,
)
self.X_ = X # 保存训练数据供 predict_proba 使用
check_classification_targets(y) # 拒绝回归目标、多标签等非法 y
# ② 实际图构建(子类实现)
graph_matrix = self._build_graph()
# ③ 标签构建:从 y 中剔除 -1 得到类别集合
classes = np.unique(y)
classes = classes[classes != -1]
self.classes_ = classes
n_samples, n_classes = len(y), len(classes)
y = np.asarray(y)
unlabeled = y == -1 # 未标注样本布尔掩码
# ④ 初始化分布矩阵:(n_samples, n_classes) 全零
self.label_distributions_ = np.zeros((n_samples, n_classes))
for label in classes:
self.label_distributions_[y == label, classes == label] = 1 # 已标注样本对应类别置 1
y_static = np.copy(self.label_distributions_) # 保存初始分布作为钳制锚点
if self._variant == "propagation":
# LabelPropagation:硬钳制,未标注位置归零
y_static[unlabeled] = 0
else:
# LabelSpreading:软钳制,初始分布乘以 (1-alpha)
y_static *= 1 - self.alpha
l_previous = np.zeros((self.X_.shape[0], n_classes)) # 上一轮分布,用于收敛判断
unlabeled = unlabeled[:, np.newaxis] # 扩展为 (n_samples, 1) 便于广播
if sparse.issparse(graph_matrix):
graph_matrix = graph_matrix.tocsr() # 统一转 CSR 格式便于矩阵乘法
# ⑤ 主迭代循环
for self.n_iter_ in range(self.max_iter):
# 收敛判据:两轮分布的绝对变化和 < tol
if np.abs(self.label_distributions_ - l_previous).sum() < self.tol:
break
l_previous = self.label_distributions_
# 信息扩散:图矩阵 @ 当前分布
self.label_distributions_ = safe_sparse_dot(
graph_matrix, self.label_distributions_
)
if self._variant == "propagation":
# 硬钳制分支
normalizer = np.sum(self.label_distributions_, axis=1)[:, np.newaxis]
normalizer[normalizer == 0] = 1 # 防除零
self.label_distributions_ /= normalizer # 行归一化
# 强制重置已标注样本为初始分布
self.label_distributions_ = np.where(
unlabeled, self.label_distributions_, y_static
)
else:
# 软钳制分支:alpha 加权融合传播结果与静态锚点
self.label_distributions_ = (
np.multiply(self.alpha, self.label_distributions_) + y_static
)
else:
# for-else:循环正常结束(未 break)即未收敛
warnings.warn(
"max_iter=%d was reached without convergence." % self.max_iter,
category=ConvergenceWarning,
)
self.n_iter_ += 1
# ⑥ 最终归一化
normalizer = np.sum(self.label_distributions_, axis=1)[:, np.newaxis]
normalizer[normalizer == 0] = 1
self.label_distributions_ /= normalizer
# ⑦ 生成直推预测
transduction = self.classes_[np.argmax(self.label_distributions_, axis=1)]
self.transduction_ = transduction.ravel()
return self
这段代码实现了标签传播的完整训练流程。关键设计点包括:
-
双钳制策略统一:通过
self._variant区分 "propagation"(硬钳制)和 "spreading"(软钳制),在同一循环中用if/else分支处理。 -
稀疏/稠密统一:
safe_sparse_dot自动处理稀疏矩阵与稠密矩阵的乘法,graph_matrix.tocsr()确保稀疏矩阵乘法效率。 -
收敛判据:使用分布矩阵的绝对变化和(L1 范数),而非相对变化,简单且有效。
-
直推 vs 归纳:
transduction_是训练集样本的标签(含伪标签),而predict()面向新样本做归纳式推理。
17.6.2 标签系统支持稀疏输入
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation.__sklearn_tags__()(第295-299行)
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.input_tags.sparse = True
return tags
通过重写 __sklearn_tags__,基类声明支持稀疏矩阵输入。这使得 validate_data 在 accept_sparse=["csr", "csc"] 时允许稀疏矩阵通过,且公共测试框架会自动验证稀疏兼容性。
17.7 预测与概率输出 —— 从“图上分布”到“新样本推断”
predict 和 predict_proba 实现了归纳式推理:利用训练时学到的 label_distributions_,通过核函数将新样本“连接”到训练图上,传播概率。
17.7.1 predict 的归纳式推理
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation.predict()(第140-162行)
def predict(self, X):
"""Perform inductive inference across the model."""
# Note: since `predict` does not accept semi-supervised labels as input,
# `fit(X, y).predict(X) != fit(X, y).transduction_`.
# Hence, `fit_predict` is not implemented.
probas = self.predict_proba(X)
return self.classes_[np.argmax(probas, axis=1)].ravel()
predict 仅是 predict_proba 的薄封装:取概率最大的类别索引,映射回 classes_。注释明确警告:fit(X, y).predict(X) 不等于 transduction_,因为前者是归纳式推理(通过核函数泛化),后者是直推式结果(直接优化训练集上的分布)。
17.7.2 predict_proba 的跨图传播与稀疏兼容
源码路径:sklearn/semi_supervised/_label_propagation.py - BaseLabelPropagation.predict_proba()(第164-196行)
def predict_proba(self, X):
"""Predict probability for each possible outcome."""
check_is_fitted(self)
X_2d = validate_data(
self,
X,
accept_sparse=["csc", "csr", "coo", "dok", "bsr", "lil", "dia"],
reset=False, # 预测时不重置已拟合状态
)
weight_matrices = self._get_kernel(self.X_, X_2d) # 训练集到测试集的核矩阵
if self.kernel == "knn":
# KNN 分支:weight_matrices 形状 (n_test, n_neighbors),含邻居索引
probabilities = np.array(
[
np.sum(self.label_distributions_[weight_matrix], axis=0)
for weight_matrix in weight_matrices
]
)
else:
# 稠密核分支:转置后与训练分布矩阵相乘
weight_matrices = weight_matrices.T # (n_test, n_train)
probabilities = safe_sparse_dot(weight_matrices, self.label_distributions_)
normalizer = np.atleast_2d(np.sum(probabilities, axis=1)).T
probabilities /= normalizer # 行归一化得到概率分布
return probabilities
这段代码展示了两种核模式下预测的差异:
-
KNN 核:
_get_kernel返回邻居索引数组(n_test, n_neighbors),对每个测试样本,直接求和其近邻的标签分布。 -
稠密核(RBF/Callable):
_get_kernel返回权重矩阵(n_train, n_test),转置后与(n_train, n_classes)的label_distributions_做矩阵乘法,得到(n_test, n_classes)的非归一化概率,最后行归一化。
validate_data 接受 7 种稀疏格式,reset=False 确保预测时不清除 self.X_ 等已拟合属性。
17.8 LabelPropagation 图构建 —— 行归一化的“概率转移矩阵”
LabelPropagation 实现了硬钳制策略,其图矩阵是行归一化的概率转移矩阵 \(P = D^{-1}W\),对应随机游走的一步转移概率。
17.8.1 LabelPropagation 的专属参数设计
源码路径:sklearn/semi_supervised/_label_propagation.py - LabelPropagation.__init__()(第351-369行)
_variant = "propagation"
_parameter_constraints: dict = {**BaseLabelPropagation._parameter_constraints}
_parameter_constraints.pop("alpha") # 移除 alpha 约束,固定为 None
def __init__(
self,
kernel="rbf",
*,
gamma=20,
n_neighbors=7,
max_iter=1000, # 默认 1000,收敛较慢
tol=1e-3,
n_jobs=None,
):
super().__init__(
kernel=kernel,
gamma=gamma,
n_neighbors=n_neighbors,
max_iter=max_iter,
tol=tol,
n_jobs=n_jobs,
alpha=None, # 显式传 None
)
LabelPropagation 通过类属性 _variant = "propagation" 标识自身策略,移除 alpha 参数约束(因为硬钳制不需要),并将默认 max_iter 设为 1000——行归一化的转移矩阵谱半径通常接近 1,收敛比软钳制慢。
17.8.2 _build_graph 的行归一化逻辑
源码路径:sklearn/semi_supervised/_label_propagation.py - LabelPropagation._build_graph()(第371-392行)
def _build_graph(self):
"""Matrix representing a fully connected graph between each sample."""
if self.kernel == "knn":
self.nn_fit = None # 强制重新拟合近邻模型
affinity_matrix = self._get_kernel(self.X_) # 获取亲和矩阵 W
normalizer = affinity_matrix.sum(axis=1) # 行求和得到度向量 D
# handle spmatrix (make normalizer 1D)
if sparse.isspmatrix(affinity_matrix):
normalizer = np.ravel(normalizer)
# 稀疏分支:对角矩阵左乘实现 D⁻¹W
if sparse.issparse(affinity_matrix):
inv_normalizer = sparse.diags(1.0 / normalizer)
affinity_matrix = inv_normalizer @ affinity_matrix
else: # Dense affinity_matrix
# 稠密分支:广播除法
affinity_matrix /= normalizer[:, np.newaxis]
return affinity_matrix
这段代码构造了随机游走转移矩阵 \(P = D^{-1}W\)。稀疏分支使用 sparse.diags 构造对角矩阵左乘,稠密分支利用 NumPy 广播机制逐行除以度值。注意 KNN 模式下 self.nn_fit = None 强制重新拟合,因为 _get_kernel 的惰性初始化依赖该属性为 None。
17.8.3 fit 方法直接委托父类
源码路径:sklearn/semi_supervised/_label_propagation.py - LabelPropagation.fit()(第394-418行)
def fit(self, X, y):
"""Fit a semi-supervised label propagation model to X."""
return super().fit(X, y)
LabelPropagation 不覆盖 fit,完全复用父类的迭代逻辑,仅通过 _variant 和 _build_graph 定制行为。
17.9 LabelSpreading 图构建 —— 归一化图拉普拉斯的“谱变换”
LabelSpreading 基于归一化图拉普拉斯 \(L_{norm} = I - D^{-1/2}WD^{-1/2}\) 构建传播算子 \(S = -L_{norm} = D^{-1/2}WD^{-1/2}\),并采用软钳制(参数 \(\alpha\) 控制)。
17.9.1 LabelSpreading 的软钳制参数体系
源码路径:sklearn/semi_supervised/_label_propagation.py - LabelSpreading.__init__()(第466-486行)
_variant = "spreading"
_parameter_constraints: dict = {**BaseLabelPropagation._parameter_constraints}
_parameter_constraints["alpha"] = [Interval(Real, 0, 1, closed="neither")] # 强制 (0,1)
def __init__(
self,
kernel="rbf",
*,
gamma=20,
n_neighbors=7,
alpha=0.2, # 默认 0.2,偏向信任初始标签
max_iter=30, # 默认 30,收敛快
tol=1e-3,
n_jobs=None,
):
super().__init__(
kernel=kernel,
gamma=gamma,
n_neighbors=n_neighbors,
alpha=alpha,
max_iter=max_iter,
tol=tol,
n_jobs=n_jobs,
)
alpha 被严格约束在 \((0, 1)\) 开区间:\(\alpha \to 0\) 完全信任初始标签(退化为纯监督),\(\alpha \to 1\) 完全接受邻居信息。默认 max_iter=30 反映拉普拉斯矩阵谱性质更好、收敛更快。
17.9.2 _build_graph 的拉普拉斯变换
源码路径:sklearn/semi_supervised/_label_propagation.py - LabelSpreading._build_graph()(第488-507行)
def _build_graph(self):
"""Graph matrix for Label Spreading computes the graph laplacian"""
if self.kernel == "knn":
self.nn_fit = None
n_samples = self.X_.shape[0]
affinity_matrix = self._get_kernel(self.X_) # W
laplacian = csgraph_laplacian(affinity_matrix, normed=True) # L_norm = I - D⁻¹ᐟ²WD⁻¹ᐟ²
laplacian = -laplacian # S = -L_norm = D⁻¹ᐟ²WD⁻¹ᐟ²
if sparse.issparse(laplacian):
diag_mask = laplacian.row == laplacian.col # 稀疏矩阵对角线掩码
laplacian.data[diag_mask] = 0.0
else:
laplacian.flat[:: n_samples + 1] = 0.0 # 稠密矩阵步长技巧清零对角线
return laplacian
这里使用 scipy.sparse.csgraph.laplacian(normed=True) 计算归一化图拉普拉斯 \(L_{norm}\),取负得到传播算子 \(S\)。关键步骤是对角线置零:\(S_{ii}=0\) 防止节点向自身传播信息(自环)。稀疏矩阵利用 COO 格式的 row/col 属性定位对角线,稠密矩阵利用 flat[::n+1] 步长访问对角线元素,这是 NumPy 中清零对角线的经典技巧。
17.10 标签传播测试剖析 —— 用“闭式解”验证迭代算法
测试文件 test_label_propagation.py 通过参数化测试覆盖 6 种估计器组合(2 算法 × 3 核),并使用数学闭式解验证迭代结果的正确性。
17.10.1 测试覆盖的核心维度
ESTIMATORS 参数化列表涵盖 rbf/knn/可调用核 × LabelPropagation/LabelSpreading 六种组合:
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - 模块级(第15-33行)
SPARSE_TYPES = ("sparse_csr", "sparse_csc", "sparse_csr_array", "sparse_csc_array")
CONSTRUCTOR_TYPES = ("array",) + SPARSE_TYPES
ESTIMATORS = [
(label_propagation.LabelPropagation, {"kernel": "rbf"}),
(label_propagation.LabelPropagation, {"kernel": "knn", "n_neighbors": 2}),
(
label_propagation.LabelPropagation,
{"kernel": lambda x, y: rbf_kernel(x, y, gamma=20)},
),
(label_propagation.LabelSpreading, {"kernel": "rbf"}),
(label_propagation.LabelSpreading, {"kernel": "knn", "n_neighbors": 2}),
(
label_propagation.LabelSpreading,
{"kernel": lambda x, y: rbf_kernel(x, y, gamma=20)},
),
]
这种参数化设计确保每个测试函数都在 6 种配置下运行,覆盖算法×核的笛卡尔积。
17.10.2 基础功能测试:直推与分布
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_fit_transduction()(第36-41行)
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_fit_transduction(global_dtype, Estimator, parameters):
samples = np.asarray([[1.0, 0.0], [0.0, 2.0], [1.0, 3.0]], dtype=global_dtype)
labels = [0, 1, -1]
clf = Estimator(**parameters).fit(samples, labels)
assert clf.transduction_[2] == 1
最简单的直推测试:3 个样本,前两个分属类别 0/1,第三个未标注。由于第三个样本更接近第二个,直推标签应为 1。
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_distribution()(第44-52行)
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_distribution(global_dtype, Estimator, parameters):
if parameters["kernel"] == "knn":
pytest.skip("Unstable test for this configuration: changes in k-NN ordering break it.")
samples = np.asarray([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=global_dtype)
labels = [0, 1, -1]
clf = Estimator(**parameters).fit(samples, labels)
assert_allclose(clf.label_distributions_[2], [0.5, 0.5], atol=1e-2)
对称结构下(第三个样本到前两个距离相等),概率分布应接近 [0.5, 0.5]。KNN 因近邻顺序不稳定被跳过。
17.10.3 归纳式推理验证
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_predict()(第55-60行)
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_predict(global_dtype, Estimator, parameters):
samples = np.asarray([[1.0, 0.0], [0.0, 2.0], [1.0, 3.0]], dtype=global_dtype)
labels = [0, 1, -1]
clf = Estimator(**parameters).fit(samples, labels)
assert_array_equal(clf.predict([[0.5, 2.5]]), np.array([1]))
验证 predict 对新样本 [[0.5, 2.5]] 的归纳式推理,预期类别为 1。
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_predict_proba()(第63-68行)
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_predict_proba(global_dtype, Estimator, parameters):
samples = np.asarray([[1.0, 0.0], [0.0, 1.0], [1.0, 2.5]], dtype=global_dtype)
labels = [0, 1, -1]
clf = Estimator(**parameters).fit(samples, labels)
assert_allclose(clf.predict_proba([[1.0, 1.0]]), np.array([[0.5, 0.5]]))
验证对称位置新样本的概率输出为 [0.5, 0.5]。
17.10.4 闭式解验证的数学之美
17.10.4.1 LabelSpreading 闭式解:Zhou et al. 2004
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_label_spreading_closed_form()(第71-92行)
@pytest.mark.parametrize("alpha", [0.1, 0.3, 0.5, 0.7, 0.9])
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_label_spreading_closed_form(global_dtype, Estimator, parameters, alpha):
n_classes = 2
X, y = make_classification(n_classes=n_classes, n_samples=200, random_state=0)
X = X.astype(global_dtype, copy=False)
y[::3] = -1
gamma = 0.1
clf = label_propagation.LabelSpreading(gamma=gamma).fit(X, y)
# adopting notation from Zhou et al (2004):
S = clf._build_graph()
Y = np.zeros((len(y), n_classes + 1), dtype=X.dtype)
Y[np.arange(len(y)), y] = 1
Y = Y[:, :-1] # 移除 -1 列
expected = np.dot(np.linalg.inv(np.eye(len(S), dtype=S.dtype) - alpha * S), Y)
expected /= expected.sum(axis=1)[:, np.newaxis]
clf = label_propagation.LabelSpreading(
max_iter=100, alpha=alpha, tol=1e-10, gamma=gamma
)
clf.fit(X, y)
assert_allclose(expected, clf.label_distributions_)
LabelSpreading 的迭代公式为 \(F^{(t)} = \alpha S F^{(t-1)} + (1-\alpha)Y\)。稳态解满足 \(F^* = \alpha S F^* + (1-\alpha)Y\),解得 \(F^* = (I - \alpha S)^{-1}(1-\alpha)Y\)。测试中构造的 Y 包含初始标签(未标注行为 0),expected 即解析解,最后行归一化后与迭代结果比较。
17.10.4.2 LabelPropagation 闭式解:Zhu et al. 2002
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_label_propagation_closed_form()(第95-118行)
def test_label_propagation_closed_form(global_dtype):
n_classes = 2
X, y = make_classification(n_classes=n_classes, n_samples=200, random_state=0)
X = X.astype(global_dtype, copy=False)
y[::3] = -1
Y = np.zeros((len(y), n_classes + 1))
Y[np.arange(len(y)), y] = 1
unlabelled_idx = Y[:, (-1,)].nonzero()[0]
labelled_idx = (Y[:, (-1,)] == 0).nonzero()[0]
clf = label_propagation.LabelPropagation(max_iter=100, tol=1e-10, gamma=0.1)
clf.fit(X, y)
# adopting notation from Zhu et al 2002
T_bar = clf._build_graph() # P = D⁻¹W
Tuu = T_bar[tuple(np.meshgrid(unlabelled_idx, unlabelled_idx, indexing="ij"))]
Tul = T_bar[tuple(np.meshgrid(unlabelled_idx, labelled_idx, indexing="ij"))]
Y = Y[:, :-1]
Y_l = Y[labelled_idx, :]
Y_u = np.dot(np.dot(np.linalg.inv(np.eye(Tuu.shape[0]) - Tuu), Tul), Y_l)
expected = Y.copy()
expected[unlabelled_idx, :] = Y_u
expected /= expected.sum(axis=1)[:, np.newaxis]
assert_allclose(expected, clf.label_distributions_, atol=1e-4)
LabelPropagation 的硬钳制将转移矩阵分块:
未标注节点分布满足 \(F_u = T_{ul}F_l + T_{uu}F_u\),解得 \(F_u = (I - T_{uu})^{-1}T_{ul}F_l\)。测试通过 meshgrid 索引提取子矩阵 Tuu、Tul,验证解析解与迭代结果一致。
17.10.5 边界条件与异常处理测试
17.10.5.1 稀疏输入类型兼容性
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_sparse_input_types()(第121-134行)
@pytest.mark.parametrize("accepted_sparse_type", SPARSE_TYPES)
@pytest.mark.parametrize("index_dtype", [np.int32, np.int64])
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
@pytest.mark.parametrize("Estimator, parameters", ESTIMATORS)
def test_sparse_input_types(
accepted_sparse_type, index_dtype, dtype, Estimator, parameters
):
X = _convert_container([[1.0, 0.0], [0.0, 2.0], [1.0, 3.0]], accepted_sparse_type)
X.data = X.data.astype(dtype, copy=False)
X.indices = X.indices.astype(index_dtype, copy=False)
X.indptr = X.indptr.astype(index_dtype, copy=False)
labels = [0, 1, -1]
clf = Estimator(**parameters).fit(X, labels)
assert_array_equal(clf.predict([[0.5, 2.5]]), np.array([1]))
全方位验证 CSR/CSC、int32/int64 索引、float32/float64 数据类型的组合兼容性。
17.10.5.2 行归一化验证
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_label_propagation_build_graph_normalized()(第137-156行)
@pytest.mark.parametrize("constructor", CONSTRUCTOR_TYPES)
@pytest.mark.parametrize("Estimator, parameters", LP_ESTIMATORS)
def test_label_propagation_build_graph_normalized(constructor, Estimator, parameters):
X = np.array([[1.0, 0.0], [1.0, 1.0], [1.0, 3.0]])
labels = [0, 1, -1]
aff_matrix = np.array([[1.0, 1.0, 0.0], [2.0, 1.0, 1.0], [0.0, 1.0, 3.0]])
expected = np.array([[0.5, 0.5, 0.0], [0.5, 0.25, 0.25], [0.0, 0.25, 0.75]])
def kernel_affinity_matrix(x, y=None):
return _convert_container(aff_matrix, constructor)
clf = Estimator(kernel=kernel_affinity_matrix).fit(X, labels)
graph = clf._build_graph()
assert_allclose(graph.sum(axis=1), 1) # normalized rows
if issparse(graph):
graph = graph.toarray()
assert_allclose(graph, expected)
通过自定义核函数注入固定亲和矩阵,验证 LabelPropagation._build_graph() 对稠密/稀疏输入均能正确行归一化。
17.10.5.3 收敛速度与警告
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_convergence_speed()(第159-168行)
@pytest.mark.parametrize("constructor_type", CONSTRUCTOR_TYPES)
def test_convergence_speed(constructor_type):
X = _convert_container([[1.0, 0.0], [0.0, 1.0], [1.0, 2.5]], constructor_type)
y = np.array([0, 1, -1])
mdl = label_propagation.LabelSpreading(kernel="rbf", max_iter=5000)
mdl.fit(X, y)
assert mdl.n_iter_ < 10
assert_array_equal(mdl.predict(X), [0, 1, 1])
RBF 核下 LabelSpreading 应在 10 轮内收敛,验证软钳制加速收敛的特性。
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_convergence_warning()(第171-190行)
def test_convergence_warning():
X = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 2.5]])
y = np.array([0, 1, -1])
mdl = label_propagation.LabelSpreading(kernel="rbf", max_iter=1)
warn_msg = "max_iter=1 was reached without convergence."
with pytest.warns(ConvergenceWarning, match=warn_msg):
mdl.fit(X, y)
assert mdl.n_iter_ == mdl.max_iter
...
验证 max_iter 耗尽时抛出 ConvergenceWarning 且 n_iter_ == max_iter。
17.10.5.4 零归一化器防护
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_label_propagation_non_zero_normalizer()(第193-206行)
@pytest.mark.parametrize(
"LabelPropagationCls",
[label_propagation.LabelSpreading, label_propagation.LabelPropagation],
)
def test_label_propagation_non_zero_normalizer(LabelPropagationCls):
X = np.array([[100.0, 100.0], [100.0, 100.0], [0.0, 0.0], [0.0, 0.0]])
y = np.array([0, 1, -1, -1])
mdl = LabelPropagationCls(kernel="knn", max_iter=100, n_neighbors=1)
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
mdl.fit(X, y)
构造两个重合样本对,KNN=1 导致某些节点度为 0,验证代码中的 normalizer[normalizer == 0] = 1 防除零逻辑有效,不抛 RuntimeWarning。
17.10.5.5 自定义稀疏核验证
源码路径:sklearn/semi_supervised/tests/test_label_propagation.py - test_predict_sparse_callable_kernel()(第209-232行)
def test_predict_sparse_callable_kernel(global_dtype):
def topk_rbf(X, Y=None, n_neighbors=10, gamma=1e-5):
nn = NearestNeighbors(n_neighbors=10, metric="euclidean", n_jobs=2)
nn.fit(X)
W = -1 * nn.kneighbors_graph(Y, mode="distance").power(2) * gamma
np.exp(W.data, out=W.data)
assert issparse(W)
return W.T
n_classes = 4
n_samples = 500
n_test = 10
X, y = make_classification(...)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=n_test, random_state=0)
model = label_propagation.LabelSpreading(kernel=topk_rbf)
model.fit(X_train, y_train)
assert model.score(X_test, y_test) >= 0.9
...
用户自定义可调用核 topk_rbf 返回稀疏矩阵,验证 LabelSpreading 和 LabelPropagation 均能正确处理稀疏核且达到高准确率。
17.11 自训练分类器架构 —— 打造“自我进化的伪标注引擎”
SelfTrainingClassifier 是一个元估计器,它将任意有监督分类器包装为半监督分类器,通过迭代伪标注扩充训练集。
17.11.1 SelfTrainingClassifier 的元估计器定位与参数约束
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier.__init__()(第144-157行)
_parameter_constraints: dict = {
"estimator": [HasMethods(["fit"])], # 仅要求 fit,允许拟合后才暴露 predict_proba
"threshold": [Interval(Real, 0.0, 1.0, closed="left")],
"criterion": [StrOptions({"threshold", "k_best"})],
"k_best": [Interval(Integral, 1, None, closed="left")],
"max_iter": [Interval(Integral, 0, None, closed="left"), None],
"verbose": ["verbose"],
}
def __init__(
self,
estimator=None,
threshold=0.75,
criterion="threshold",
k_best=10,
max_iter=10,
verbose=False,
):
self.estimator = estimator
self.threshold = threshold
self.criterion = criterion
self.k_best = k_best
self.max_iter = max_iter
self.verbose = verbose
estimator 仅要求实现 fit(通过 HasMethods(["fit"])),不强制要求 predict_proba,这允许传入“拟合后才暴露 predict_proba”的元估计器(如 StackingClassifier)。clone 机制确保用户原始估计器不被污染。
17.11.2 克隆估计器与标签透传
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier._get_estimator()(第159-167行)
def _get_estimator(self):
"""Get the estimator."""
return clone(self.estimator)
clone 深拷贝估计器,保证每次 fit 都是全新的实例,避免副作用。
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier.__sklearn_tags__()(第454-458行)
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse
return tags
稀疏支持标签从底层估计器透传:如果底层支持稀疏,自训练分类器也支持。
17.12 自训练迭代主循环 —— 观察“伪标签的滚雪球效应”
fit 方法实现了自训练的核心循环:每轮在当前已标注集上拟合,对未标注集预测概率,按 threshold 或 k_best 选择高置信样本加入训练集,直到终止条件触发。
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier.fit()(第169-303行)
@_fit_context(prefer_skip_nested_validation=False)
def fit(self, X, y, **params):
_raise_for_params(params, self, "fit") # 元数据路由未启用时拒绝额外参数
self.estimator_ = self._get_estimator() # 克隆估计器
# 数据校验:支持 CSR/CSC/LIL/DOK,不强制检查有限性(委托给底层估计器)
X, y = validate_data(
self,
X,
y,
accept_sparse=["csr", "csc", "lil", "dok"],
ensure_all_finite=False,
)
if y.dtype.kind in ["U", "S"]:
raise ValueError(
"y has dtype string. If you wish to predict on "
"string targets, use dtype object, and use -1"
" as the label for unlabeled samples."
)
has_label = y != -1 # 已标注布尔掩码
if np.all(has_label):
warnings.warn("y contains no unlabeled samples", UserWarning)
if self.criterion == "k_best" and (
self.k_best > X.shape[0] - np.sum(has_label)
):
warnings.warn(
(
"k_best is larger than the amount of unlabeled "
"samples. All unlabeled samples will be labeled in "
"the first iteration"
),
UserWarning,
)
# 元数据路由:根据全局配置决定是否处理路由参数
if _routing_enabled():
routed_params = process_routing(self, "fit", **params)
else:
routed_params = Bunch(estimator=Bunch(fit={}))
self.transduction_ = np.copy(y) # 直推标签副本,将被逐步填充
self.labeled_iter_ = np.full_like(y, -1) # -1 表示从未标注
self.labeled_iter_[has_label] = 0 # 初始标注样本迭代号为 0
self.n_iter_ = 0
# 主循环
while not np.all(has_label) and (
self.max_iter is None or self.n_iter_ < self.max_iter
):
self.n_iter_ += 1
# 在当前已标注子集上拟合
self.estimator_.fit(
X[safe_mask(X, has_label)],
self.transduction_[has_label],
**routed_params.estimator.fit,
)
# 对未标注子集预测概率
prob = self.estimator_.predict_proba(X[safe_mask(X, ~has_label)])
pred = self.estimator_.classes_[np.argmax(prob, axis=1)]
max_proba = np.max(prob, axis=1)
# 选择策略
if self.criterion == "threshold":
selected = max_proba > self.threshold # 布尔掩码
else:
n_to_select = min(self.k_best, max_proba.shape[0])
if n_to_select == max_proba.shape[0]:
selected = np.ones_like(max_proba, dtype=bool)
else:
# argpartition 返回索引,非掩码
selected = np.argpartition(-max_proba, n_to_select)[:n_to_select]
# 将局部索引映射回原始数组位置
selected_full = np.nonzero(~has_label)[0][selected]
# 更新状态
self.transduction_[selected_full] = pred[selected]
has_label[selected_full] = True
self.labeled_iter_[selected_full] = self.n_iter_
if selected_full.shape[0] == 0:
self.termination_condition_ = "no_change"
break
if self.verbose:
print(
f"End of iteration {self.n_iter_},"
f" added {selected_full.shape[0]} new labels."
)
# 循环结束后的终止条件判定
if self.n_iter_ == self.max_iter:
self.termination_condition_ = "max_iter"
if np.all(has_label):
self.termination_condition_ = "all_labeled"
# 最终在完整标注集上重拟合
self.estimator_.fit(
X[safe_mask(X, has_label)],
self.transduction_[has_label],
**routed_params.estimator.fit,
)
self.classes_ = self.estimator_.classes_
return self
这段代码实现了自训练的完整流水线。关键设计点:
-
三种终止条件:
max_iter耗尽、no_change(本轮无新增)、all_labeled(全量标注完成)。 -
索引映射技巧:
np.nonzero(~has_label)[0]获取未标注样本在原数组中的位置,[selected]将局部选择索引映射回全局。 -
k_best 的 argpartition:
np.argpartition(-max_proba, n_to_select)[:n_to_select]以 \(O(N)\) 复杂度选出前 k 大值的索引(非排序),比全排序更高效。 -
最终重拟合:循环结束后用完整标注集(含所有伪标签)重新拟合
estimator_,classes_取自底层估计器。
17.13 条件方法暴露与元数据路由 —— 自训练的“动态接口设计”
SelfTrainingClassifier 使用 available_if 装饰器根据底层估计器能力动态暴露方法,并实现了完整的元数据路由支持。
17.13.1 available_if 装饰器的条件方法
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier.predict()(第305-334行)
@available_if(_estimator_has("predict"))
def predict(self, X, **params):
"""Predict the classes of `X`."""
check_is_fitted(self)
_raise_for_params(params, self, "predict")
if _routing_enabled():
routed_params = process_routing(self, "predict", **params)
else:
routed_params = Bunch(estimator=Bunch(predict={}))
X = validate_data(
self,
X,
accept_sparse=True,
ensure_all_finite=False,
reset=False,
)
return self.estimator_.predict(X, **routed_params.estimator.predict)
@available_if(_estimator_has("predict")) 仅在 estimator_ 拥有 predict 方法时暴露该方法。否则访问会抛出包含内外层信息的 AttributeError。predict_proba、decision_function、predict_log_proba、score 均采用相同模式。
17.13.2 元数据路由的完整实现
源码路径:sklearn/semi_supervised/_self_training.py - SelfTrainingClassifier.get_metadata_routing()(第454-476行)
def get_metadata_routing(self):
"""Get metadata routing of this object."""
router = MetadataRouter(owner=self)
router.add(
estimator=self.estimator,
method_mapping=(
MethodMapping()
.add(callee="fit", caller="fit")
.add(callee="score", caller="fit")
.add(callee="predict", caller="predict")
.add(callee="predict_proba", caller="predict_proba")
.add(callee="decision_function", caller="decision_function")
.add(callee="predict_log_proba", caller="predict_log_proba")
.add(callee="score", caller="score")
),
)
return router
声明了 7 组 callee→caller 映射:
-
fit时向底层fit和score传递元数据(如sample_weight) -
predict/predict_proba/... 向对应底层方法传递元数据
_routing_enabled() 检测全局配置,未启用时使用空 Bunch 隔离参数,_raise_for_params 在路由未启用时对传入的额外参数抛出 ValueError。
17.14 自训练测试全景 —— 验证“伪标注质量与边界条件”
test_self_training.py 通过模块级全局数据准备,构建复用的测试基座,覆盖分类正确性、策略行为、边界条件、异常处理、元估计器兼容性、元数据路由等全维度。
17.14.1 模块级全局测试数据准备(main)
源码路径:sklearn/semi_supervised/tests/test_self_training.py - 模块级(第22-33行)
# 第 17 章 —— load the iris dataset and randomly permute it
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, random_state=0
)
n_labeled_samples = 50
y_train_missing_labels = y_train.copy()
y_train_missing_labels[n_labeled_samples:] = -1
mapping = {0: "A", 1: "B", 2: "C", -1: "-1"}
y_train_missing_strings = np.vectorize(mapping.get)(y_train_missing_labels).astype(
object
)
y_train_missing_strings[y_train_missing_labels == -1] = -1
模块加载时即执行:加载 iris、切分训练/测试集、前 50 样本保留标签其余置 -1、构建字符串标签映射。所有测试函数复用这些全局变量,避免重复数据准备。
17.14.2 核心分类正确性测试
源码路径:sklearn/semi_supervised/tests/test_self_training.py - test_classification()(第45-75行)
@pytest.mark.parametrize(
"estimator",
[KNeighborsClassifier(), LogisticRegression()],
)
@pytest.mark.parametrize("selection_crit", ["threshold", "k_best"])
def test_classification(estimator, selection_crit):
estimator = clone(estimator)
threshold = 0.75
max_iter = 10
st = SelfTrainingClassifier(
estimator, max_iter=max_iter, threshold=threshold, criterion=selection_crit
)
st.fit(X_train, y_train_missing_labels)
pred = st.predict(X_test)
proba = st.predict_proba(X_test)
st_string = SelfTrainingClassifier(
estimator, max_iter=max_iter, criterion=selection_crit, threshold=threshold
)
st_string.fit(X_train, y_train_missing_strings)
pred_string = st_string.predict(X_test)
proba_string = st_string.predict_proba(X_test)
assert_array_equal(np.vectorize(mapping.get)(pred), pred_string)
assert_array_equal(proba, proba_string)
assert st.termination_condition_ == st_string.termination_condition_
# Check consistency between labeled_iter, n_iter and max_iter
labeled = y_train_missing_labels != -1
assert_array_equal(st.labeled_iter_ == 0, labeled)
assert_array_equal(y_train_missing_labels[labeled], st.transduction_[labeled])
assert np.max(st.labeled_iter_) <= st.n_iter_ <= max_iter
...
双参数化(2 估计器 × 2 策略)验证:
-
数值标签与字符串标签(object dtype)产生一致预测
-
labeled_iter_ == 0精确对应初始标注样本 -
初始标注样本标签在训练中不变
-
max(labeled_iter_) ≤ n_iter_ ≤ max_iter
17.14.3 k_best 策略的精确行为验证
源码路径:sklearn/semi_supervised/tests/test_self_training.py - test_k_best()(第78-98行)
def test_k_best():
st = SelfTrainingClassifier(
KNeighborsClassifier(n_neighbors=1),
criterion="k_best",
k_best=10,
max_iter=None,
)
y_train_only_one_label = np.copy(y_train)
y_train_only_one_label[1:] = -1
n_samples = y_train.shape[0]
n_expected_iter = ceil((n_samples - 1) / 10)
st.fit(X_train, y_train_only_one_label)
assert st.n_iter_ == n_expected_iter
assert np.sum(st.labeled_iter_ == 0) == 1
for i in range(1, n_expected_iter):
assert np.sum(st.labeled_iter_ == i) == 10
assert np.sum(st.labeled_iter_ == n_expected_iter) == (n_samples - 1) % 10
assert st.termination_condition_ == "all_labeled"
仅 1 个初始标注样本,KNN=1,k_best=10,每轮恰好添加 10 个样本,迭代次数符合天花板除法预期,最后一轮可能不足 10 个。
17.14.4 独立复现选择过程确认最优
源码路径:sklearn/semi_supervised/tests/test_self_training.py - test_k_best_selects_best()(第256-283行)
def test_k_best_selects_best():
est = LogisticRegression(random_state=0)
st = SelfTrainingClassifier(est, criterion="k_best", max_iter=1, k_best=10)
has_label = y_train_missing_labels != -1
st.fit(X_train, y_train_missing_labels)
got_label = ~has_label & (st.transduction_ != -1)
est.fit(X_train[has_label], y_train_missing_labels[has_label])
pred = est.predict_proba(X_train[~has_label])
max_proba = np.max(pred, axis=1)
most_confident_est = X_train[~has_label][np.argsort(max_proba)[-10:]]
added_by_st = X_train[np.where(got_label)].tolist()
for row in most_confident_est.tolist():
assert row in added_by_st
独立复现:用相同初始标注集拟合 LogisticRegression,对未标注集预测概率,取前 10 高置信样本,验证自训练选择的样本集合一致。
17.14.5 早停、零迭代与全标注场景
源码路径:sklearn/semi_supervised/tests/test_self_training.py - test_early_stopping()(第195-203行)
def test_early_stopping():
lr = LogisticRegression()
st = SelfTrainingClassifier(lr)
X_train_easy = [[1], [0], [1], [0.5]]
y_train_easy = [1, 0, -1, -1]
st.fit(X_train_easy, y_train_easy)
assert st.n_iter_ == 1
assert st.termination_condition_ == "no_change"

浙公网安备 33010602011771号