ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

反向传播与参数更新:深度学习训练的核心机制与调参实战

反向传播与参数更新:深度学习训练的核心机制与调参实战

很多人第一次接触神经网络,都会被“反向传播”和“参数更新”这两个词劝退。公式看一眼就头大,代码跑起来又全靠调参,最后模型效果不好也不知道该怪谁。但实际上,这两个东西就是整个深度学习训练的引擎:反向传播负责计算每个参数该往哪个方向走、走多快,参数更新负责真正迈出这一步。只要把这两件事彻底搞明白,再看网络上那些训练技巧、loss不降、梯度爆炸的报错,你基本都能自己判断出问题出在哪个环节。

这篇内容我尽量用大白话加上真实写代码时会遇到的场景来讲,配合具体数值例子和排查套路,不带任何框架黑盒。无论你是刚入门的新手,还是训练了好几次模型的老手但一直没深究过优化器细节,这篇文章都值得花十分钟慢慢看。

1. 反向传播到底在做什么:先想清楚“要算什么”

1.1 从数值微分到反向传播:本质是把求梯度的成本降下来

神经网络训练的核心目标一句话就能说清:找到一组参数,让损失函数尽可能小。这里的损失函数是模型预测值和真实标签之间的差距,比如均方误差、交叉熵之类的。而要“找到更小的损失”,最基础的办法就是算出损失函数对每个参数的偏导数,也就是梯度,然后沿着梯度的反方向去调整参数。

假如网络里有100万个参数,下一步就是面临一个非常实际的问题:这个梯度怎么算。可能有人第一反应是直接用微积分定义,把每个参数分别扰动一个极小量,再算损失变化。这就是数值微分:

  • 每算一个参数的梯度,都要完整跑一遍前向传播;
  • 100万个参数就要跑100万次前向传播;
  • 一次训练迭代做100万次前向传播,这个成本实际项目中完全不可接受。

反向传播高明在哪里呢?它只需要一次前向传播得到损失,然后一次反向传播让每个参数都拿到自己的梯度。总的计算量大约只是前向传播的两倍,而不是参数数量倍。和数值微分相比,这个效率差距是质的飞跃。

从数学上看,反向传播只是链式法则的系统化应用,但它的工程意义远不止“会推导公式”。理解了这一点,你就明白为什么所有深度学习框架都把“自动求梯度”作为第一等公民来实现,也明白为什么模型参数再多,训练也能在合理时间内跑完。

1.2 计算图视角:前向拿结果,反向拿梯度

反向传播真正落地的时候,依赖的是一个叫计算图的结构。计算图就是把一次完整的前向传播拆成一堆最基本的运算节点,每个节点接收输入,产出输出。比如一个简单的两层网络:

  • 第一层线性变换:( z^{(1)} = W^{(1)}x + b^{(1)} )
  • 激活函数:( a^{(1)} = \sigma(z^{(1)}) )
  • 第二层线性变换:( z^{(2)} = W^{(2)}a^{(1)} + b^{(2)} )
  • 损失计算:( L = \text{MSE}(z^{(2)}, y) )

这些步骤用图形方式表达出来,每个箭头都是数据流向,每个节点都是一个可微运算。前向传播就是从输入 ( x ) 出发,沿着箭头一路算到损失 ( L )。

反向传播则完全反过来。从损失 ( L ) 开始,逐个节点计算“损失对中间变量的偏导数”,并沿原路返回。打个比方:前向传播像你把一颗球从山顶推下去,球顺着坡道滚到山脚;反向传播像是你沿路把“误差信号”一点点传回每个岔路口,告诉每个路口“你刚才拐弯的角度对最终结果影响了多少”。

实际操作中,每个节点只需要做两件事:一是记录前向时的输入值,二是实现一个局部梯度函数。比如一个乘法节点 ( c = a \times b ),它的局部梯度就是 ( \frac{\partial c}{\partial a} = b )、( \frac{\partial c}{\partial b} = a )。这些局部梯度乘以上游传过来的梯度,就是链式法则的全部内容。

注意:这也是为什么训练时必须保存中间变量。前向传播算出来的激活值、线性层输出,都是反向传播时要用的。显存不够往往不是因为模型参数多,而是因为中间激活值太多。这也是“梯度检查点”这类技巧的理论出发点。

2. 反向传播的完整计算过程:从单个神经元到一个Batch

2.1 单个参数的梯度是怎么流过来的:一个具体数值例子

公式看多了容易头晕,这里用一个极简例子把梯度流动的全过程走一遍。假设网络只有一个隐藏层神经元,激活函数是Sigmoid,损失是均方误差。

定义如下运算:

  • 输入 ( x = 1 ),真实标签 ( y = 0 )
  • 权重 ( w = 0.5 ),偏置 ( b = 0 )
  • 线性输出 ( z = wx + b = 0.5 )
  • 激活输出 ( a = \sigma(z) = \frac{1}{1 + e^{-0.5}} \approx 0.622 )
  • 损失 ( L = \frac{1}{2}(a - y)^2 \approx 0.193 )

现在反向传播一步步来:

  1. 先算损失对激活输出的梯度:( \frac{\partial L}{\partial a} = a - y = 0.622 )
  2. 算激活输出对线性输出的梯度(Sigmoid导数):( \frac{\partial a}{\partial z} = a(1-a) = 0.622 \times 0.378 \approx 0.235 )
  3. 于是损失对线性输出的梯度:( \frac{\partial L}{\partial z} = 0.622 \times 0.235 \approx 0.146 )
  4. 继续传:损失对权重的梯度 ( \frac{\partial L}{\partial w} = 0.146 \times x = 0.146 );损失对偏置的梯度 ( \frac{\partial L}{\partial b} = 0.146 \times 1 = 0.146 )

然后做一步更新。学习率设为 ( \eta = 0.1 ):

  • ( w' = 0.5 - 0.1 \times 0.146 = 0.4854 )
  • ( b' = 0 - 0.1 \times 0.146 = -0.0146 )

如果用更新后的参数重新前向计算,会得到新的损失大约为 ( 0.190 ),确实比之前的 ( 0.193 ) 小。这就是“参数更新让损失下降”这句话最朴素的证据。整个过程没有黑魔法,每一步都可以用笔算出来。

这个例子的价值在于:当你在代码里用loss.backward()之后,再打印param.grad时,看到的数值就是类似这样一点点汇总结算出来的。如果有一天你怀疑某个梯度算错了,完全可以拿这种极小的网络手工推一遍并和框架结果对比。

2.2 矩阵化与Batch训练:真实代码里的反向传播

单个神经元好算,但真实神经网络动辄几十层、上亿参数,不可能一个节点一个节点手写求导。深度学习框架的做法是把计算图上的每个张量运算都实现好局部梯度,然后用矩阵运算一次性计算一批数据的所有梯度。

这就是Batch训练的意义:把多条样本堆叠成一个矩阵,前向传播一次算出整批数据的预测和损失,反向传播一次算出所有样本的平均梯度。比如线性层的前向是 ( Z = XW + b ),反向时对权重的梯度就是 ( \frac{\partial L}{\partial W} = X^T \frac{\partial L}{\partial Z} ),一个矩阵乘法就把 ( X ) 里所有样本的贡献都算进去了。

从这段代码里能直观看到梯度是怎么流动的:

import torch import torch.nn as nn # 一个小线性层:输入3维,输出1维 layer = nn.Linear(3, 1) x = torch.randn(4, 3) # 4条样本,每条3维 y = torch.randn(4, 1) # 4个目标值 # 前向 z = layer(x) loss = nn.functional.mse_loss(z, y) # 反向 loss.backward() # 看梯度 print(layer.weight.grad) # 形状 [1, 3] print(layer.bias.grad) # 形状 [1]

这段代码里的loss.backward()执行时,框架沿着计算图反向遍历,每个张量节点的.grad会被自动填上。这就是反向传播在真实工程里的形态:你不需要手写任何导数,但必须理解它背后的运算逻辑,否则遇到梯度形状不匹配、梯度为NaN、梯度很小不更新这类问题时会完全无从下手。

2.3 激活函数如何决定梯度质量:Sigmoid饱和问题

许多新人在推导反向传播时都算过一遍Sigmoid的导数,但很少人注意到一个潜在问题:Sigmoid导数的峰值是0.25,输入在0附近时梯度最大,一旦输入绝对值变大,导数就会急剧逼近0。

如果网络层数一深,连乘多个Sigmoid导数后,梯度会以指数速度萎缩。比如十层网络,每层的局部梯度都只有0.1,那最终反向传到第一层的梯度就只有 ( 0.1^{10} ),这在浮点数里几乎就是零。结果就是靠近输入层的权重几乎完全接收不到更新信号,模型退化成只有后面几层在工作。

这也是ReLU之所以成为默认激活函数的原因之一:正区间导数恒为1,梯度连乘不会衰减,反向传播可以顺利穿过很多层。不过ReLU在负区间会直接杀死梯度(导数恒为0),所以后来又出现了Leaky ReLU几个变体。你在设计网络结构时,激活函数的选择本质上就是在选择梯度的“传输特性”,这一点往往比“谁拟合能力强”更值得优先考虑。

3. 参数更新的核心逻辑:拿到梯度之后怎么迈步

3.1 学习率:最不该拍脑袋定、却又最常拍脑袋定的超参数

梯度只告诉我们参数应当朝哪个方向调整,但没说调多少。调整幅度由一个系数控制,这就是学习率 ( \eta )。看更新公式:

[ \theta_{t+1} = \theta_t - \eta \cdot \nabla_\theta L ]

学习率太大,参数会跨过最低点来回震荡,损失不降反升;学习率太小,参数每一步都挪得极其缓慢,训练几百个epoch还没到底。这个度很难把握,是因为它既和具体问题有关,也和loss曲面的形状有关。

实际经验里,一个等价的可行性判断是:从 ( 0.01 ) 或 ( 0.001 ) 开始,观察loss曲线。如果loss震荡严重,就调小十倍;如果loss平稳但降得比蜗牛还慢,就试试调大。很多框架的默认学习率都设在 ( 0.001 ) 左右,这个值在大量任务上表现尚可,但它不可能是所有任务的最优解。

更有意思的是,学习率本身还可以随时间变化。训练初期需要用较大学习率快速探索,后期需要用较小学习率精细收敛,这就催生了学习率调度器:Step Decay、Cosine Annealing、Warmup等策略。这些方案看起来是花样百出的技巧,本质都是在说一句话:参数更新的步子大小是动态的,应该跟着训练阶段走。

3.2 为什么不能只看当前梯度:动量法的补偿机制

纯SGD最大的问题是它只看当前这一时刻的梯度,完全不考虑之前的方向。如果loss曲面是个狭长山谷,梯度的方向会不断在两侧来回摆动,真正的下降方向反而每一步都走得很少。现实中这样的情况非常多见。

动量法(Momentum)的思路相当朴素:让参数更新不仅参考当前梯度,还要参考之前几次更新的“惯性”。更新公式变成:

[ v_{t+1} = \beta v_t + (1-\beta) \nabla_\theta L ] [ \theta_{t+1} = \theta_t - \eta v_{t+1} ]

当梯度方向持续一致时,( v ) 会越积越大,相当于加速前进;当梯度方向频繁反转时,( v ) 会相互抵消,摆动被削弱。用生活经验来理解就是你在推一个很重的购物车:推一下它不会马上停下来,而是会继续往前滑,方向不容易被瞬间改变。动量系数 ( \beta ) 通常取 ( 0.9 ),相当于保留了之前大部分运动趋势。

这个设计让训练在“山谷型”的loss曲面上能明显跑得更顺。你如果在SGD下loss像锯齿一样来回跳、但整体趋势又是降的,换成带动量的SGD之后通常会看到平滑得多的曲线。

3.3 Adam为什么“能用但别滥用”

当前最主流的优化器是Adam。它做了两件事:

  • 使用了类似动量的一阶矩估计,保留梯度方向的历史;
  • 还估计了梯度平方的滑动平均,用它对每个参数单独做归一化缩放。

效果就是:每个参数都获得了自适应学习率,稀疏特征的维度能迈大步,频繁出现的维度则自动迈小步。这让Adam在很多任务上几乎不用怎么调参就能跑出不错的结果,这就是它“能用”的原因。

“滥用”的隐患在于两点。第一,Adam对学习率的敏感度仍然存在,它只是对每个参数的缩放做了动态调整,但全局学习率依然需要人设,设得过大照样发散。第二,Adam在部分任务上最终收敛的精度可能不如调好的SGD,尤其在图像分类这类问题上,很多经典模型的最终效果是用带动量的SGD跑出来的。此外,Adam存储额外一阶和二阶动量,显存开销比SGD多一倍。

我个人的选择经验是:

  • 起步探索新任务时,先上Adam,省时间、好上手;
  • 如果最终效果卡在瓶颈,再把优化器换成SGD + Momentum,并重新调学习率,常常能再涨一点;
  • 显存吃紧又不想牺牲效果,中途可以从Adam切回SGD,很多社区都有这种实际切换案例。

4. 训练跑不动时怎么排查:顺着反向传播与参数更新找问题

4.1 loss不降先看梯度:从数值上定位问题所在

训练最常见的问题就是loss不降或直接变NaN。很多人的第一反应是改网络结构、加数据、调loss,但更有章法的排查顺序应该是从梯度和参数更新入手。

第一步先检查梯度是否正常。出现NaN时,大概率是梯度爆炸了,可以打印各层梯度的均值和最大值。第二步检查学习率是否过大,如果loss曲线在高位震荡,这基本就是学习率问题。第三步检查数据预处理,输入特征没有归一化也会导致某些方向的梯度特别大。

排查时用一段简单代码观察每一层梯度的分布:

for name, param in model.named_parameters(): if param.grad is not None: print(f"{name}: grad_mean={param.grad.mean():.6f}, " f"grad_max={param.grad.abs().max():.6f}")

如果发现第一层的grad_max极其小,而后面几层却是正常量级,那就是经典梯度消失。如果所有层梯度都巨大,那就是梯度爆炸。这两者都可以在反向传播的连乘逻辑中找到解释:梯度就是沿着链式法则一路相乘传回去的,连乘的稳定性直接决定了梯度质量。

4.2 梯度消失与梯度爆炸的常见诱因

梯度在深层网络中日益不稳定,核心原因就是多层矩阵相乘的连乘效应。具体实践中最常见的诱因有这么几个。

第一个是没有用合适的权重初始化。如果权重初始值太大或太小,前向信号会指数级放大或萎缩,反向梯度也会同样糟糕。某个项目里我遇到过第一层权重初始化没做任何限制,导致前向特征方差逐层爆炸,最终loss在几十步内就变成NaN的情况。

第二个是使用了不合适的激活函数,尤其是Sigmoid和Tanh这类饱和型激活函数堆叠过深。现代网络基本只用ReLU族就是这个原因,它把梯度传输调成“通畅但可控”的模式。

第三个是损失函数设计不当。比如某些任务里直接回归一个量级非常大的目标,梯度会非常大,需要在最后加个缩放或选择更平滑的loss。

4.3 权重初始化与归一化:把参数起点放到容易训练的位置

训练神经网络很像把一个小球放在一张地形复杂的山地上让它滚向最低点。初始位置如果选在悬崖边缘,随便一滚就滚飞了。权重初始化就是决定小球初始位置的手段。

  • 如果初始化全是0或相同的常数,所有神经元前向输出一致、反向梯度一致,网络退化成单一神经元,完全失去表达能力。常被称为对称性问题。
  • 如果初始化值太小,深层网络的信号传不过去。
  • 如果初始化值太大,前向信号爆炸,反向梯度也爆炸。

现代框架默认都是Xavier或Kaiming初始化,这些方法根据输入维度和输出维度来动态决定初始权重的方差,目标是保证前向信号和反向梯度在穿越各层时保持在一个不容易消失也不容易爆炸的量级。

除了初始化,批归一化(BatchNorm)是另一层保险。它的作用是让每一层的输入都保持稳定的均值方差,间接防止梯度过大或过小。你把BatchNorm理解成“给每一层加了个自动的标准化闸门”就行,它让训练不再那么依赖初始化和学习率的选择。不过注意,BatchNorm会引入额外的统计量,在小batchsize或某些序列任务里可能不稳定。

5. 一次完整的训练状态检查流程:从开始到收敛

5.1 训练前强制做的三步检查

很多训练问题其实在正式开跑前就能预防。我在每次训练新模型之前都会强制做下面三件事。

第一步,检查数据形态。训练数据是否归一化、标签是否在合理范围,这两点直接影响初始梯度量级。输入特征动辄几千上万里程的数据,配上初始权重,很容易让前向输出溢出。

第二步,小规模过拟合测试。拿一小部分数据(比如几十条)单独训练,观察loss能不能在几步之内降到接近0。这一步能在几分钟内验证“模型具备记忆能力”,如果连小批量数据都拟合不了,多半是代码逻辑或者梯度流动本身有问题。框架报错、grad全部为None、某个参数没参与计算图,这些都会在这步暴露出来。

第三步,检查所有参与训练的参数的grad是否为None。初学者经常遇到“定义了某个模块但忘了加到主模型里”的问题,或者某个操作用了torch.no_grad()导致梯度没有计算,最后参数悄悄冻结在初始值。可以在训练开始前后各打印一次参数均值,确认参数确实动了。

5.2 训练过程中盯哪些指标:不只是看loss

很多人训练时只盯着train loss,等到发现不降了才叫救命,其实这个过程可以更从容。训练曲线有非常多的信息可以提前预警。

当train loss持续下降但val loss升高时,这是过拟合信号,应增加正则化、数据增强或减小模型容量。当train loss和val loss都卡住不动时,大概率是学习率太小或梯度消失了。当loss曲线锯齿状明显、没有下降趋势时,学习率大概率过大。另外还要监控梯度的范数和更新幅度,如果在某一层更新幅度一直比参数本身小好几个数量级,那这层的有效训练几乎为零。

调参的时候可以同时做一组对比实验,比如:

  • 学习率从 ( 0.001 ) 降到 ( 0.0001 );
  • 优化器从Adam换成SGD+Momentum;
  • 网络层里加上BatchNorm;
  • 初始化随机种子固定,保证对比公平。

这些对比实验每组跑几个epoch,直接在验证集上看趋势。很多时候,看两条训练曲线的差异,比读一百篇理论文章更能帮你建立直觉。

6. 我做调试时最常用的一套排查顺序:经验型操作参考

调试神经网络训练过程是一项高度依赖经验的活动。我个人的习惯是按照“梯度是否正常 → 学习率是否合适 → 模型容量是否足够 → 数据是否存在问题”这个顺序逐步排查。

6.1 梯度异常时从哪几个角度检查

如果损失突然变成NaN,首先怀疑梯度爆炸。最有效的临时手段是梯度裁剪,把梯度的范数限制在一个阈值以内:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

这行代码的作用相当于给每一步参数更新加了个“安全帽”,防止个别大梯度把参数推到无穷大。它能缓解症状,但不能根治问题。根治往往需要降低学习率、检查数据里是否有极端值、查看是否需要更稳定的损失函数。

如果梯度本身不是NaN,但第一层梯度接近0,那就是梯度消失。此时可以考虑用残差连接让梯度跨层直线传播。如果用了ReLU后仍然消失,那么检查每一层之后是否跟了Sigmoid之类的操作,这类“隐藏的饱和层”经常藏在不显眼的位置。

如果某层参数计算出的梯度全部相同,那就是权重初始化对称性问题,重新用随机初始化即可解决。

6.2 处理完紧急状况之后:优化器与调度器如何配合

处理完梯度异常之后,训练能跑通了,但不代表能跑得好。接下来通常要调的就是优化器和学习率调度器。

一种非常稳妥的组合是:前几个epoch用warmup,把学习率从0线性升到目标值,之后再下降。原因在于训练刚开始时,网络参数完全随机,梯度的方向噪声很大,直接给一个大学习率会让参数在快速波动中破坏初始化还很脆弱的特征结构。给一个“预热期”让梯度方向稳定下来,后续训练的抗风险能力会强很多。

调度器方面,我最常使用的是Cosine Annealing,它在训练后半段的平滑下降能对最终收敛质量有明显帮助。而业界经典的Step Decay策略也有它的优势:实现简单、行为可预测。这里没有绝对的最优,核心原则是:前段要给足探索空间,后段要稳定收敛。

7. 几个训练时的真实片段:用观察检验理解

这里再分享一个我实际调试中印象深刻的例子。某次训练图像分类模型,我用默认参数跑了几十个epoch,train loss从2.0降到1.2以后就一直在原地打转,val loss也纹丝不动。当时第一反应是模型容量不够,于是加了层数和通道数,结果没有任何改善。

后来打印梯度的分布情况,惊讶地发现优化器使用的是Adam,但最后几层的梯度最大值只有 ( 10^{-3} ) 量级,而第一层的梯度也在这个量级。整体梯度过小会让Adam的二阶动量估计也变得很小,更新幅度自然上不去。

把学习率从默认的 ( 0.001 ) 调到 ( 0.01 ) 之后,loss才真正开始往下走。原因其实特别简单:这个任务的损失曲面相对平缓,默认学习率对应的更新步长根本不够用。这里也能看出,把“默认值”当常理而不去怀疑它,是很多训练卡住问题的共同根源。

另一个例子是观察SGD和Adam在相同任务上的训练曲线差异。Adam前期下降快,后期曲线变平;SGD前期下降慢,但后劲足。这是两类优化器最典型的行为差异。理解了这个,你面对“为什么Adam到底都差一点”的问题时就不会慌,要么换成SGD精细调,要么在Adam后期手动降低学习率再继续跑。

8. 最后再说一点实操体会

反传和参数更新这两个词,看着像深度学习的入门知识,实际上贯穿整个训练调参过程。你之后遇到的绝大多数训练问题,比如loss不降、loss震荡、梯度消失、参数冻结、显存溢出,往回追溯都会落在“反向传播的梯度是否正确”和“参数更新的方向和步长是否合适”这两件事上。

我自己的体会是,不要只停留在会调框架接口,最好花一两个小时把前面那个单神经元例子里每一步梯度计算出数值结果,再用代码验证一遍。等你能从纸面上讲清楚“梯度从哪里来、到哪里去、怎么变成参数更新量”这三步,你已经超过了大量只会写model.fit的初学者。

下次训练遇到问题,也先别急着改模型结构,按梯度分布、学习率、动量参数、权重初始化这个顺序检查一遍。往往你会发现,问题出在最朴素的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进