最近我一直在做一件有点“返祖”的事:把之前学得囫囵吞枣的深度学习基础,重新啃一遍。起因很简单,上手大模型、微调、跑推理都挺顺,但一旦遇到Loss不下降、梯度爆炸、显存溢出这类问题,就发现自己只是在“调参咒语”,根本不理解模型内部到底发生了什么。所以这次我干脆把自己当成一个AI小号,从头重练,第一篇先记录最核心也最劝退的三个概念:前向传播、梯度下降与反向传播。
这篇重修日记适合谁?如果你也和我一样,能跑通不少现成代码,但问起“为什么更新权重用梯度”“反向传播到底反传的是什么”就支支吾吾,那这篇就是写给你的。我会尽量用生活化的类比和最简单的手算例子,把这三个概念串成一条线,并附上完整的PyTorch复现思路和踩坑记录。它解决的核心问题只有一个:让你从“会调参”变成“看得懂参数为什么会那样变”。
1. 前向传播:数据是怎么在神经网络里流动的
1.1 从一次最简单的预测说起
我一直觉得,初学者被前向传播这个概念吓到,主要是因为教科书一上来就摆一堆网络结构图。其实前向传播做的事,本质上就是一次函数调用。
假设我们有一个最简单的神经元,它有两个输入:x1 = 3,x2 = 1。这个神经元内部维护两个权重w1 = 0.5、w2 = -1,还有一个偏置b = 2。那么前向传播就是做一次线性变换:
z = w1*x1 + w2*x2 + b = 0.5*3 + (-1)*1 + 2 = 1.5 - 1 + 2 = 2.5然后,为了让这个神经元具备非线性表达能力,再套一个激活函数。比如如果用Sigmoid,就会得到:
a = sigmoid(2.5) ≈ 0.993这个a就是该神经元的最终输出。如果这是最后一层,a就可以当作模型的预测结果。实际场景中如果这是一个二分类任务,我们可能把输出和阈值0.5比较,大于0.5认为类别为1。
你自己写一遍这个计算就会发现,所谓前向传播,本质就是一堆乘法和加法的堆叠,中间穿插着激活函数。深度学习模型动辄几百层,但每一层的计算模式都和上面这个例子一致,只是从标量变成了矩阵而已。
1.2 为什么要用矩阵形式
单个神经元好理解,但真实模型里有几百个神经元,如果一个个写乘法,代码就没法看了。所以实践中前向传播都是写成矩阵运算。
举个例子,我们不要两个输入,而是三个输入x = [x1, x2, x3],这一层不要一个神经元,而是两个神经元。两个神经元各自的权重分别是w1 = [w11, w12, w13]和w2 = [w21, w22, w23]。那么两个神经元的输出可以一次性写出:
z1 = w11*x1 + w12*x2 + w13*x3 + b1 z2 = w21*x1 + w22*x2 + w23*x3 + b2写成矩阵形式就是:
z = W * x + b这里的W形状是(2, 3),x形状是(3,),z形状是(2,)。这看起来只是记法上的简化,但它带来了一个关键好处:计算效率高。GPU就是为这种大规模矩阵运算设计的。PyTorch里你写一行z = torch.mm(W, x) + b,实际底层会调用高度优化的线性代数库,而不是逐个元素做Python循环。
我建议初学者一定要自己动手把两层、带有激活函数的网络展开成矩阵形式写一遍。很多困惑其实都源于对矩阵形状不清楚。我自己当初卡了很久才发现:前向传播时,每一层输出的形状恰好就是下一层期望的输入形状。这个维度上的环环相扣,是理解整个模型计算流的一根主线。
1.3 激活函数为什么不能省略
如果网络只做线性变换,那不管堆多少层,展开后本质上还是一个线性函数。换句话说,深层网络就会退化成单层网络。激活函数的作用,就是给网络注入非线性。
Sigmoid能把任意实数压缩到0到1之间,适合做二分类输出层;ReLU则把负数全部置零,正数保持不变,因为计算简单、梯度特性好,现在几乎成了隐藏层的默认选择。大家可能会看到很多网图说ReLU是“工业默认”,这不是没有原因的。
我们在前向传播做完一次计算后,得到模型预测值,接下来的问题是:怎么评价预测得好不好?这就引出了损失函数。其实前向传播的终点就是损失函数——模型的输出传入损失函数,和真实标签比对,得到一个衡量差距的数值。这个数值正是后面梯度下降和反向传播的起点。
2. 梯度下降:沿着什么方向更新参数
2.1 损失函数是山,参数是位置
把前向传播、损失函数和参数更新的过程放到一起看,梯度下降有一个非常直观的几何解释。假设我们只有一个参数w,损失函数L(w)就是一条二维曲线。我们随机初始化时,相当于站在曲线上某个位置。训练的目标就是走到曲线的低点,也就是让损失尽可能小的地方。
如果有两个参数w1和w2,损失函数就是三维曲面,我们相当于在一片连绵的山脉上找最低的谷底。这是理解梯度下降最常见的场景:把损失函数想象成一座山,把参数想象成当前坐标,梯度就是这座山在当前坐标处最陡的上坡方向,梯度下降则是反着走——沿着最陡的下坡方向迈出一步。
下山这个类比很直观,但它隐藏着一个关键细节:梯度是函数对每个参数求偏导后组成的向量。它是个向量,有方向也有大小。方向告诉我们往哪边走能更快下降,大小告诉我们这个陡峭程度有多强烈。两者共同决定了一次参数更新的幅度。
2.2 学习率:步子迈多大,是个大问题
按照梯度下降的直觉,参数更新公式是:
w_new = w_old - learning_rate * gradient这里learning_rate就是学习率。学习率是训练过程中最敏感的超参数之一,很可能没有“之一”。
学习率如果太大,一步跨出去可能直接跳过最低点,甚至跳到损失更高的位置,训练曲线会出现明显的震荡,极端情况下会直接发散。学习率如果太小,虽然方向正确,但每步走得太慢,训练到天荒地老也不见得收敛。
我自己的经验是,刚开始调参时,不用盯着高级优化器里的花哨参数,先把学习率定下来才是核心。一个比较实用的做法是:先用一个较大的学习率比如0.1或0.01看Loss曲线是否下降;如果震荡,除以10;如果下降太慢,乘以3。反复几次就能快速定位到一个相差不多的量级。不要一上来就0.001起步,很多新手就是因为学习率设太小,等了半小时Loss纹丝不动,还以为是代码写错了。
2.3 三种梯度下降形态的取舍
严格来说,梯度下降有三种常见的实现方式,它们的区别主要在于“计算梯度时用多少数据”。
第一种是批量梯度下降,每次用全部训练数据计算一次梯度,然后更新一次参数。这样做梯度方向最准确,但一旦训练集达到几万、几十万条,每更新一次都要完整遍历一遍数据,计算量极大,实践中基本不采用。
第二种是随机梯度下降,每次只随机取一条样本计算梯度并更新。这相当于拿到一条样本的“局部意见”就行动,好处是更新极快,坏处是梯度方向噪声很大,Loss曲线会像心电图一样剧烈跳动。
第三种是小批量梯度下降,每次取一小批数据,比如32或64条,计算这批数据的平均梯度来更新参数。这是当前实践中的主流选择。它兼顾了上面两者的优点,而且矩阵运算正好能把一批数据一次性算完,GPU利用率很高。
从工程实践的角度看,真正用的几乎都是第三种,但理解第一种和第二种的原理仍然是必要的,因为很多学习率调整策略、收敛性分析都建立在这两种抽象之上。
2.4 梯度为零和梯度爆炸的隐患
梯度下降看起来简单,但有两个极其常见的坑:梯度消失和梯度爆炸。
先说梯度消失。当网络层数很深或者激活函数选择不当时,梯度在反向传播过程中会逐层缩小。打个比方,反向传播每经过一层就要乘一次小于1的数,几十次之后梯度就接近零了。梯度越小,参数更新量越小,前面的层几乎学不到东西。历史上Sigmoid在隐藏层被大量淘汰,一个重要原因就是它在较大输入值附近导数趋近于零,让浅层梯度消失。
再说梯度爆炸。和消失相反,梯度经过多层传播后变得极大,参数更新幅度大到离谱,Loss会直接冲到NaN或者溢出。这种情况在循环神经网络里尤其普遍。常用的应对手段包括梯度裁剪、换用更稳定的激活函数、合理初始化、适当降低学习率等。
我前面说这些,是想说明一个问题:梯度下降不是一个平平无奇的“下山算法”,在实际训练时,梯度本身是否干净、稳定,在很大程度上决定了训练能不能成功。而梯度是怎么算出来的?这就进入了反向传播的地盘。
3. 反向传播:梯度是怎么“传”回每一层的
3.1 链式法则:反向传播的数学引擎
反向传播(Backpropagation)的核心并不是一个复杂的算法,而是微积分里的链式法则在神经网络结构上的系统应用。
很多教程从公式开始讲,但我想换一个角度:直接用一个衔接上一章的具体例子来算一遍。假设网络只有两层:
第一层:h = w1 * x + b1,然后经过ReLU 第二层:y = w2 * h + b2整个前向过程就是:x → h → y。现在我们对模型输出y计算一个损失L。要更新w1,我们需要知道dL/dw1。但损失是y的函数,y才是w1的函数。怎么把这条链路打通?链式法则告诉我们:
dL/dw1 = (dL/dy) * (dy/dh) * (dh/dw1)这就是反向传播的全貌:从最后的损失开始,一层一层往回算梯度,每经过一层就乘上该层的局部导数项。最终得到损失对每个权重的偏导。
你可能会想:这不就是复合函数求导嘛。对,原理一点不复杂。但它特别的地方在于工程实现:一个现代神经网络可能有几百万甚至几亿个参数,如果每次都手动推导这些链式偏导,根本不可能。所以框架们普遍采用了一种叫“自动微分”的技术,它在计算前向过程的同时记录计算图,然后反向遍历这张图,自动完成链式法则的所有计算。
3.2 用一个双层网络手算一遍完整流程
理论说多了容易飘,还是动手。我设计一个极简网络:输入x = 2,真实标签y_true = 1。
初始化:
w1 = 0.5 b1 = 0 w2 = -0.8 b2 = 0前向传播:
h = w1 * x + b1 = 0.5*2 + 0 = 1.0 a = relu(h) = 1.0 # h为正,ReLU原样输出 y_pred = w2 * a + b2 = -0.8*1 + 0 = -0.8使用均方误差损失:
L = 0.5 * (y_pred - y_true)^2 = 0.5 * (-0.8 - 1)^2 = 0.5 * 3.24 = 1.62接下来反向传播。第一步,计算dL/dy_pred:
dL/dy_pred = y_pred - y_true = -0.8 - 1 = -1.8第二步,计算w2的梯度。根据链式法则:
dL/dw2 = (dL/dy_pred) * (dy_pred/dw2)其中dy_pred/dw2 = a = 1.0,因此:
dL/dw2 = -1.8 * 1.0 = -1.8第三步,继续向下去求w1的梯度。先看dL/dh,它等于(dL/dy_pred) * (dy_pred/dh),而dy_pred/dh = w2 = -0.8,所以:
dL/dh = -1.8 * (-0.8) = 1.44这里要注意ReLU的导数。h = 1.0,大于零,所以ReLU的导数是1;如果h小于零,导数就是0,梯度会在这里直接截断。
dL/dh_pre_activation = dL/dh * relu'(h) = 1.44 * 1 = 1.44最后:
dL/dw1 = dL/dh_pre_activation * (dh/dw1) = 1.44 * x = 1.44 * 2 = 2.88整理一下,我们的梯度结果是:
dL/dw2 = -1.8 dL/dw1 = 2.88更新参数时,假设学习率lr = 0.1:
w1_new = 0.5 - 0.1 * 2.88 = 0.212 w2_new = -0.8 - 0.1 * (-1.8) = -0.62注意w2原来的值是负数,更新后负数的绝对值变小了,方向上其实是往让y_pred变大、损失变小的趋势走。重复这个过程多轮,Loss就会逐步下降。这就是前向传播、梯度下降和反向传播三者首尾相接、循环淬炼的全过程。
3.3 计算图:自动微分的基石
刚才我们手动做了一遍链式法则,但框架层面不会这么做。PyTorch、TensorFlow这类框架采用的方式是:先构建计算图,再自动微分。
所谓计算图,就是把所有运算节点和它们之间的依赖关系画成一张有向图。前向传播时,框架沿图正向计算每个张量。同时,每个张量会记录自己是“由哪些张量经过什么操作算出来的”。等到调用.backward()时,框架沿图的逆向方向,利用链式法则逐节点算出梯度。
这样设计带来的最大好处是灵活:只要前向计算能被表示成一张计算图,反向传播就能自动完成。所以即便你自定义了一个非常奇怪的网络结构,只要运算都是可微的,PyTorch就能自动帮你算出梯度。这正是“动态图”思维的价值。
为了更直观地感受计算图,下一节我用一个微型案例来完整演示一次训练循环,把前向传播、损失计算、反向传播、参数更新的完整链路都串起来。
4. 实操:用PyTorch把三件事串成一次训练循环
4.1 搭建微型模型
前面说了这么多理论,不落到代码里总觉得不踏实。这次我用PyTorch写一个极简的回归任务:输入是一个特征值,输出是一个数值,模型只有一个神经元。整体代码只有几十行,但对理解整套训练流程非常有帮助。
首先定义模型:
import torch import torch.nn as nn class TinyModel(nn.Module): def __init__(self): super().__init__() self.linear = nn.Linear(1, 1) def forward(self, x): return self.linear(x)nn.Linear(1, 1)代表一个输入维度为1、输出维度为1的线性层。它内部自动初始化了权重和偏置。前向传播就是简单调用self.linear(x)。
生成一些简单训练数据,假设真实规律是y = 2 * x + 1:
x_train = torch.tensor([[1.0], [2.0], [3.0], [4.0]], requires_grad=False) y_train = 2 * x_train + 14.2 训练循环的标准四步
PyTorch的标准训练循环长得都差不多,核心就是四个动作:前向计算、损失计算、反向传播、参数更新。
model = TinyModel() optimizer = torch.optim.SGD(model.parameters(), lr=0.01) loss_fn = nn.MSELoss() for epoch in range(100): # 1. 前向传播:用当前参数计算预测值 y_pred = model(x_train) # 2. 计算损失 loss = loss_fn(y_pred, y_train) # 3. 反向传播:计算所有参数的梯度 optimizer.zero_grad() loss.backward() # 4. 参数更新:优化器用梯度更新参数 optimizer.step()这段代码只有十几行,但它把前面所有的概念都装进去了。model(x_train)是前向传播,loss_fn计算预测和真实值之间的差距,loss.backward()计算每个参数的梯度,optimizer.step()根据梯度更新参数。zero_grad()则是把上一次迭代残留的梯度清零,非常重要,如果漏了这一步,梯度会累加,参数更新方向会被污染。
我经常建议新手在这段代码里加上两行调试信息:
if epoch % 20 == 0: print(epoch, loss.item(), model.linear.weight.item(), model.linear.bias.item())这样你能亲眼看到Loss从几到零点几的下降过程,也能看到weight和bias如何慢慢逼近真实值2和1。这种观察比自己闷头看代码直观多了。
4.3 把optimizer换成SGD之后发生了什么
我在上面的例子里用的优化器是SGD,它就是最经典的梯度下降:直接用lr * grad更新参数。不过有一点值得新手警惕:optimizer.step()这一步,并没有直接写在你的代码循环里,这其实是一种刻意设计。
PyTorch把“计算梯度”和“更新参数”拆开,让你有更大的灵活性。比如你想实现梯度裁剪,可以在loss.backward()之后、optimizer.step()之前手动修改梯度值。这也是我在实际项目里经常用到的写法,尤其是训练循环神经网络或者Transformer类模型时,梯度裁剪几乎是标配。
再补充一点,很多人以为optimizer.zero_grad()会把梯度清零,但准确说是把grad属性清零。如果你用的是autograd.grad这类函数手动计算梯度,不受zero_grad影响。这块细节没必要死记,但遇到“梯度莫名其妙变大”的问题时,就要优先检查是不是忘了清零。
4.4 观察Loss为什么是抛物线式下降
跑完上面那段代码,你会发现Loss曲线不是直线下降,而是开始降得快、后面降得慢。这是正常的:随机初始化时,参数离最优解远,梯度大,每步更新多;随着参数接近最优解,梯度变小,更新幅度也变小。这正是梯度下降在误差曲面较平滑区域的自然行为。
如果Loss下降太平滑,我反而会怀疑是不是学习率太小;如果Loss先急降后反弹,那我第一反应是学习率太大或者数据本身有问题。这种对Loss曲线形态的敏感,比背诵多少理论都更有用。
5. 常见问题与排查技巧实录
5.1 Loss变成NaN,第一时间检查什么
训练过程中Loss变成NaN(Not a Number),是我见过新手最容易恐慌的问题。它根本不意味着模型“坏了”,几乎总是下面几个原因之一:学习率过大导致梯度爆炸,梯度值直接溢出;数据中含有NaN或无穷大;模型输出的数值范围过大,比如对数运算输入了负数。
排查顺序我会这么走:先打印一次前向传播的输出,确认输入数据没有异常;然后打印梯度的最大值,看是否是一个异常巨大的数;最后把学习率降到原来的十分之一再跑一遍。三个步骤走完,大概率能定位到问题。
5.2 梯度很小但不更新,是不是收敛了
新手容易把“梯度很小”和“已经收敛”画等号。未必。梯度很小也许是因为你正好处在误差曲面的一片平坦区域,也就是所谓“鞍点”附近。这种情况下模型参数几乎不动,但并非真正到了最优解。
可以尝试改变优化器。Adam这类自适应优化器对学习率的敏感程度比SGD低,很多跳过鞍点的场景下表现更好。另外,也可以给学习率加一个调度策略,比如训练过程中逐步衰减,帮助模型在后期更好地收敛。
5.3 ReLU神经元“死掉”了是怎么回事
ReLU虽然好用,但有个很出名的问题:dying ReLU。当前向传播中某个神经元的输入一直为负数时,ReLU的输出恒为0,反向传播时梯度经过它就是0,于是该神经元的权重再也得不到更新,相当于这个神经元永远“死”了。
遇到这种情况,我常用的处理方式有三种:降低学习率,减少梯度把权重推到负数区域的概率;换用LeakyReLU,给负数区域一个很小的斜率,保证梯度能够流通;或者调整权重初始化策略,避免一开始就把大量神经元的输入推到负数区域。
5.4 梯度不更新但我想手动检查梯度,怎么办
有时候我需要确认某个参数到底有没有梯度。可以在loss.backward()之后,直接查看参数张量的grad属性:
for name, param in model.named_parameters(): print(name, param.grad)如果某些层参数一直梯度为零,要分两种情况判断:如果是因为激活函数截断导致的真零梯度,那就是“死神经元”问题;如果是因为requires_grad=False或者被放在了torch.no_grad()上下文里,那是代码逻辑问题。两种修法完全不同,先别急着改代码,先看清楚是哪一种。
5.5 特征尺度不一致会让梯度下降很慢
很多新手会忽略数据归一化对梯度下降的影响。如果两个特征量纲差别悬殊,比如一个在0到1之间,另一个在0到10000之间,损失函数曲面就会变得非常狭长,梯度下降在这种曲面上会极度曲折缓慢。
归一化到标准正态分布或者0到1区间,能在很大程度上让误差曲面变成一个接近圆形的碗,梯度下降路径会直接得多。这也是为什么几乎所有的深度学习项目都有数据归一化这一环。
6. 这套基础概念与大模型训练的真实关联
6.1 大模型的前向传播到底在算什么
前面讲的是单层、双层线性模型,你可能会隐隐觉得:大模型那么复杂,难道也只是在做这些吗?答案是,从机制上说,是的。
大模型也就是Transformer结构,它的前向传播依然是一堆矩阵乘法、非线性激活函数、归一化操作,只是这些运算被打包成了不同模块:自注意力、前馈网络、层归一化、残差连接等等。在推理阶段,一个模型把输入的token序列映射成下一个token的概率分布,本质上就是完成了一次巨大的前向传播。
但有一点变化非常关键:大模型参数量动辄几十亿上百亿,如果还一视同仁地更新全部参数,训练成本高到无法接受。由此发展出很多基于“只更新一部分参数”的策略,比如冻结底层、只训练顶层,或者通过低秩近似间接训练大矩阵。理解了完整训练链路之后,再去看这些策略会通透很多。
6.2 反向传播为什么在大模型时代仍然没有过时
有很多人问过我,大规模分布式训练、大模型微调是不是已经不需要反向传播了。事实恰好相反,反向传播至今仍是训练的主流。但分布式训练中,它被切成了数据并行和模型并行的不同实现方式。
数据并行最简单:每张GPU卡存一份完整模型副本,各自用一部分数据计算梯度,然后汇总梯度取平均,再统一更新每张卡上的模型参数。模型并行更复杂:模型太大放不进单卡内存,就把不同层分到不同卡上,前向传播时数据依次流经各卡,反向传播时梯度再逆序流回,本质还是标准的链式法则,只是跨了设备。
有一个在训练中经常引用的名言式经验:反向传播的时间和空间开销都与前向传播相当,所以要预判训练一个超大模型需要多大内存,常常直接按前向峰值内存乘二来粗估。理解这个层面的工程考量,会比单纯看公式更有用。
6.3 微调大模型时的梯度策略
现在对大模型做微调,主流做法不是更新全部参数,而是引入少量可训练参数来适配下游任务。这类方法的底层逻辑其实和反向传播完全兼容:冻结大部分参数,只有新增的少量参数会接收梯度并更新。
这套思路带来的好处很直接:显存占用大幅下降,因为不需要为冻结参数保存大量中间激活值和梯度;训练稳定性也更好,因为基础模型学到的丰富特征没有被破坏。前向传播全部正常走,反向传播只更新一小部分参数。现在去回看这些框架时,再配合今天讲的完整梯度链路,理解起来就真的是水到渠成。
7. 重修之后的一点心得体会
坦白说,这一轮重修给我最大的收获并不是“记住了公式”,而是建立了一种直觉:训练过程本质上就是在反复做三次操作——用当前参数算一遍预测,看看预测差了多远,然后让误差一步步传回去修正参数。
我建议新手在啃理论的同时,一定亲手把第4节那个微型模型改着玩,比如改一下学习率、换一下损失函数、调大一下层数,看看Loss曲线和打印出的梯度各有什么变化。这类动手实验带来的体感,比任何教程都珍贵。
最后分享一个小习惯,我每次训练新模型时,都会顺手把以下几个指标一起打印出来:Loss值、梯度最大范数、更新后参数的变化幅度。训练不收敛时,这三组数据能快速帮我判断问题到底出在前向计算、梯度质量还是学习率设置上。这套方法救过我很多次,你也可以试试。