第 7 章 机器学习与神经网络的最小训练闭环
7.1 什么操作,才会让下一次预测不同?
Section titled “7.1 什么操作,才会让下一次预测不同?”模型可以算出分数、概率和损失,却什么也没学到。只要参数不变,相同输入仍会经过相同计算。第 6 章已经给出误差信号,现在还缺一步:怎样用它改变保存的数值?
先看一个很小的分类任务。输入是带两个无量纲坐标的点 ,目标只有类别 0 和类别 1。这是为理解训练而构造的数值任务,不是在给真实的人分类,也不是正在运行的差旅制度系统。我们先处理一个点,再检查参数拟合时没有使用的点。之所以要问第二个问题,正是为了区分“学到可用规律”与“符合已有标签”。
例 7.1 — 先观察参数的小变化,再认识梯度。 取 ,目标为 。给定权重 、偏置 。偏置是额外加上的可调常数,与权重不同,它不乘输入坐标。定义类别 1 的分数为 ,按类别 0、类别 1 的顺序使用 logits [0,z]。Softmax 得到
这个二分类形式叫作 sigmoid。在当前参数下,、,目标损失为 。目标里的 1 是类别标签,不是在说输入坐标等于 1。
保持其他内容不变,只把 从 0 增加到 0.01。分数 也增加到 0.01,损失降至约 0.688160。如果把 降到 −0.01,损失则升至约 0.698160。在这个起点附近,增大 对当前样本有帮助。我们已经通过具体数值找到一个方向,但还没有一套能高效处理许多参数的方法。
7.2 用局部变化率,把影响一层层传过去
Section titled “7.2 用局部变化率,把影响一层层传过去”用损失的变化量除以参数的变化量,可以估计 局部变化率(local rate of change)。利用刚才两次扰动,得到
负号表示,权重稍微增加时,损失会下降。数值大小表示,在当前点附近, 每增加一个单位,损失大约减少半个 nat。它并不能预言任意大幅改动的效果。
对于平滑的运算,把改变量不断缩小,这个比值会趋近于 导数(derivative)。当只改变 、保持其他独立输入与参数不变时,记为 。这个符号表示局部敏感程度,不是模型里另一个参数。我们可以沿着前向计算已经用过的操作,把各处的局部变化率传过去,高效算出它。
也可以对中间量做同样的检查。概率分别为 0.499 和 0.501 时,负对数损失约为 0.695149183231 和 0.691149177897;损失差除以 ,约得 −2.000003。分数分别为 −0.01 和 0.01 时,sigmoid 给出的概率约为 0.497500020833 和 0.502499979167,两者之差除以 0.02,约得 0.249998。继续缩小区间,在中心点分别趋近 −2 和 1/4。这两组数先说明损失对概率、概率对分数有多敏感,还没有连到某个权重上。
对于目标为 1 的这个例子,只需要三条关系。在 附近, 的斜率为 。sigmoid 的斜率为 ,这里是 : 小幅增加 0.01 时, 约增加 0.0025。最后,分数是加权和,所以 增加一个很小的量时, 增加的量就是它乘以 。
为什么要把这些变化率相乘?设权重变化为 ,那么 ;它进一步导致 ,再导致 。连起来就是 。这便是 链式法则(chain rule):沿中间值形成的一条路径,把局部变化率相乘。如果某个参数经过多条路径影响损失,还要把各条路径的贡献相加。
表 7.1 — 一次反向计算所需的全部局部数值。
| 关系 | 起点处的局部变化率 | 含义 |
|---|---|---|
| 增大目标概率会降低损失 | ||
| 分数的小变化在这里缩小了 | ||
| 对 的变化率分别为 | 第二个坐标带来两倍的敏感程度 | |
| 从分数传到损失 | ||
| 传到全部参数 | 顺序对应 |
对数和 sigmoid 的导数可以从各自函数推导;这里用局部数值检查展示它们的作用,不要求读者预先学过微积分。如果采用对应目标的损失,无论二分类目标 是 0 还是 1,合并后的分数导数都是 。代入 ,仍得 −1/2;若目标换成 ,结果变为 +1/2,此时应降低类别 1 的分数。
对各个参数的导数排在一起,就是 梯度(gradient)。从损失出发,沿保存的中间值向回计算这些导数,叫作 反向传播(backpropagation),也称反向计算。自动微分系统会记录运算,并应用相应的求导规则。它不会找出正确标签,也不会替我们决定损失应该衡量什么。算出梯度本身,同样不会改变权重。PyTorch:自动微分
7.3 真正更新一次,训练闭环才接上
Section titled “7.3 真正更新一次,训练闭环才接上”局部变化率告诉我们哪个方向会增大损失。要寻求下降,就往反方向走。 梯度下降(gradient descent) 使用一个正的步长 ,称为 学习率(learning rate):
把参数收在一起, 按相同顺序收集对应变化率,减法逐项进行。这是根据局部信息提出的一次更新,并不能保证任意步长都让每个样本的损失下降。
例 7.2 — 完成一步训练。 取 ,使用表 7.1 的梯度。新值分别为 、、。再对同一输入算一遍:
新损失为 ,实际计算使用未舍入的概率。至此, 前向 → 损失 → 反向 → 更新 的整条闭环已经走通。前向算出中间值,损失与目标比较,反向算出变化率,更新才改变参数;下一轮使用这些新参数。
- 模型保存的数值w=[0,0],b=0;x=[1,2]
- 处理步骤z=x·w+b=0 → p=1/2
- 处理步骤目标为 1:L=−ln p=ln 2
读取参数,此时还不改动参数。
- 处理步骤(−2)×(1/4)=−1/2;梯度 w=[−1/2,−1],b=−1/2
- 模型保存的数值减去 0.1×梯度:w=[0.05,0.1],b=0.05
重新前向:z=0.3,p≈0.574443,L≈0.554355。下一步还要从前向开始。
沿图 7.1 的两个方向各看一遍。反向的数值描述“变化将怎样传递”,不是另一组预测。双边框内的参数会保留到下一步。把这些值存起来,就是一个模型参数快照;这与第 4 章完成 MG-0 项目里程碑是不同概念。
优化器(optimizer) 负责执行更新规则,并保存该规则所需的状态。普通的 随机梯度下降(stochastic gradient descent,SGD),用选定训练批次估计的梯度执行上述减法;若批次包含全部训练样本,得到的就是全量数据梯度。对平均损失,各样本的贡献要先按批次中真实样本数分摊,再合并起来。纸笔例题只有一个真实观测,因此分母为 1。
PyTorch 可以把闭环写成下面几行;完整的可运行代码见 code/part-ii/classifier.py 和训练 Notebook:
optimizer.zero_grad()logits = model(x)loss = torch.nn.functional.cross_entropy(logits, targets)loss.backward()optimizer.step()zero_grad() 清除之前累积的梯度,model(x) 完成前向计算。配套分类器的 logits 形状为 [B,2],整数目标形状为 [B],损失对 个真实样本求平均。backward() 填入梯度,step() 按优化器规则更新。如果并非有意累积却省略清除,旧梯度可能进入后续更新;如果只有前向、没有 step(),模型就不会训练。评估时,model.eval() 切换到评估行为,torch.no_grad() 关闭梯度记录,它们都不是参数更新。PyTorch 2.7:SGD
步子太大,可能越过原本能改善的位置。例如,损失 在 处的斜率为 −2。步长 0.1 给出 ,损失从 1 降为 0.64;步长 2 却给出 ,损失升为 9。两次使用的梯度方向都有信息,只是第二次走得离计算这个梯度的位置太远了。
7.4 层数增加,能拟合的规律和噪声都增加
Section titled “7.4 层数增加,能拟合的规律和噪声都增加”若分数为正时选择类别 1, 在平面上给出的分类边界就是一条直线。现在规定:两个坐标同号时标签为 1,异号时为 0。四个角点 [1,1]、[−1,−1]、[1,−1]、[−1,1] 绕原点交替属于两类。一条直线无法把对角的两个类别 1 点,与另两个类别 0 点同时分开。
一个小型 神经网络(neural network) 可以先算出多项加权和,经过非线性函数,再组合结果。实践使用 ReLU,把输入 变为 :负值变成零,正值保持原样。这种先乘矩阵、再加偏置的运算叫作 仿射层(affine layer)。如果没有非线性步骤,连续的仿射层仍可合并成一个仿射映射,摆脱不了直线边界的限制。非线性允许更复杂的边界,却不保证额外的灵活性一定会用在有益之处。
固定网络的形状依次为 [B,2] → [B,32] → [B,32] → [B,2],两个隐藏层后各接一次 ReLU。“隐藏”指中间表示,并不是某个不可查的知识来源。每层的权重和偏置参与学习;层宽、学习率、更新次数则是梯度更新之外选定的设置,称为 超参数(hyperparameters)。三层的参数数目分别是 、 和 :每条输入到输出的连接有一个权重,每个输出另有一个偏置。合计 。这 1,218 个可训练数值,足以灵活地拟合一个很小的数据集。
表 7.2 — 拟合之前,先固定数据职责。 原创 quadrants-v1 数据包含 120 个互不重复的坐标对,由固定随机生成器产生,保留四位小数。干净标签的规则是: 时为类别 1。
| 划分 | 行数 | 标签与用途 |
|---|---|---|
| 训练集 | 24 | 预先指定的四个标签被翻转;全部 24 行用于梯度 |
| 验证集 | 48 | 按干净规则标注;选择最早达到最低验证损失的快照 |
| 测试集 | 48 | 按干净规则标注;选完快照后再计分 |
例如,训练点 [0.9927,0.7696] 的标签是 1,[−0.1784,−0.0846] 也是 1。点 [0.3345,−0.6350] 按规则本应属于 0,但它的训练标签被有意翻成 1。全部坐标、标签及翻转标记都有保存。这个构造专门用来暴露拟合标签噪声的问题,不是在断言训练与部署标签通常都这样不同。
数据划分在训练前固定。即使 ID 不同,相关记录也可能泄漏信息:另一个任务中的近重复问题、同一文档的不同版本,可能需要按组或按时间划分,单纯打乱行顺序解决不了这些问题。本例使用互不重复的合成坐标和明确规则,是为了把实验控制在可核查的规模内。
7.5 看懂训练与验证怎样逐渐分开
Section titled “7.5 看懂训练与验证怎样逐渐分开”例 7.3 — 诊断一次实际发生的过拟合。 CPU 运行固定种子 17、float64、一个计算线程,每步使用全部 24 个训练样本,采用学习率 0.03 的 Adam,共更新 600 次。Adam 会保存梯度的移动统计量,与纸笔算例的普通 SGD 不同。这里把它作为明确固定的更新选择,不展开另一套推导。两种语言使用同一份完整配置和保存结果。
表 7.3 — 同一次实测运行中的参数快照。 损失以每个带标签点的平均 nat 数计量;分类取两个 logits 中较大的那一项。
| 更新次数 | 训练损失 | 训练集分对/总数 | 验证损失 | 验证集分对/总数 |
|---|---|---|---|---|
| 0 | 0.697242 | 7/24 | 0.696076 | 19/48 |
| 5,选中 | 0.527363 | 17/24 | 0.581859 | 37/48 |
| 20 | 0.376093 | 19/24 | 0.736129 | 37/48 |
| 100 | 0.063092 | 23/24 | 6.900090 | 33/48 |
| 300 | 0.050499 | 23/24 | 9.593773 | 31/48 |
| 600 | 0.033060 | 24/24 | 8.150310 | 31/48 |
训练结束时,所有训练标签都被分对,包括那四个故意改错的标签,验证损失却比第 5 步大得多。这种不断拉大的差距,是本实验中 过拟合(overfitting) 的证据:模型越来越符合训练观测,却损害了在指定验证群体上的预测。验证损失不必每一步都上升;判断依据是整体分离趋势和预先指定的评价标准。
实线:训练集;虚线:验证集。两条坐标轴均为线性刻度。 靠近左边缘的竖向点线标出选中的第 5 步。
图 7.2 使用包含初始化在内的全部 601 个实测点,并非手画的示意曲线。坐标轴为线性刻度:训练曲线逐渐贴近底部,验证曲线向上爬升。第 5 步的验证最低点靠近左边缘,表 7.3 单独列出它,不需要读者从像素估读。
为什么大多数标签仍能分对,验证损失却能升得这么高?准确率只看哪个类别胜出,对数损失还会惩罚那些很有把握的错误预测。对于一个分错类的样本,把正确目标的概率从 0.1 降到 0.0001,它仍然只算“错一次”,损失却会从约 2.303 升到 9.210。损失与准确率都有用,但概括的是不同表现。
预先规定的选择规则保留最低验证损失快照,也就是第 5 步。代码随后才对测试集计分: 48 个点中分对 38 个,平均损失为 0.471905。选择步骤没有使用测试集曲线。反复执行同一份固定代码,是复现原实验,不是增加 48 条新观测。若因为这次测试分数不满意而另选网络结构,就已经把测试集用于开发;以后想诚实地做最终评估,需要新的留出证据。
这个小结果不能估计真实语言模型的质量。验证选择本身也只基于 48 个点,却检查了许多快照,因此可能偏乐观。从失败中可以提出一些后续方案:检查标签、减少不必要的容量、补充合适的训练数据,或选择更早的快照。每种方案都需要重新声明实验,不能保证调一个开关就能消除过拟合。泛化关注的是模型在拟合过的具体观测之外怎样表现。《动手学深度学习》英文版,第 3.6 节
7.6 三种“检查”,职责不能混在一起
Section titled “7.6 三种“检查”,职责不能混在一起”验证集(validation set) 提供不参与参数拟合的样本,帮助作开发选择。 数据格式校验(schema validation) 检查数据是否符合契约,例如订单记录是否有字符串 order_id,状态值是否在允许范围内。后面还会遇到 核验器(verifier),它可能根据证据与规则检查某个具体回答或操作。这些检查不能相互替代。分类器可以通过格式校验,却泛化很差;模型表现好,也不会因此获得取消订单的权限。
实践顺序仍为第 5 章核心正文 → Python Primer → 第 4 章 N-gram Notebook → NumPy 形状练习 → 03-training.ipynb。训练 Notebook 会检查手算梯度,复现固定训练,并与保存记录比较。classifier-data.json 固定数据划分,classifier-run.json 保存实际曲线、软件信息、数据散列与耗时,classifier-model.json 保存选中的参数。本地初次记录的运行约耗时 3.53 秒,包含其中的检查。这是指定 CPU 环境下的一次观测,不是速度对比,也不是对其他机器耗时的承诺。
7.7 本章小结
Section titled “7.7 本章小结”训练需要更新参数,只有预测和损失还不够。局部变化率解释微小改动怎样穿过中间值,链式法则把它们组合起来,反向传播算出参数梯度,优化器再按指定规则更新。非线性层扩大了能拟合的范围,也包括噪声。训练集、验证集和测试集回答不同问题,训练集表现好,不能单独证明泛化能力。
这个闭环已经能处理数值点。把它用于语言之前,还要补上一项契约:送进模型的文本究竟是什么?第 8 章将检查,为什么屏幕上一段看似简单的字符串,底层可能是不同的数据。
7.8 习题
Section titled “7.8 习题”- 保持 ,把目标改成 0,从零权重、零偏置重新算梯度。步长为 0.1 时,新权重、偏置和类别 1 概率各是什么?哪个类别的概率应上升?
- 目标为 1、输入为 ,仍从零权重和零偏置开始。求参数梯度并更新一次,步长取 0.1。为什么其中一个权重没有被这个样本改变?
- 一段程序做了 100 次前向和反向,却从未调用
step();另一段调用了step(),却从未有意识地清除累积梯度。分别诊断问题。 - 第 5 步和第 20 步验证准确率相同。按既定规则应选哪个快照?哪项证据支持这个选择?为什么不看训练准确率来决定?
- 一个团队按测试分数从十种网络宽度中选出赢家,再把赢家的测试分数称为“未经使用的最终估计”。应怎样修正实验职责?
- 返回的订单对象通过了 JSON schema 校验。为什么这既不能证明分类器会泛化,也不能证明可以取消该订单?
7.9 参考解答
Section titled “7.9 参考解答”- 分数导数为 。乘以输入坐标,得到权重梯度
[1/2,1],偏置梯度为 1/2。新值是w=[−0.05,−0.1]、b=−0.05;分数为 −0.3,类别 1 概率约为 0.425557,类别 0 概率相应升至约 0.574443,方向符合目标要求。 - 分数导数为 −1/2,权重梯度是
[−1,0],偏置梯度为 −1/2。更新后w=[0.1,0]、b=0.05,分数为 0.25。当前样本的 ,改变 影响不了它的分数;另一个样本可能为这个权重提供非零梯度。 - 第一段可以算出梯度,但从未更新参数。第二段可能把旧梯度带入后续更新,除非原本就有明确的累积与缩放安排。预期闭环应先清除旧梯度,计算当前损失,反向传播,再更新。
- 应选第 5 步:尽管都是 48 个点分对 37 个,验证损失 0.581859 仍低于 0.736129。损失还能区分各目标得到的概率。训练准确率衡量的是对参与梯度的数据的拟合,其中还有四个故意翻转的标签,因此不是这里的选择标准。
- 这些测试分数已经成为开发证据。应使用验证集选择宽度,并保留另一份未使用的测试集,对最终选定流程作评估;不能从记录中抹掉此前尝试。数据集的职责取决于它被怎样使用,不取决于文件名叫什么。
- schema 检查结构和允许的字段值,既不度量新样本上的预测,也不检查用户是否授权操作。格式正确的
order_id仍可能指向错误订单,取消操作需要独立的证据与审批规则。
7.10 参考文献
Section titled “7.10 参考文献”- PyTorch:自动微分教程。解释计算图、反向传播和梯度累积;本章的一步更新数值另由手算推导。
- PyTorch 2.7:SGD。规定纸笔算例所用的更新机制;非线性实践另在固定配置中明确使用 Adam。
- PyTorch 2.7:Adam。说明实测非线性分类器所用的梯度移动统计量和默认设置。实测曲线不能归因于纸笔推导的 SGD 规则。
- PyTorch 2.7:CrossEntropyLoss。说明分类器所用 logits 与整数目标的形状。
- Zhang 等,《动手学深度学习》英文版:泛化。解释拟合有限观测为何不同于找到适用于新数据的规律。图中的合成实验和全部实测数值均来自本书自己的运行。