资讯动态

深度学习反向传播原理详解:从计算图到梯度更新的实战指南

发布时间:2026/10/13 7:43:33 来源:尧图企业网站定制
1. 从“能跑通”到“真明白”为什么第五篇要死磕反向传播很多人学深度学习有个分水岭前四篇教程里模型能跑、损失能降、准确率能看但一旦有人问“梯度到底是怎么从最后一层传回第一层的”脑子里就只剩一个模糊的“链式法则”四个字。这个状态我太熟了早期我也是这么过来的——调包调得飞起原理一问就虚。到了第五篇如果还停留在“搭个网络、fit一下、看acc”的层面那后面遇到梯度消失、梯度爆炸、loss不下降这些问题时你连从哪下手都不知道。这篇要干的事很明确把深度神经网络的工作原理从“黑箱”拆成“白箱”然后拿一个实际任务把它跑通、调好、讲透。核心关键词就三个——前向传播、反向传播、梯度更新。这三个词听起来像教科书目录但真正理解它们的人和只会调API的人在遇到问题时的差距是数量级的。适合谁看如果你已经能用Python搭出一个简单的全连接网络知道nn.Linear、ReLU、CrossEntropyLoss大概是什么意思但说不清楚loss.backward()那一行背后发生了什么那这篇就是写给你的。如果你是完全零基础建议先把前四篇的前向传播和基本训练循环过一遍不然这篇的推导部分会有点吃力。我先把结论摆在这反向传播不是什么高深魔法它就是一个极其聪明的“复用计算结果”的链式法则实现。理解这一点你对深度学习的认知会上一个台阶。下面我从计算图讲起一路推到实际任务中间穿插我踩过的坑和调试技巧。2. 计算图视角下的前向传播数据到底流过了什么2.1 把网络看成一张有向无环图教科书讲前向传播通常就是一行公式a σ(Wx b)。这个公式没错但它把“计算过程”压缩成了一个符号你看不到中间发生了什么。我更推荐用计算图的视角来理解每一个操作加法、乘法、激活函数都是图上的一个节点数据沿着边流动每个节点只负责一件小事。举个最小的例子。假设一个单层网络输入x是2维权重W是2×3偏置b是3维激活函数是ReLU。前向传播的计算图是这样的节点1z W x b矩阵乘加节点2a relu(z)逐元素激活节点3loss cross_entropy(a, y)损失计算数据从x流入经过节点1变成z经过节点2变成a最后在节点3和标签y一起算出损失。整个过程是单向的没有环所以叫有向无环图。为什么这个视角重要因为反向传播就是沿着这张图反着走一遍。你在前向时每经过一个节点都产生了一个中间结果z、a这些中间结果在反向时会被复用。如果你不理解计算图你就理解不了为什么PyTorch要“保存中间激活值”——它保存的就是这些节点上的数据等着反向时用。2.2 一个具体的数值例子把抽象变具体光说图还是太抽象我们上数字。假设x [1.0, 2.0] W [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] b [0.01, 0.02, 0.03]先算z W x bz[0] 0.1*1.0 0.4*2.0 0.01 0.1 0.8 0.01 0.91z[1] 0.2*1.0 0.5*2.0 0.02 0.2 1.0 0.02 1.22z[2] 0.3*1.0 0.6*2.0 0.03 0.3 1.2 0.03 1.53所以z [0.91, 1.22, 1.53]。过ReLU因为三个值都大于0所以a z [0.91, 1.22, 1.53]。假设真实标签是类别1索引从0开始用交叉熵损失。先过softmaxexp(a) [e^0.91, e^1.22, e^1.53] ≈ [2.484, 3.387, 4.622]求和2.484 3.387 4.622 10.493softmax [0.237, 0.323, 0.440]损失loss -log(0.323) ≈ 1.130这一串数字算下来你应该能感觉到前向传播就是一连串确定的数值运算没有任何神秘的地方。每一个中间值都是可追溯的这也是为什么我们能调试它——如果loss异常你可以逐层打印这些中间值看是哪一步出了问题。提示实际调试时不要只打印最终loss。把每一层的输出均值、标准差、最大值、最小值都打出来你很快就能定位到是某一层输出爆炸了还是全变成0了。2.3 前向传播里最容易被忽略的细节维度对齐我见过太多新手卡在维度不匹配上。W x里W的形状是(out_features, in_features)x的形状是(in_features,)或者(batch, in_features)。PyTorch的nn.Linear内部做的是x W.T b所以定义时nn.Linear(in_features, out_features)权重矩阵的形状是(out_features, in_features)。这个细节为什么重要因为反向传播时梯度的形状必须和参数形状完全一致。如果你自己手写反向传播维度搞错了梯度累加就会出错而且这种错误往往不会报错只会让模型学不动。我的经验是每次定义完一层立刻打印它的weight形状和bias形状和你的预期对一遍。这个习惯能帮你省下大量排查时间。3. 反向传播的链式法则梯度是怎么一层层“传回去”的3.1 从损失函数出发先算最后一层的梯度反向传播的起点永远是损失函数。我们接着上面的数值例子算一下损失对a的梯度。交叉熵加softmax的组合有个非常漂亮的性质损失对logits的梯度就是softmax输出减去真实标签的one-hot。真实标签是类别1one-hot是[0, 1, 0]softmax输出是[0.237, 0.323, 0.440]所以dL/da [0.237 - 0, 0.323 - 1, 0.440 - 0] [0.237, -0.677, 0.440]这个结果非常直观对于正确类别梯度是负的因为概率还不够大需要往上推对于错误类别梯度是正的需要往下压。三个数加起来等于0这也是softmax梯度的固有性质。接下来要穿过ReLU。ReLU的导数很简单输入大于0时导数为1小于等于0时导数为0。我们的z三个值都大于0所以ReLU这一层的局部梯度是[1, 1, 1]。链式法则相乘dL/dz dL/da * relu(z) [0.237, -0.677, 0.440]3.2 继续往回传对权重和偏置的梯度现在到了线性层z W x b。这里要算三个东西对W的梯度、对b的梯度、对x的梯度虽然输入不需要更新但如果前面还有层就需要继续往回传。对b的梯度最简单dL/db dL/dz因为z对b的偏导是1。所以dL/db [0.237, -0.677, 0.440]。对W的梯度dL/dW dL/dz x.T。展开算dL/dW[0,0] 0.237 * 1.0 0.237dL/dW[0,1] 0.237 * 2.0 0.474dL/dW[1,0] -0.677 * 1.0 -0.677dL/dW[1,1] -0.677 * 2.0 -1.354dL/dW[2,0] 0.440 * 1.0 0.440dL/dW[2,1] 0.440 * 2.0 0.880对x的梯度dL/dx W.T dL/dz。这个在单层网络里用不到但多层网络里必须算否则前一层的参数就没法更新。3.3 为什么说反向传播“聪明”复用中间结果如果你自己手推过上面的过程会发现一个关键点计算dL/dW时用到了x计算dL/dx时用到了W计算dL/dz时用到了dL/da。这些值在前向传播时都已经算过了反向传播直接拿来用不需要重新计算。这就是反向传播高效的核心原因。如果不用计算图复用每算一个参数的梯度都要重新做一遍前向计算量会爆炸。深度网络有百万甚至十亿级参数没有这个复用机制训练根本不可能完成。注意PyTorch的autograd默认会保存前向的中间结果这就是为什么训练时显存占用比推理时大很多。如果你做推理一定要用torch.no_grad()不然中间激活值全存着显存很快就不够用了。3.4 梯度消失和梯度爆炸链式法则的“副作用”理解了链式法则梯度消失和梯度爆炸就很好解释了。假设网络有10层每层反向传播时梯度都要乘上一个局部导数。如果这些局部导数都小于1比如Sigmoid的导数最大只有0.2510层乘下来就是0.25^10 ≈ 0.00000095梯度几乎消失了。反过来如果都大于1乘下来就会爆炸。这也是为什么ReLU取代Sigmoid成为主流激活函数——ReLU在正区间的导数是1不会缩小梯度。但ReLU也有自己的问题负区间导数为0神经元可能“死掉”。实际用的时候LeakyReLU或者GELU往往是更稳的选择。我自己的经验是如果你发现loss在前几个epoch降得很快然后突然卡住不动了八成是梯度消失或者神经元死亡。这时候先检查激活函数再检查权重初始化最后看学习率。这三个地方是重灾区。4. 用NumPy手写一遍反向传播不调框架才能真正理解4.1 为什么要手写框架帮你做了太多事PyTorch的loss.backward()一行代码就搞定了所有梯度计算方便是方便但也把原理完全遮住了。我的建议是至少手写一次完整的反向传播用NumPy实现一个两层网络在MNIST或者类似的简单数据集上跑通。这个过程会让你对梯度的形状、流动方向、更新方式有肌肉记忆。下面是我自己写过的核心代码结构用一个两层网络输入784隐藏层128输出10做例子import numpy as np def relu(x): return np.maximum(0, x) def relu_backward(dout, x): return dout * (x 0) def softmax_cross_entropy(logits, y): # 数值稳定处理减去最大值 shifted logits - np.max(logits, axis1, keepdimsTrue) exp_scores np.exp(shifted) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) n logits.shape[0] loss -np.log(probs[np.arange(n), y] 1e-12).mean() return loss, probs def forward(X, W1, b1, W2, b2): z1 X W1 b1 a1 relu(z1) z2 a1 W2 b2 return z1, a1, z2 def backward(X, y, z1, a1, z2, W2, probs): n X.shape[0] # 最后一层梯度softmax 交叉熵 dz2 probs.copy() dz2[np.arange(n), y] - 1 dz2 / n dW2 a1.T dz2 db2 np.sum(dz2, axis0) # 传到隐藏层 da1 dz2 W2.T dz1 relu_backward(da1, z1) dW1 X.T dz1 db1 np.sum(dz1, axis0) return dW1, db1, dW2, db2这段代码里最关键的是dz2 probs - one_hot(y)这一行。很多人手写的时候会在这里出错要么忘了除以batch size要么忘了减1。除以batch size是因为loss是平均的减1是因为交叉熵对logits的梯度就是概率减标签。这两个细节搞错梯度要么大得离谱要么方向完全反了。4.2 参数更新SGD、Momentum和Adam到底差在哪梯度算出来了接下来是更新参数。最基础的是SGDW1 - lr * dW1 b1 - lr * db1但纯SGD在实际任务里往往收敛慢、容易震荡。Momentum引入了“惯性”vW1 momentum * vW1 - lr * dW1 W1 vW1Adam更进一步同时维护梯度的一阶矩和二阶矩并做偏差修正。它的更新公式看起来复杂但核心思想就是给每个参数自适应地调整学习率——梯度大的参数学习率小一点梯度小的参数学习率大一点。我实测下来的经验是小数据集或者简单任务SGD加Momentum就够了收敛稳定大数据集或者复杂网络Adam通常收敛更快但最终精度可能不如调好的SGD。这不是绝对的但可以作为你选优化器时的起点。4.3 手写过程中最容易踩的三个坑第一个坑是忘记清零梯度。PyTorch里梯度是累加的如果你不在每个batch开始时调optimizer.zero_grad()梯度会越加越大模型很快就飞了。手写NumPy版本时反而没这个问题因为每次都是重新算的。这个对比很有意思框架帮你省了事但也埋了坑。第二个坑是数值稳定性。算softmax时如果不减去最大值exp很容易溢出。算log时如果不加一个极小值log(0)会变成负无穷。这些细节在框架里都被处理好了但手写时你必须自己考虑。第三个坑是维度对不上。dW2 a1.T dz2里a1的形状是(n, 128)dz2的形状是(n, 10)转置后(128, n) (n, 10)得到(128, 10)正好和W2的形状一致。如果你把顺序搞反了要么报错要么得到一个形状不对的结果。每次矩阵乘法前先把形状写在纸上对一遍这个笨办法能帮你省下大量调试时间。5. 把原理落到实际任务一个图像分类的完整训练流程5.1 任务选择和数据处理为什么选这个数据集原理讲完了得找个实际任务练手。我选一个中等难度的图像分类任务手写数字识别。这个数据集不大不小训练快适合验证原理同时它又足够真实能让你遇到batch处理、数据归一化、过拟合这些实际问题。数据加载用PyTorch的DataLoader但有几个细节要注意。第一一定要做归一化。原始像素值是0到255直接送进网络会导致梯度非常大训练不稳定。通常减均值除标准差或者简单除以255。第二训练集和验证集要分开不要拿训练集当验证集那样你看到的准确率是虚高的。第三shuffle要打开不然每个batch的样本分布可能偏差很大。from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)这里的0.1307和0.3081是这个数据集的全局均值和标准差直接用就行。如果你用自己的数据集记得先算一下这两个值。5.2 网络结构设计层数、宽度和激活函数的选择网络结构不需要太复杂两层全连接就够。输入78428×28隐藏层256输出10。激活函数用ReLU输出层不加激活因为交叉熵损失内部会做softmax。import torch.nn as nn class Net(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 256) self.fc2 nn.Linear(256, 10) self.relu nn.ReLU() def forward(self, x): x x.view(x.size(0), -1) # 展平 x self.relu(self.fc1(x)) x self.fc2(x) return x为什么隐藏层选256这是个经验值。太小了欠拟合太大了过拟合且训练慢。你可以从128开始试如果训练准确率上不去就加到256或512。宽度比深度更容易调对于这种简单任务两层网络足够加更多层反而可能梯度消失。5.3 训练循环每个epoch到底发生了什么训练循环的骨架很简单但每个细节都有讲究model Net() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() # 1. 清零梯度 output model(data) # 2. 前向传播 loss criterion(output, target) # 3. 算损失 loss.backward() # 4. 反向传播 optimizer.step() # 5. 更新参数这五行就是深度学习的核心循环。zero_grad清掉上一轮的梯度forward算出预测criterion算损失backward算梯度step更新参数。顺序不能乱少了任何一步都会出问题。我见过有人把zero_grad放在backward后面结果梯度累加了两轮loss直接爆炸。也见过有人忘了model.train()导致BatchNorm和Dropout在训练时行为不对。这些看起来是小问题但实际调试时能让你找半天。5.4 验证和测试怎么判断模型是真的学到了训练过程中要定期在验证集上评估不然你根本不知道模型是记住了训练数据还是真的学到了规律。评估时要调model.eval()并且用torch.no_grad()关掉梯度计算model.eval() correct 0 with torch.no_grad(): for data, target in test_loader: output model(data) pred output.argmax(dim1) correct (pred target).sum().item() accuracy correct / len(test_dataset)这个流程跑下来准确率应该在97%以上。如果低于95%检查一下归一化、学习率、网络结构。如果训练准确率很高但测试准确率低那就是过拟合了需要加Dropout或者L2正则化。提示验证集和测试集不要混用。验证集用来调超参数测试集只在最后评估一次。如果你反复在测试集上调参测试集就变成了验证集你看到的指标就不再客观了。6. 训练不收敛怎么办一份按优先级排序的排查清单6.1 先看数据再看模型排查顺序不能反模型不收敛很多人第一反应是改网络结构、调学习率。但我的经验是八成的问题出在数据上。先检查这几件事标签有没有对齐输入和标签是不是一一对应的数据有没有归一化像素值是不是还在0到255之间有没有NaN或者Inf用np.isnan和np.isinf扫一遍。类别是否极度不平衡如果某一类占了99%模型全预测那一类也能有99%准确率。数据没问题了再看模型。模型这边先检查输出范围如果输出全是同一个值说明权重初始化有问题或者梯度没传进去。再检查梯度打印每一层梯度的均值和最大值如果全是0那就是梯度消失如果大得离谱那就是梯度爆炸。6.2 学习率最常调也最容易调错的参数学习率是超参数里最重要的一个。太大loss震荡甚至发散太小收敛慢得像蜗牛。我的经验值是Adam从1e-3开始试SGD从1e-2开始试。如果loss在前几个batch就变成NaN学习率肯定太大了除以10再试。如果loss几乎不动乘以10再试。还有一个技巧是学习率预热和衰减。预热就是在训练初期用很小的学习率慢慢升到设定值避免一开始就把参数推飞。衰减是在训练后期降低学习率让模型更精细地收敛。PyTorch里有lr_scheduler可以直接用但手动实现也不难。6.3 权重初始化为什么不能全初始化为0如果所有权重都初始化为0每个神经元的输出都一样反向传播时梯度也一样更新后还是一样。这叫对称性问题网络永远学不到不同的特征。所以权重必须随机初始化。常用的初始化方法有Xavier和He初始化。Xavier适合Sigmoid和TanhHe初始化适合ReLU。PyTorch的nn.Linear默认用均匀分布初始化对于小网络够用但如果你自己搭深层网络最好显式指定初始化方法nn.init.kaiming_normal_(self.fc1.weight, modefan_out, nonlinearityrelu) nn.init.zeros_(self.fc1.bias)6.4 一个真实的排查案例loss卡在2.3不动我之前遇到过一个情况模型训练时loss一直卡在2.3左右准确率10%相当于随机猜。排查过程是这样的第一步检查数据。发现标签没问题归一化也做了。第二步检查模型输出。打印logits发现所有样本的输出几乎一样。第三步检查梯度。发现第一层的梯度全是0。第四步检查激活函数。发现用的是Sigmoid而且输入值很大Sigmoid饱和了导数接近0。换成ReLU之后问题解决。这个案例说明梯度消失不一定是深层网络才有激活函数选错了浅层网络照样出问题。7. 从原理到直觉几个让我豁然开朗的类比7.1 反向传播像“责任分配”前向传播是“做事”反向传播是“追责”。损失大了是谁的责任最后一层说“我有一部分责任”然后把剩下的责任按权重比例传给前一层。每一层都这么传最后每个参数都知道自己该负多少责任然后按责任大小调整自己。这个类比帮我理解了一件事梯度的大小就是责任的大小。梯度大说明这个参数对损失的“贡献”大需要多调整梯度小说明它影响不大少调一点就行。7.2 学习率像“步长”优化过程像下山。梯度告诉你哪个方向是下坡学习率告诉你每步走多远。步长太小天黑都下不了山步长太大一步跨过山谷来回震荡。Adam这类自适应优化器相当于给每个方向配了不同的步长——陡的地方走小步平的地方走大步。7.3 过拟合像“死记硬背”模型在训练集上表现很好在测试集上表现差就像学生死记硬背了练习册的答案换一套题就不会做了。Dropout相当于考试时随机遮住一部分题目逼学生理解规律而不是背答案。L2正则化相当于惩罚“太复杂的解释”让模型倾向于简单的规律。这些类比不能替代数学推导但它们能帮你建立直觉。有直觉的人调参是有的放矢没直觉的人调参是碰运气。8. 下一步可以往哪走从全连接网络到更真实的场景全连接网络在MNIST上能跑到97%以上但换成更大的彩色图片参数量会爆炸。一张224×224×3的图片展平后是150528维接一个256维的隐藏层光这一层就有3800万个参数。这时候就需要卷积神经网络了。卷积的核心思想是局部连接和权重共享。一个卷积核在整张图上滑动检测同一种特征比如边缘、纹理参数量大大减少。反向传播在卷积层上的推导和全连接类似只是链式法则要处理滑动窗口的对应关系。再往后还有循环神经网络处理序列、注意力机制处理长距离依赖、Transformer处理各种模态。但不管网络结构怎么变前向传播算输出、反向传播算梯度、优化器更新参数这个核心循环是不变的。把这套原理吃透后面学什么新结构都是在这个骨架上加东西。我自己走下来的体会是不要急着追新模型先把反向传播手推一遍、手写一遍、调通一遍。这个过程花不了太多时间但它带来的确定感能让你在后面遇到各种奇怪问题时心里有底。调包谁都会但能定位问题、能解释现象、能自己设计实验验证想法的人才是真正入了门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑