说实话,反向传播这四个字,我第一次接触的时候觉得特别唬人,感觉像是某种只有数学天才才能驾驭的黑魔法。后来自己动手搭了一个两层的全连接网络,把梯度从损失函数一路推回每一个参数,才恍然大悟:它本质上就是微积分里那套链式法则,只是被神经网络这个外壳包装得吓人了一点而已。
这篇文章想聊的就是两件事:反向传播怎么把梯度一分不差地传给每个参数,参数更新又是怎么拿着梯度把损失一步步压下去。无论你是刚入门深度学习的学生,还是已经开始调模型但被梯度搞晕的工程师,这条主线都可以帮你把神经网络最核心的运行机制彻底理清。全文不会有太高深的数学,只要你会一元函数求导,就能跟得上。
1. 反向传播和参数更新到底是一对什么组合
1.1 用一个比喻理解“梯度”这件事
先别急着看公式,我们打一个比方。假设你在打篮球,投篮命中率不高,你想改进。影响命中率的因素很多:手腕发力、起跳高度、出手角度、投篮位置。你怎么知道该改哪一项?当然是一次只改一个变量,反复试错,但这样效率太低。
更聪明的做法是:找一台高速摄像机,把每一次投篮拍下来,分析出手角度变化对命中率的“影响程度”。这个影响程度,在数学上就是“偏导数”。如果出手角度是影响命中率的最大因素,你就重点调它;如果起跳高度已经差不多了,你就少动它。
反向传播干的就是这件事:给定一个损失函数,它能计算出损失对网络中每一个参数的“影响程度”,也就是梯度。参数更新干的是另一件事:知道影响程度之后,决定每个参数该往哪个方向调、调多少。一个是算账,一个是行动,两者缺一不可。
1.2 为什么非得“反向”才算得动
你可能想问,既然是求导数,为什么不能用最原始的办法,对每个参数单独求导?
理论上可以。把某个参数 w 改变一个极小量 ε,重新跑一次前向传播,看看损失变了多少,就能算出偏导数的近似值。但问题是:一个一千万参数的模型,做一次这样的操作就要跑一千万次前向传播,训练一次要迭代几十万步,这计算量放到任何机器上都撑不住。
反向传播的聪明之处在于“复用中间结果”。前向传播时,网络会生成一系列中间值:线性层的输出、激活函数的输出。反向传播从损失函数出发,沿着前向传播的路径倒着走一遍,每经过一个中间结果,就把已经算好的上游梯度“接力”给下游。这样整个网络只需要一次正向计算、一次反向计算,就能拿到所有参数的梯度。整体复杂度大概是 O(前向计算量),而不是 O(参数个数 × 前向计算量)。
这个思路,本质上就是动态规划。你计算每一层的梯度时,不需要重新算一遍前序的所有东西,因为那些量的梯度已经被上一步算过了,你只是不断做局部求导并相乘而已。
1.3 参数更新:拿着梯度该走多远
梯度只是一个方向,告诉你往哪个方向调损失会下降。真正决定下降快慢和稳定性的,是“走多远”,也就是学习率。
举个例子,你在山上想下山,梯度告诉你北边是最陡的下坡方向。但这一脚迈多大?迈大了可能直接冲到对面山坡,迈小了要走几千步才到山脚。参数更新本质上就是回答这个问题:沿着梯度方向迈出多大的一步。
通常的更新公式极其简单:
θ_new = θ_old − 学习率 × 梯度
如果你只用单个样本的梯度来更新,因为样本噪声太大,更新方向会左右乱跳。所以实践中几乎都是用 mini-batch 的梯度来更新:取一小批样本,各自算出梯度,取平均再更新。平均之后的方向更接近整体下降方向,噪声小得多。
反向传播负责算梯度,更新规则负责决定怎么动。这两个环节一起构成了神经网络训练的“心脏”。理解了这一点,后面的所有细节都是在围绕它们做优化。
2. 拆开反向传播:每一层梯度的流向
2.1 线性层的三个梯度公式
神经网络里最基础的层是线性层,就是 y = Wx + b。输入 x 是一个向量,W 是权重矩阵,b 是偏置向量。前向传播很简单,矩阵乘法加上加法。反向传播的关键问题是:已知损失对 y 的梯度 dL/dy,怎么得到损失对 W、x、b 的梯度?
答案来自链式法则。先看最简单的偏置 b。y = Wx + b,所以 y 对 b 的导数是一个单位矩阵,损失对 b 的梯度就等于损失对 y 的梯度本身。如果训练时用了 batch 大小为 N 的输入,那么多个样本的梯度需要求和,也就是把 dL/dy 按 batch 维度和特征维度做规约。
再看权重 W。W 的维度是 (输出维度, 输入维度),它和 x 之间是线性关系。对 W 中某一个元素 W[i, j] 来说,它只影响 y[i] 而不会影响 y 的其他维度,所以 dL/dW[i, j] = dL/dy[i] × x[j]。写成矩阵形式,梯度就是 dL/dy 和 x 的外积:一个“上游梯度向量”转置后乘以“输入向量”。
最后看对输入 x 的梯度。这一步看起来是“反向”的,因为前向时是 W 作用在 x 上,反向时是 W 的转置作用在上游梯度上:dL/dx = W^T × dL/dy。为什么要转置?因为前向传播时,x 的每一个分量会同时影响 y 的所有输出,所以反向传播时,x 的梯度需要把 y 相同维度上的梯度按权重加权求和。转置运算做的正是这件事。
这三个公式,是所有全连接网络反向传播的基石。理解了它们,CNN 里的全连接层、RNN 里的输出层,本质都是一回事。
2.2 激活函数的局部梯度
激活函数是神经网络里“非线性”的来源。如果没有激活函数,多层线性变换叠加起来还是线性变换,网络再深也白搭。激活函数的前向计算很简单,反向传播同样只多一步:把上游梯度乘上激活函数在当前位置的导数。
以最常用的 ReLU 为例,它前向是 max(0, x)。当 x 大于 0 时,导数是 1,梯度直接透过;当 x 小于等于 0 时,导数是 0,梯度被截断,不再往后传。这个“截断”的副作用是:如果一个神经元的输入长期小于 0,它的梯度永远为 0,参数永远得不到更新,这就是大家常说的“神经元死亡”。
Sigmoid 的导数则有一个非常漂亮的公式:σ'(x) = σ(x) × (1 − σ(x))。意思是,前向传播时算出的激活值 σ(x),在反向传播时可以直接用来计算局部梯度,不需要重新算一次指数函数。但它的缺陷也很明显:当激活值接近 0 或 1 时,导数趋近于 0,梯度会在多层传播中被乘成近似 0 的数,导致梯度消失。这也是为什么现在深层网络里 ReLU 及其变体更流行的原因之一。
写代码时特别容易犯一个错误:把激活函数的导数和损失函数对输出层的梯度搞混。损失函数对输出 y 的梯度是 dL/dy,它是一个由损失定义决定的量;激活函数的局部梯度是 dy/dz,它只和激活函数本身有关。反向传播时,每经过一个运算节点,就要把上游梯度乘以该节点的局部雅可比,两个量虽然最后会相乘,但各自的来源和含义完全不同。
2.3 损失函数层的梯度
损失函数是反向传播的起点,所有梯度的“源头”。不同损失函数的梯度形式差异非常大,这里只说两个最常见的。
均方误差(MSE)通常配合回归问题使用,表达式是 L = 0.5 × (y_pred − y_true)²。如果前面没有接 sigmoid 这类激活函数,那么它对输出的梯度就是 y_pred − y_true,形式非常简洁,误差越大,梯度越强。严格说一下,如果 y_pred 前面接的是 sigmoid,那么 MSE 对 sigmoid 输入的梯度就不是这个简洁形式,而是会多乘一个 sigmoid 导数,这会造成“输出接近 0 或 1 时梯度很小”的问题,训练会变得非常缓慢。
交叉熵则更像分类问题的标准配置。对于二分类,用 sigmoid 加二元交叉熵,组合起来之后,损失对输出的梯度也可以简化成 y_pred − y_true 这种简洁形式,这才是“配对了”的损失和激活函数。如果是多分类,softmax 加交叉熵也有同样的简化效果,实现时把这两者合并成一个“softmax+loss”的节点来计算梯度,不仅能提高计算效率,还能避免中间项指数运算导致的数值溢出。
所以我的习惯是:除非你有特别的理由,否则不要把损失函数和激活函数当作两个独立节点去手工推梯度,而是把常用的组合当成一个整体来理解。很多深度学习框架内部也是这么做的。
2.4 深层网络中的梯度流
到多层网络里,反向传播就是把这些局部规则串起来。假设网络是输入→线性层→激活→线性层→激活→损失,梯度从损失出发,逐层倒推:
dL/dy_pred → dL/dz_output → dL/dW2(更新权重),同时 dL/da1 → dL/dz_hidden → dL/dW1。
每一层的梯度计算都依赖上一层已经传下来的 dL/dz。只要前一层算完了,后一层的梯度就能接着往下传,不需要回看更原始的输入。
这里有个很关键的感受:梯度在网络里是一路“连乘”过去的。每经过一个权重矩阵和激活函数,梯度就乘以一次某个矩阵的范数或激活导数的标量。如果大部分因子都大于 1,梯度会指数增长,变成梯度爆炸;如果大部分因子都小于 1,梯度会指数衰减,变成梯度消失。所以你看到的所有初始化方法、激活函数选型、BN(批归一化)、残差连接,本质上都是在试图控制这条梯度连乘链的“量级”不要失控。
3. 参数更新的几种主流策略
3.1 朴素 SGD:直接沿着梯度走
最朴素的更新方式就是 SGD:每个参数都按同一个学习率,沿着梯度的反方向走一步。公式前面已经写过:θ = θ − η × grad。训练时把样本分成小 batch,每个 batch 算一次梯度,更新一次。
用着用着你会发现它的几个问题。第一个是学习率的选择很讲究:设小了训练半天不收敛,设大了损失会在最优值附近来回震荡,甚至直接发散。第二个是它对梯度的各个维度一视同仁,但实际场景里参数的量级和使用频率差异很大,有些参数应该走大步,有些应该走小步,统一用一个学习率不够灵活。
不过朴素 SGD 有一个好处是简单、可解释,而且在一些精心调整过的任务上,加上动量和学习率衰减,效果往往能逼近甚至超过更花哨的优化器。它仍然是理解后面所有优化器的基准。
3.2 动量:带着惯性更新
动量(Momentum)的思想特别直觉:既然单次梯度噪声大,那我就记住历史梯度的“平均方向”,让每次更新既参考当前梯度,又参考过去一段时间的方向。更新公式一般写成:
v = μ × v − η × grad
θ = θ + v
这里的 μ 通常取 0.9 左右,意思是上一次的速度保留 90%,再叠加当前这次梯度的贡献。你可以把它想象成小球从山上滚下来:它不会每一步都被局部小坑带偏,因为惯性会让它冲过那些狭窄的局部极小值,最终更可能落到更低的坑里。
实际使用中,动量能明显加快收敛速度,尤其是在损失 landscape 比较狭长的碗状区域。方向重复的成分会被动量积累放大,方向来回震荡的成分会被逐步抵消。这也是为什么它比朴素 SGD 在深层网络里有用得多。
3.3 自适应方法:每个参数一个学习率
模型里不同参数的梯度量级可能差好几个数量级。有的参数训练样本中出现频率高,梯度大,学习率应该小一点;有的参数出现频率低,梯度小,学习率应该大一点。于是就有了自适应学习率方法。
AdaGrad 会为每个参数累计历史梯度的平方和,然后用当前梯度除以这个累计值的平方根作为实际更新步长。梯度大的参数被自动压低步长,梯度小的参数被自动放大步长。但它的缺点是累计值只增不减,训练时间长了学习率会变得无穷小,直接停摆。
RMSProp 针对这个问题做了改进,用指数滑动平均代替了简单累加,只记住近期的梯度平方信息。这样学习率不会一直衰减到 0,能保持合理范围。本质上它是在估算“每个维度近期梯度的典型大小”,再用这个典型大小去归一化当前梯度。
3.4 Adam:动量和自适应学习的合体
Adam 在实践中几乎成了默认选项,因为它同时融合了两代优化器的优点:它既用一阶矩估算梯度方向的历史趋势,相当于动量;又用二阶矩估算梯度幅度的历史趋势,相当于自适应学习率。为了防止训练前期历史信息太少导致的偏差,它还会做一个偏差修正,让前几步的估计更准确。
常用的默认超参数是:一阶矩衰减系数 β1 = 0.9,二阶矩衰减系数 β2 = 0.999,防除零的小项 ε = 1e-8。这几个值在绝大多数问题上都能直接工作,不需要你花太多时间去调。不过 Adam 也有被人吐槽的地方:它对学习率依然敏感,而且由于自适应机制的存在,某些情况下权重更新步长会出现不自然的放大效应,导致泛化效果不如精心调过的 SGD + 动量。所以实践中大家会说“Adam 帮你快速找到一个好区域,SGD 帮你精调”。
3.5 优化器到底怎么选
选优化器,我认为分场景看更现实。
表格列出常见对比:
| 优化器 | 核心思路 | 优点 | 典型问题 | 适用场景 |
|---|---|---|---|---|
| SGD | 统一学习率直走 | 简单、可解释、泛化好 | 学习率难调、收敛慢 | 已经调好学习率的小中规模任务 |
| SGD+Momentum | 累积历史梯度 | 减少震荡、加速收敛 | 仍需手动调学习率 | 常规监督学习,可精调 |
| RMSProp | 自适应缩放每参数 | 对尺度差异不敏感 | 二阶矩估计需要衰减参数 | 非平稳目标、RNN 类任务 |
| Adam | 动量+自适应 | 默认好用、收敛快 | 可能泛化不如精调 SGD | 探索期、大规模模型、图像分类等 |
我的个人倾向是:拿到一个新任务,先无脑用 Adam 开跑,把网络结构、数据预处理、损失函数这些更本质的问题调对。等到模型结构基本稳定、要刷最后几个点的精度时,再切到 SGD + 动量,配合学习率衰减,用网格搜索挑一个合适的学习率。这个流程我在几个项目里试过,通常比一开始就死磕 SGD 学习率要快得多。
4. 手算一遍:从零做一次反向传播和参数更新
4.1 网络设定与数值初始化
看再多的公式,都不如手算一遍来得透彻。我设计了一个极小但完整的网络:输入 x 只有 1 个维度,中间层只有 1 个神经元,输出层也只有 1 个神经元。结构是这样的:
输入 x → 线性层(w1, b1)→ Sigmoid 激活 → 线性层(w2, b2)→ 输出 y_pred
为了手算方便,我不给最终输出接激活函数。损失函数用 MSE,并且写成 L = 0.5 × (y_pred − y_true)²,这个 0.5 是为了求导后消掉平方指数,让梯度表达式更干净。
初始化数值如下:
- 输入 x = 1
- 目标 y_true = 0.5
- w1 = 0.2,b1 = 0.1
- w2 = 0.3,b2 = 0.0
- 学习率 η = 0.5
4.2 前向传播计算
按照网络结构一步步算下去。
第一个线性层:
z1 = w1 × x + b1 = 0.2 × 1 + 0.1 = 0.3
经过 Sigmoid 激活:
a1 = sigmoid(0.3) = 1 / (1 + e^(-0.3))
e^(-0.3) ≈ 0.7408,所以 1 + 0.7408 = 1.7408,a1 ≈ 0.5744。
第二个线性层,b2 为 0,所以:
y_pred = a1 × w2 + b2 = 0.5744 × 0.3 + 0 ≈ 0.1723
损失函数:
L = 0.5 × (y_pred − y_true)² = 0.5 × (0.1723 − 0.5)² = 0.5 × (−0.3277)² = 0.5 × 0.1074 ≈ 0.0537
前向传播结束,我们得到了预测值 0.1723,它距离目标 0.5 还挺远,损失大约 0.054。
4.3 反向传播手工推导
现在从损失开始,一步一步往回推。我先写结论:反向传播的所有中间量都依赖于“损失对当前节点输出的梯度”,我按层切开计算。
先从最末端开始。损失对 y_pred 的梯度:
dL/dy_pred = y_pred − y_true = 0.1723 − 0.5 = −0.3277
这个值是整个反向传播的源头,它是负的,意思是增加 y_pred 能降低损失。
接着更新输出层的参数。因为 y_pred = w2 × a1 + b2,所以:
dL/dw2 = dL/dy_pred × a1 = −0.3277 × 0.5744 ≈ −0.1882
dL/db2 = dL/dy_pred = −0.3277
然后继续往回传,计算损失对 a1 的梯度。由于 y_pred 对 a1 的导数是 w2,所以:
dL/da1 = dL/dy_pred × w2 = −0.3277 × 0.3 ≈ −0.0983
接下来经过 Sigmoid 激活。Sigmoid 在 z1 = 0.3 处的局部导数是:
σ'(0.3) = a1 × (1 − a1) = 0.5744 × (1 − 0.5744) = 0.5744 × 0.4256 ≈ 0.2445
所以损失对 z1 的梯度:
dL/dz1 = dL/da1 × σ'(0.3) = −0.0983 × 0.2445 ≈ −0.0240
最后回到第一个线性层。 z1 = w1 × x + b1,于是:
dL/dw1 = dL/dz1 × x = −0.0240 × 1 = −0.0240
dL/db1 = dL/dz1 = −0.0240
到此,四个参数的梯度都拿到了:dw1 ≈ −0.0240,db1 ≈ −0.0240,dw2 ≈ −0.1882,db2 ≈ −0.3277。
4.4 参数更新效果验证
按前面设定的学习率 η = 0.5,用最朴素的 SGD 更新参数:
w1 = 0.2 − 0.5 × (−0.0240) = 0.2 + 0.0120 = 0.2120
b1 = 0.1 − 0.5 × (−0.0240) = 0.1 + 0.0120 = 0.1120
w2 = 0.3 − 0.5 × (−0.1882) = 0.3 + 0.0941 = 0.3941
b2 = 0.0 − 0.5 × (−0.3277) = 0.0 + 0.1639 = 0.1639
注意到所有更新都在“增大”参数,因为损失对它们的梯度都是负的,增大参数意味着减小损失。
再用新参数做一次前向传播,验证损失是否真下降了。
第一个线性层:
z1 = 0.2120 × 1 + 0.1120 = 0.3240
a1 = sigmoid(0.3240) ≈ 0.5803
第二个线性层:
y_pred = 0.5803 × 0.3941 + 0.1639 ≈ 0.2287 + 0.1639 = 0.3926
损失:
L = 0.5 × (0.3926 − 0.5)² = 0.5 × (−0.1074)² ≈ 0.0058
看,损失从 0.0537 降到了 0.0058,一步就降了一个量级。这就是反向传播加参数更新配合起来的效果。你完全可以照着这个流程,换成 ReLU、换成更大的网络,用同样的链式法则层层往回推,每步都是简单的乘法。
我也把整个计算过程整理成一张表,方便你对数:
| 节点 | 前向值 | 反向参数 | 数值 |
|---|---|---|---|
| z1 | 0.3 | dL/dz1 | −0.0240 |
| a1 | 0.5744 | dL/da1 | −0.0983 |
| y_pred | 0.1723 | dL/dy_pred | −0.3277 |
| w1 | 0.2 | dL/dw1 | −0.0240 |
| b1 | 0.1 | dL/db1 | −0.0240 |
| w2 | 0.3 | dL/dw2 | −0.1882 |
| b2 | 0.0 | dL/db2 | −0.3277 |
5. 调参实战中的常见问题与排查
5.1 梯度消失和梯度爆炸
梯度消失的特征很好辨认:网络前面的层参数几乎不更新,损失降了一段之后就不再变化,或者训练曲线在某个平台期停滞特别久。梯度爆炸的特征更明显,训练过程中可能出现极端大的参数值,loss 直接变成 NaN,权重更新一步后整个网络数值结构都被冲垮。
原因刚才说过,就是梯度连乘链的乘积要么趋近于 0,要么趋近于无穷。排查的时候先从架构入手:激活函数用 Sigmoid 的深层网络,几乎都会遇到梯度消失,换成 ReLU 或类 ReLU 激活是第一选择;残差连接可以给梯度一条“高速公路”,让信号不经过任何激活就直接向后传;批归一化通过对每层输出做标准化,把激活值拉回一个有效区间,也能明显缓解梯度消失的问题。
梯度爆炸的话,最常用的急救方法是梯度裁剪:算完梯度之后,如果梯度的全局范数超过某个阈值,就把它等比例缩小到阈值以内。我在实际项目中就把阈值设为 1.0 或 5.0,效果非常直接,训练稳定性立刻提升。注意裁剪不等于根治,根本原因往往在初始化、数据尺度或者网络结构上。
5.2 学习率导致的振荡与不收敛
训练时如果发现 loss 曲线像心电图一样剧烈震荡,第一反应永远是先调小学习率。学习率过大时,每一次更新都会跨过最优点的“狭窄区域”,在两边来回弹。我遇到过最经典的现象是:用学习率 0.01 时 loss 完全不降,改成 0.001 后开始稳定下降,这基本就是学习率差了数量级的典型例子。
反过来,学习率太小也有问题:loss 确实在降,但每步只降一点点,训练几万步还没到目标精度。这时候可以用学习率预热策略:前几百步用一个很小的学习率慢慢热身,之后再切到正常学习率,最后再按 epoch 做余弦退火或分段衰减。这种三段式学习率调度,在很多项目里都是稳定可靠的选择。
怎么判断当前学习率方向对不对?我的懒人法则是:先尝试一小组数量级,比如 0.1、0.01、0.001,分别跑几千步,画在同一个坐标系里看曲线趋势。哪个方向是平滑单调下降的,就在哪个数量级附近微调。这个做法虽然简单,但每次都能大大缩小搜索范围。
5.3 梯度检查:确认梯度没算错
反向传播是最容易出现“差一个负号”“少乘一个权重”“搞混了转置”的地方。写网络代码时,尤其是手写反向传播,必须做梯度检查。
原理是用数值微分来近似真实梯度。取一个很小的 h,比如 1e-5,对某个参数 w 计算前后向各一次差值:
数值梯度 ≈ (L(w + h) − L(w − h)) / (2h)
这就是中心差分,它比单边差分 (L(w + h) − L(w)) / h 更准确,误差阶数更高,所以我一直都用中心差分做检查。然后把算出来的“解析梯度”和“数值梯度”做相对比较,公式是 |解析梯度 − 数值梯度| / (|解析梯度| + |数值梯度|)。相对误差在 1e-6 到 1e-4 之内,基本可以认为反向传播写对了。
做梯度检查时有三个注意点:第一,检查时一定要关闭 dropout 这类随机操作和任何会改变计算图的东西,否则两次前向传播之间的损失函数都不同,数值梯度根本不准;第二,h 不能取得太小,太小会因为浮点数精度损失让数值微分反而变差,通常取 1e-5 到 1e-6 之间比较好;第三,如果模型太大,不要所有参数都检查,随机抽一小批参数,比如每类层抽几组 W 和 b 检查即可,因为同一个层的代码逻辑往往是共享的。
5.4 训练中的异常值与隐蔽错误
训练到一半 loss 变成 NaN 或者出现无穷大,大概率的几类原因:数据里有 NaN 值、数值运算中除以了 0、log 函数的输入为 0、或者梯度爆炸导致权重变为无穷。排查时先检查输入数据,把任何 NaN 值都处理掉;再检查损失函数,看里边有没有 log、除法、sqrt 这类容易出问题的运算,必要时在分母上加一个极小值 ε。
还有一个很容易被忽略的问题:数值稳定性。比如 softmax 的实现,如果先算 exp(z) 再归一化,当 z 很大时 exp(z) 会直接溢出为无穷大。正确的做法是先减去最大值再算 exp,这样最大值变成 0,其他项都不会溢出。类似这种实现细节,在小规模测试时数据范围小看不出来,一旦数据量增大就会突然爆炸。
还有一些更隐蔽的问题和代码逻辑有关。比如权重初始化时把标准差设得过大,第一层输出就落进 Sigmoid 的饱和区,梯度瞬间被压成 0;比如 batch size 太小且数据分批次不均,导致梯度方向长期偏斜;再比如更新参数时是在原权重上原地赋值还是拷贝后赋值,深拷贝和浅拷贝用错,可能会让上一次的更新被意外叠加。遇到 loss 反常时,我的习惯是先把程序和训练流程中的“确定性”找回来,固定随机种子,然后一个一个因素去隔离,而不是一股脑改十个超参数。
6. 养成好习惯:让我少走弯路的几条经验
6.1 训练前先做“小数据过拟合”测试
接手任何一个新模型,我会先做一次过拟合测试:只拿几十个样本,训练足够多的 epoch,看模型能不能把损失降到非常低。如果连几十个样本都过拟合不了,那说明代码逻辑、梯度计算、数据管道里大概率有 bug,这时候先不要动优化器,也不要调学习率,先把代码修对。这个习惯帮我排除了大量低级错误,比自己对照公式一行行抠要省时间得多。
过拟合测试通过之后,再切换到正常数据量、正常正则化和数据增强。这时候出现的现象才值得用优化器、学习率这些手段去调。
6.2 每次只改一个变量,把实验记录当作资产
训练深度学习模型非常容易陷入“同时改了三个东西”的混乱状态。调学习率的同时改了 batch size,又换了数据增强方式,最后损失确实下降了,但你根本不知道功劳属于谁。我踩过几次这种坑之后,给自己定了一条纪律:任何一个实验周期,所有变量保持不变,只动其中一个。想调学习率,就只调学习率;想改 batch size,就先冻结学习率。每一步改动都记录下来,包括 IDE 里随手改的数值也要记,因为“这次跑得好”和“上次跑得好”大家通常隔了一个星期才对比,遗忘是常态。
我会给每次实验记录这样的基本信息:随机种子、学习率、优化器、batch size、网络层数、数据预处理方式、loss 曲线截图、跑出来的最终量化指标。排查问题时,翻历史记录比重新跑一次实验高效得多。深度学习项目本质上是在大量实验里找规律,没有记录的实验等于没做。
反向传播和参数更新这条线,是理解所有后续高级调优的底座。把第 4 节的例子亲手算一遍,把常见问题和排查方法心里有数,再去接触分布式训练、混合精度、大规模并行这些进阶话题时,你会发现它们本质上都是在解决同一个问题:如何让梯度更准确、更高效地流动,并让参数沿着更好的方向更新。