跳转到内容

第 7 章 机器学习与神经网络的最小训练闭环

7.1 什么操作,才会让下一次预测不同?

Section titled “7.1 什么操作,才会让下一次预测不同?”

模型可以算出分数、概率和损失,却什么也没学到。只要参数不变,相同输入仍会经过相同计算。第 6 章已经给出误差信号,现在还缺一步:怎样用它改变保存的数值?

先看一个很小的分类任务。输入是带两个无量纲坐标的点 x=[x0,x1]x=[x_0,x_1],目标只有类别 0 和类别 1。这是为理解训练而构造的数值任务,不是在给真实的人分类,也不是正在运行的差旅制度系统。我们先处理一个点,再检查参数拟合时没有使用的点。之所以要问第二个问题,正是为了区分“学到可用规律”与“符合已有标签”。

例 7.1 — 先观察参数的小变化,再认识梯度。 取 x=[1,2]x=[1,2],目标为 y=1y=1。给定权重 w=[0,0]w=[0,0]、偏置 b=0b=0。偏置是额外加上的可调常数,与权重不同,它不乘输入坐标。定义类别 1 的分数为 z=x0w0+x1w1+bz=x_0w_0+x_1w_1+b,按类别 0、类别 1 的顺序使用 logits [0,z]。Softmax 得到

p=P(y=1∣x)=ez1+ez=11+e−z.p=P(y=1\mid x)=\frac{e^z}{1+e^z}=\frac1{1+e^{-z}}.

这个二分类形式叫作 sigmoid。在当前参数下,z=0z=0、p=1/2p=1/2,目标损失为 ℓ=−ln⁡p=ln⁡2≈0.693147\ell=-\ln p=\ln2\approx0.693147。目标里的 1 是类别标签,不是在说输入坐标等于 1。

保持其他内容不变,只把 w0w_0 从 0 增加到 0.01。分数 zz 也增加到 0.01,损失降至约 0.688160。如果把 w0w_0 降到 −0.01,损失则升至约 0.698160。在这个起点附近,增大 w0w_0 对当前样本有帮助。我们已经通过具体数值找到一个方向,但还没有一套能高效处理许多参数的方法。

7.2 用局部变化率,把影响一层层传过去

Section titled “7.2 用局部变化率,把影响一层层传过去”

用损失的变化量除以参数的变化量,可以估计 局部变化率(local rate of change)。利用刚才两次扰动,得到

0.6881596805−0.69815968050.01−(−0.01)≈−0.5.\frac{0.6881596805-0.6981596805}{0.01-(-0.01)}\approx-0.5.

负号表示,权重稍微增加时,损失会下降。数值大小表示,在当前点附近,w0w_0 每增加一个单位,损失大约减少半个 nat。它并不能预言任意大幅改动的效果。

对于平滑的运算,把改变量不断缩小,这个比值会趋近于 导数(derivative)。当只改变 w0w_0、保持其他独立输入与参数不变时,记为 ∂ℓ/∂w0\partial\ell/\partial w_0。这个符号表示局部敏感程度,不是模型里另一个参数。我们可以沿着前向计算已经用过的操作,把各处的局部变化率传过去,高效算出它。

也可以对中间量做同样的检查。概率分别为 0.499 和 0.501 时,负对数损失约为 0.695149183231 和 0.691149177897;损失差除以 0.501−0.499=0.0020.501-0.499=0.002,约得 −2.000003。分数分别为 −0.01 和 0.01 时,sigmoid 给出的概率约为 0.497500020833 和 0.502499979167,两者之差除以 0.02,约得 0.249998。继续缩小区间,在中心点分别趋近 −2 和 1/4。这两组数先说明损失对概率、概率对分数有多敏感,还没有连到某个权重上。

对于目标为 1 的这个例子,只需要三条关系。在 p=1/2p=1/2 附近,−ln⁡p-\ln p 的斜率为 −1/p=−2-1/p=-2。sigmoid 的斜率为 p(1−p)p(1-p),这里是 1/41/4:zz 小幅增加 0.01 时,pp 约增加 0.0025。最后,分数是加权和,所以 w0w_0 增加一个很小的量时,zz 增加的量就是它乘以 x0=1x_0=1。

为什么要把这些变化率相乘?设权重变化为 Δw0\Delta w_0,那么 Δz=1Δw0\Delta z=1\Delta w_0;它进一步导致 Δp≈(1/4)Δz\Delta p\approx(1/4)\Delta z,再导致 Δℓ≈(−2)Δp\Delta\ell\approx(-2)\Delta p。连起来就是 Δℓ≈(−1/2)Δw0\Delta\ell\approx(-1/2)\Delta w_0。这便是 链式法则(chain rule):沿中间值形成的一条路径,把局部变化率相乘。如果某个参数经过多条路径影响损失,还要把各条路径的贡献相加。

表 7.1 — 一次反向计算所需的全部局部数值。

关系起点处的局部变化率含义
ℓ=−ln⁡p\ell=-\ln p∂ℓ/∂p=−2\partial\ell/\partial p=-2增大目标概率会降低损失
p=1/(1+e−z)p=1/(1+e^{-z})∂p/∂z=1/4\partial p/\partial z=1/4分数的小变化在这里缩小了
z=x0w0+x1w1+bz=x_0w_0+x_1w_1+b对 w0,w1,bw_0,w_1,b 的变化率分别为 1,2,11,2,1第二个坐标带来两倍的敏感程度
从分数传到损失(−2)(1/4)=−1/2(-2)(1/4)=-1/2∂ℓ/∂z=−1/2\partial\ell/\partial z=-1/2
传到全部参数[−1/2,−1,−1/2][-1/2,-1,-1/2]顺序对应 [w0,w1,b][w_0,w_1,b]

对数和 sigmoid 的导数可以从各自函数推导;这里用局部数值检查展示它们的作用,不要求读者预先学过微积分。如果采用对应目标的损失,无论二分类目标 yy 是 0 还是 1,合并后的分数导数都是 p−yp-y。代入 p=1/2,y=1p=1/2,y=1,仍得 −1/2;若目标换成 y=0y=0,结果变为 +1/2,此时应降低类别 1 的分数。

对各个参数的导数排在一起,就是 梯度(gradient)。从损失出发,沿保存的中间值向回计算这些导数,叫作 反向传播(backpropagation),也称反向计算。自动微分系统会记录运算,并应用相应的求导规则。它不会找出正确标签,也不会替我们决定损失应该衡量什么。算出梯度本身,同样不会改变权重。PyTorch:自动微分

7.3 真正更新一次,训练闭环才接上

Section titled “7.3 真正更新一次,训练闭环才接上”

局部变化率告诉我们哪个方向会增大损失。要寻求下降,就往反方向走。 梯度下降(gradient descent) 使用一个正的步长 η\eta,称为 学习率(learning rate):

θnew=θold−η ∇θℓ.\theta_{\rm new}=\theta_{\rm old}-\eta\,\nabla_\theta\ell.

θ\theta 把参数收在一起,∇θℓ\nabla_\theta\ell 按相同顺序收集对应变化率,减法逐项进行。这是根据局部信息提出的一次更新,并不能保证任意步长都让每个样本的损失下降。

例 7.2 — 完成一步训练。 取 η=0.1\eta=0.1,使用表 7.1 的梯度。新值分别为 w0=0−0.1(−0.5)=0.05w_0=0-0.1(-0.5)=0.05、w1=0−0.1(−1)=0.1w_1=0-0.1(-1)=0.1、b=0−0.1(−0.5)=0.05b=0-0.1(-0.5)=0.05。再对同一输入算一遍:

znew=1(0.05)+2(0.1)+0.05=0.3,pnew=11+e−0.3≈0.574443.z_{\rm new}=1(0.05)+2(0.1)+0.05=0.3, \qquad p_{\rm new}=\frac1{1+e^{-0.3}}\approx0.574443.

新损失为 −ln⁡0.574443≈0.554355-\ln0.574443\approx0.554355,实际计算使用未舍入的概率。至此, 前向 → 损失 → 反向 → 更新 的整条闭环已经走通。前向算出中间值,损失与目标比较,反向算出变化率,更新才改变参数;下一轮使用这些新参数。

前向 → 损失
  1. 模型保存的数值w=[0,0],b=0;x=[1,2]
  2. 处理步骤z=x·w+b=0 → p=1/2
  3. 处理步骤目标为 1:L=−ln p=ln 2

读取参数,此时还不改动参数。

反向 → 更新
  1. 处理步骤(−2)×(1/4)=−1/2;梯度 w=[−1/2,−1],b=−1/2
  2. 模型保存的数值减去 0.1×梯度:w=[0.05,0.1],b=0.05

重新前向:z=0.3,p≈0.574443,L≈0.554355。下一步还要从前向开始。

图 7.1 — 前向计算数值,反向计算变化率,更新才真正改变参数。 本书原创手算计算图,CC BY-SA 4.0。

沿图 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],损失对 BB 个真实样本求平均。backward() 填入梯度,step() 按优化器规则更新。如果并非有意累积却省略清除,旧梯度可能进入后续更新;如果只有前向、没有 step(),模型就不会训练。评估时,model.eval() 切换到评估行为,torch.no_grad() 关闭梯度记录,它们都不是参数更新。PyTorch 2.7:SGD

步子太大,可能越过原本能改善的位置。例如,损失 L=(w−1)2L=(w-1)^2 在 w=0w=0 处的斜率为 −2。步长 0.1 给出 w=0.2w=0.2,损失从 1 降为 0.64;步长 2 却给出 w=4w=4,损失升为 9。两次使用的梯度方向都有信息,只是第二次走得离计算这个梯度的位置太远了。

7.4 层数增加,能拟合的规律和噪声都增加

Section titled “7.4 层数增加,能拟合的规律和噪声都增加”

若分数为正时选择类别 1,x⋅w+bx\cdot w+b 在平面上给出的分类边界就是一条直线。现在规定:两个坐标同号时标签为 1,异号时为 0。四个角点 [1,1]、[−1,−1]、[1,−1]、[−1,1] 绕原点交替属于两类。一条直线无法把对角的两个类别 1 点,与另两个类别 0 点同时分开。

一个小型 神经网络(neural network) 可以先算出多项加权和,经过非线性函数,再组合结果。实践使用 ReLU,把输入 aa 变为 max⁡(0,a)\max(0,a):负值变成零,正值保持原样。这种先乘矩阵、再加偏置的运算叫作 仿射层(affine layer)。如果没有非线性步骤,连续的仿射层仍可合并成一个仿射映射,摆脱不了直线边界的限制。非线性允许更复杂的边界,却不保证额外的灵活性一定会用在有益之处。

固定网络的形状依次为 [B,2] → [B,32] → [B,32] → [B,2],两个隐藏层后各接一次 ReLU。“隐藏”指中间表示,并不是某个不可查的知识来源。每层的权重和偏置参与学习;层宽、学习率、更新次数则是梯度更新之外选定的设置,称为 超参数(hyperparameters)。三层的参数数目分别是 2×32+32=962\times32+32=96、32×32+32=105632\times32+32=1056 和 32×2+2=6632\times2+2=66:每条输入到输出的连接有一个权重,每个输出另有一个偏置。合计 96+1056+66=121896+1056+66=1218。这 1,218 个可训练数值,足以灵活地拟合一个很小的数据集。

表 7.2 — 拟合之前,先固定数据职责。 原创 quadrants-v1 数据包含 120 个互不重复的坐标对,由固定随机生成器产生,保留四位小数。干净标签的规则是:x0x1>0x_0x_1>0 时为类别 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 中较大的那一项。

更新次数训练损失训练集分对/总数验证损失验证集分对/总数
00.6972427/240.69607619/48
5,选中0.52736317/240.58185937/48
200.37609319/240.73612937/48
1000.06309223/246.90009033/48
3000.05049923/249.59377331/48
6000.03306024/248.15031031/48

训练结束时,所有训练标签都被分对,包括那四个故意改错的标签,验证损失却比第 5 步大得多。这种不断拉大的差距,是本实验中 过拟合(overfitting) 的证据:模型越来越符合训练观测,却损害了在指定验证群体上的预测。验证损失不必每一步都上升;判断依据是整体分离趋势和预先指定的评价标准。

实线:训练集;虚线:验证集。两条坐标轴均为线性刻度。 靠近左边缘的竖向点线标出选中的第 5 步。

图 7.2 — 持续拟合训练标签,后来反而降低了验证数据的似然。 本书原创 CPU 实验与图示,CC BY-SA 4.0;全部实测点见 classifier-run.json。

图 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 环境下的一次观测,不是速度对比,也不是对其他机器耗时的承诺。

训练需要更新参数,只有预测和损失还不够。局部变化率解释微小改动怎样穿过中间值,链式法则把它们组合起来,反向传播算出参数梯度,优化器再按指定规则更新。非线性层扩大了能拟合的范围,也包括噪声。训练集、验证集和测试集回答不同问题,训练集表现好,不能单独证明泛化能力。

这个闭环已经能处理数值点。把它用于语言之前,还要补上一项契约:送进模型的文本究竟是什么?第 8 章将检查,为什么屏幕上一段看似简单的字符串,底层可能是不同的数据。

  1. 保持 x=[1,2]x=[1,2],把目标改成 0,从零权重、零偏置重新算梯度。步长为 0.1 时,新权重、偏置和类别 1 概率各是什么?哪个类别的概率应上升?
  2. 目标为 1、输入为 x=[2,0]x=[2,0],仍从零权重和零偏置开始。求参数梯度并更新一次,步长取 0.1。为什么其中一个权重没有被这个样本改变?
  3. 一段程序做了 100 次前向和反向,却从未调用 step();另一段调用了 step(),却从未有意识地清除累积梯度。分别诊断问题。
  4. 第 5 步和第 20 步验证准确率相同。按既定规则应选哪个快照?哪项证据支持这个选择?为什么不看训练准确率来决定?
  5. 一个团队按测试分数从十种网络宽度中选出赢家,再把赢家的测试分数称为“未经使用的最终估计”。应怎样修正实验职责?
  6. 返回的订单对象通过了 JSON schema 校验。为什么这既不能证明分类器会泛化,也不能证明可以取消该订单?
  1. 分数导数为 p−y=1/2p-y=1/2。乘以输入坐标,得到权重梯度 [1/2,1],偏置梯度为 1/2。新值是 w=[−0.05,−0.1]、b=−0.05;分数为 −0.3,类别 1 概率约为 0.425557,类别 0 概率相应升至约 0.574443,方向符合目标要求。
  2. 分数导数为 −1/2,权重梯度是 [−1,0],偏置梯度为 −1/2。更新后 w=[0.1,0]、b=0.05,分数为 0.25。当前样本的 x1=0x_1=0,改变 w1w_1 影响不了它的分数;另一个样本可能为这个权重提供非零梯度。
  3. 第一段可以算出梯度,但从未更新参数。第二段可能把旧梯度带入后续更新,除非原本就有明确的累积与缩放安排。预期闭环应先清除旧梯度,计算当前损失,反向传播,再更新。
  4. 应选第 5 步:尽管都是 48 个点分对 37 个,验证损失 0.581859 仍低于 0.736129。损失还能区分各目标得到的概率。训练准确率衡量的是对参与梯度的数据的拟合,其中还有四个故意翻转的标签,因此不是这里的选择标准。
  5. 这些测试分数已经成为开发证据。应使用验证集选择宽度,并保留另一份未使用的测试集,对最终选定流程作评估;不能从记录中抹掉此前尝试。数据集的职责取决于它被怎样使用,不取决于文件名叫什么。
  6. schema 检查结构和允许的字段值,既不度量新样本上的预测,也不检查用户是否授权操作。格式正确的 order_id 仍可能指向错误订单,取消操作需要独立的证据与审批规则。