最近在系统过一遍深度学习的核心基础把《PyTorch深度学习实践》系列课程中反向传播这部分完整复现了一遍。这篇博客就是这次学习实践的完整记录包含原理图解、逐行代码注释、实验中遇到的坑和排查思路适合正在学PyTorch和深度学习基础、想把反向传播真正搞懂而不是停留在调用层面的同学参考。整个系列课程最大的特点就是“从零手写”不把PyTorch当成黑盒而是带着你把梯度计算、参数更新这些底层的逻辑一步步推导出来。我个人觉得反向传播这一节是整个课程里最值得反复看的一节因为后面所有复杂的网络结构、损失函数、优化器本质上都是在反向传播这条主线上叠加变化。搞懂这一节后面学CNN、RNN、Transformer都会顺畅很多。1. 反向传播到底在解决什么问题1.1 从梯度下降说起在机器学习里训练模型的核心目标就是让损失函数的值尽可能小。损失函数衡量的是模型预测值和真实值之间的差距这个差距越小说明模型越准确。那怎么让损失函数变小最常用的方法就是梯度下降沿着损失函数下降最快的方向也就是负梯度方向不断调整模型的参数。但是一个现实的问题是深度神经网络通常有很多层每一层又有多个参数。比如一个简单的两层网络权重参数可能就有几十上百个复杂一点的网络更是动辄几百万参数。如果每次更新参数都要把网络整体重新算一遍计算量会非常大。反向传播算法解决了这个问题它利用链式法则从输出层到输入层逐层计算每个参数对损失函数的梯度然后把梯度传给优化器让优化器去更新参数。整个过程是“正向传播算损失反向传播算梯度”循环往复直到损失收敛。1.2 为什么不能直接求解析解有人可能会问既然损失函数是一个数学表达式为什么不直接对每个参数求偏导、令偏导为零解出最优值理论上可以但实际做不了。因为神经网络的损失函数通常不是简单的凸函数而是一个极其复杂的高维非凸函数。直接求解解析解意味着要解一个包含数百万变量的方程组计算量不可接受而且大多数情况下根本不存在解析解。更关键的是深层网络的损失函数往往高度非线性根本无法写出对所有参数的统一解析表达式。所以必须采用数值迭代的方式一步步逼近最优值。而每次迭代都需要知道“每个参数对损失的影响方向”也就是梯度——反向传播就是高效计算这些梯度的算法。1.3 类比理解猜价格游戏我特别喜欢用一个通俗的类比来解释反向传播假设你是一个猜价格游戏的玩家目标是根据几个特征比如面积、地段、楼层猜出一套房子的价格。你最初随便猜了一套参数算出一个预测价格761但真实价格是700差了61。这时候你需要知道每个特征参数应该往哪个方向调、调整多少才能让预测更准正向传播就是“用当前参数算预测值”反向传播就是“根据误差反推每个参数的修正方向”。误差大的特征参数多调一些误差小的少调一些如此反复迭代预测值越来越接近真实值。这个类比虽然简化了很多数学细节但抓住了反向传播的本质把最终的误差“分摊”回每一个参数头上明确每个参数该往哪里走。2. PyTorch中反向传播的实现机制2.1 Tensor是核心载体PyTorch中反向传播的基础是Tensor张量对象。Tensor不仅存储数据本身还额外存储两个关键信息requires_grad是否需要对这个Tensor计算梯度grad计算出来的梯度值当你设置某个Tensor的requires_gradTrue时PyTorch就会在计算过程中自动构建一个计算图computational graph记录每一次运算的输入、输出和运算关系。一条关键的规则只有叶子张量即由用户直接创建的Tensor不是运算结果的在反向传播后能直接通过.grad访问到梯度。非叶子张量的梯度默认不被保留这既是为了节省内存也是因为实际更新参数时只需要叶子节点的梯度。这里需要区别一下两个重要属性属性含义示例data存储的具体数值tensor([[1., 2.]])grad_fn生成这个Tensor的计算函数AddBackward0、MulBackward0每次执行运算PyTorch都会记录一个grad_fn表示这个Tensor是怎么来的。反向传播时就是沿着这些grad_fn从后往前逐层回溯计算出每个参数对应的梯度。2.2 计算图的构建与释放PyTorch采用动态计算图机制也就是每次前向传播都会重新构建计算图而不是像静态图那样预先定义好整个网络结构。这样的好处是灵活性高模型的输入输出形状甚至结构都可以动态改变非常适合研究与调试场景。但动态图也带来一个特性默认情况下每次调用backward()之后计算图就会被自动释放以节省内存。这意味着如果你在同一个计算图上想多次反传必须设置retain_graphTrue否则第二次调用会报错。在实际训练中我们通常不需要保留计算图因为每轮迭代都会重新做一次前向传播、重新构建计算图。只有在某些特殊场景比如需要在一个图上多次计算梯度或者想做梯度累积才需要手动保留。2.3 backward()内部发生了什么调用loss.backward()的那一刻PyTorch会从loss这个张量出发利用链式法则沿计算图逐层回溯将梯度传播到所有叶子节点上。具体过程可以这样理解对于计算图上的每个节点PyTorch会根据grad_fn知道它对应的局部导数比如加法操作的局部导数是1乘法操作的局部导数是另一个操作数然后结合从后往前传来的梯度利用链式法则计算出该节点的梯度继续向前传播。这个过程高度自动化理论上你完全不需要手动计算任何梯度。但如果不理解其中原理遇到梯度为0、梯度消失、梯度爆炸这类问题时就会一头雾水。所以我个人建议即使是调包侠也值得把核心机制的源码层面逻辑理一遍。3. 反向传播代码的完整实现过程3.1 实验环境说明这一节的代码我全程在以下环境中运行Python 3.9.18 PyTorch 2.0.1cpu不需要GPU纯CPU就可以跑通因为样例数据量非常小。3.2 第一步准备数据课程中最经典的例子是线性回归模型拟合一条直线。假设自变量x和因变量y之间满足某种线性关系我们只有少量的已知数据点需要通过这些数据点反推出线性函数的权重w。创建输入数据和标签数据注意两点数据类型必须是浮点型且需要是Tensor。import torch # 输入数据 x_data torch.tensor([[1.0], [2.0], [3.0]]) # 标签数据 y_data torch.tensor([[2.0], [4.0], [6.0]])这才三条数据训练集非常小但足以演示完整的训练流程。x对应的y值是x的两倍因此理论上最优模型就是y2x权重w最理想的取值是2.0。3.3 第二步定义线性模型这里用的是继承torch.nn.Module的方式这是PyTorch中最标准、最通用的模型定义方式。模型的核心思想是前向传播函数forward()定义输出如何由输入计算而来PyTorch的nn.Module会自动构造对应的反向传播路径。class LinearModel(torch.nn.Module): def __init__(self): super(LinearModel, self).__init__() # 创建权重参数初始值为1.0开启梯度追踪 self.w torch.nn.Parameter(torch.tensor([[1.0]], requires_gradTrue)) def forward(self, x): # 定义前向计算过程y x * w return x.mm(self.w)torch.nn.Parameter是一个特殊的Tensor它默认requires_gradTrue并且在model.parameters()中自动注册。这意味着优化器可以直接通过model.parameters()获取所有需要更新的参数非常方便。这里用到的是x.mm(self.w)也就是矩阵乘法。虽然这条数据是一维乘一维但为了和课程保持一致我用的是矩阵乘法的形式便于后续扩展成多特征或多输出的场景。3.4 第三步定义损失函数和优化器对于回归问题最常用的损失函数是均方误差MSE也就是预测值和真实值之差的平方的平均值。PyTorch里已经封装好了criterion torch.nn.MSELoss(size_averageTrue)这个size_averageTrue表示对每个样本的误差取平均而不是求和。取平均的好处是损失值的规模不会随着样本数量的增加而变大便于控制学习率。优化器选择随机梯度下降SGD传入模型的所有参数和学习率optimizer torch.optim.SGD(model.parameters(), lr0.01)学习率lr0.01看起来不大但对于这个只有3条数据的简单线性模型来说已经足够。学习率太大会导致参数来回震荡甚至发散太小则收敛太慢。3.5 第四步训练循环训练循环是整段代码的核心每一轮迭代都遵循一个固定的流程前向传播、计算损失、梯度清零、反向传播、更新参数。我先把完整代码贴出来再逐行解释每个步骤的作用。for epoch in range(100): # 1. 前向传播计算预测值 y_pred model(x_data) # 2. 计算损失 loss criterion(y_pred, y_data) # 打印损失值注意用.item()而不是直接打印loss print(fEpoch [{epoch 1}/100], Loss: {loss.item()}) # 3. 梯度清零 optimizer.zero_grad() # 4. 反向传播计算梯度 loss.backward() # 5. 参数更新 optimizer.step()这个流程我必须强调一下每一步都有它存在的理由前向传播把输入数据x_data传入模型得到预测值y_pred。计算损失用均方误差比较预测值和真实值的差距得到一个标量损失值。梯度清零这一步很多人会忽略但它非常重要。PyTorch的反向传播会累加梯度也就是说如果不清零上一次迭代的梯度会叠加到这一次的梯度上导致参数更新方向错误。必须在每次反向传播之前调用optimizer.zero_grad()或model.zero_grad()。反向传播loss.backward()就是这节课的核心。通过链式法则自动计算出每个参数的梯度并把梯度存放在参数张量的.grad属性中。参数更新optimizer.step()让优化器按照预设的规则这里是SGD更新所有参数的值。100轮迭代之后把模型权重打印出来可以看到它已经非常接近2.0了。3.6 关键参数的计算过程拆解虽然PyTorch自动完成了所有梯度计算但我还是想把每一步数学推导的过程展示出来这样你能真正理解这些代码在做什么。当前权重初始值是1.0第一条数据的预测值就是y_pred 1.0 × 1.0 1.0真实值是2.0损失函数MSE对单条数据的损失是loss (y_pred - y_true)² / 2 (1.0 - 2.0)² / 2 0.5然后反向传播计算损失对权重w的梯度。根据链式法则∂loss/∂w (∂loss/∂y_pred) × (∂y_pred/∂w)其中∂loss/∂y_pred (y_pred - y_true) (1.0 - 2.0) -1.0 ∂y_pred/∂w x 1.0所以第一条数据的梯度就是-1.0。三条数据分别计算并取平均因为用了size_averageTrue得到整体梯度。优化器SGD更新权重的公式是w_new w_old - learning_rate × gradient代入数值就是w_new 1.0 - 0.01 × (-1.0) 1.01这才完成了一轮训练。每轮训练后权重都会朝着减小损失的方向移动一点点经过100轮迭代权重会越来越接近2.0。整个推导过程再次说明了一个核心结论反向传播就是链式法则的系统化应用把从输出到输入每一层局部的导数乘起来得到全局的梯度。4. 常见问题与排查技巧实录4.1 梯度累加问题如果你在训练时发现损失曲线在波动或者模型根本无法收敛首先要检查的就是是否做了梯度清零。我在刚开始写代码的时候经常看到有人在循环里忘了写optimizer.zero_grad()直接导致同样的梯度一直累加参数越更新越离谱。排查方法很简单在backward()之后、step()之前打印一下model.w.grad的值看看梯度是否只来自于当前batch。如果数值明显异常偏大基本就是梯度累加了。4.2 loss.item()与直接用print(loss)的区别还有一个很常见的问题是为什么打印损失函数的时候要用loss.item()而不是直接print(loss)或print(loss.item())的输出一样吗直接打印loss会得到一个Tensor对象包含类似tensor(0.5000, grad_fnMseLossBackward0)的输出其中记录了梯度函数信息会占用较多内存。而loss.item()只返回一个普通的Python浮点数不参与计算图打印更干净、也不影响性能。更重要的原因是如果你在训练循环中保留了Tensor类型的loss并且后续不再使用计算图可能无法及时释放累积起来会造成内存泄漏。所以记一个经验任何不需要梯度信息的标量都建议用.item()取出来再使用。4.3 计算图释放导致的双重反向传播报错当一个场景需要在一个计算图上调用两次backward()时会直接得到类似“Trying to backward through the graph a second time”的报错。这就是因为默认情况下第一次反向传播后计算图已经被释放了。解决办法有两种# 方法1在第一次backward时保留计算图 loss.backward(retain_graphTrue) # 方法2将计算图保存下来需要时再反传但在常规训练场景中我不建议保留计算图因为它占用的内存非常大。常规做法就是每轮重新前向传播、重新构建计算图自然就不存在这个问题。4.4 非叶子节点拿不到梯度有一次我想查看中间层的梯度结果发现某些Tensor的.grad是None怎么都想不明白。后来才意识到PyTorch默认只保存叶子节点的梯度非叶子节点的梯度需要额外设置backward(retain_graphTrue)或者注册hook才能获取。这一点在调试复杂网络时非常重要。如果你需要查看中间层的梯度变化比如说怀疑梯度消失最简单的方案是在需要查看的中间层输出上注册一个register_hook把中间层的梯度打印出来。4.5 学习率选择对收敛的影响学习率是训练中需要反复调试的关键超参数。过大的学习率会导致权重来回震荡甚至越过最优解过小又会收敛极慢。在我这个实验里学习率取0.01100轮迭代基本收敛。如果换成0.1你可能会看到损失在最初的几轮迭代后迅速下降但之后开始剧烈波动。如果想进一步验证可以试试把学习率调到0.5损失几乎肯定会不降反升。调试学习率时一个实用技巧先用一个较大的学习率快速观察损失变化趋势如果损失在最开始的几次迭代中大幅下降说明当前学习率可接受然后再逐步调小找到稳定收敛的值。4.6 常见报错速查表我把这个实验过程中比较常见的报错信息整理成了一张表方便大家遇到问题时快速定位报错信息原因解决办法RuntimeError: element 0 of tensors does not require grad...输入数据未设置requires_grad或参数未正确设置确认需要更新的参数是Parameter类型或requires_gradTrueRuntimeError: Trying to backward through the graph a second time在同一计算图上调用了多次backward设置retain_graphTrue或者重新进行前向传播TypeError: unsupported operand type(s) for *: float and Tensor数据类型不匹配可能是Python标量与Tensor混用确认所有参与运算的变量都是Tensor类型RuntimeError: expected scalar type Float but found Double数据类型不一致常见是float和double混用统一数据类型加上.float()或.double()AttributeError: NoneType object has no attribute data访问了未初始化的参数或层级检查模型定义是否正确初始化了所有参数5. 反向传播在复杂网络中的延伸5.1 从单层线性模型到多层网络课程里的线性模型只是最简单的情况但它展示的反向传播思想可以直接推广到任意深度。在一个多层神经网络中每一层相当于一个函数的复合。前向传播时数据依次经过每一层的线性变换和非线性激活函数反向传播时梯度也从输出层开始一层一层地传回去。这就是“反向传播”这个名字的直观含义误差信号从输出端反向流动每一层根据下游传来的梯度计算出自己这一层参数的梯度继续向上游传播。5.2 张量运算中的各种局部导数在实际网络中反向传播过程中会遇到很多种类型的局部导数。比如矩阵乘法层梯度的计算涉及转置操作这是维度匹配的关键卷积层梯度通过卷积核的翻转操作进行传播池化层最大池化的梯度只会传播到一个位置最大值所在位置平均池化则均分给所有位置激活函数层Sigmoid的梯度是σ(x)(1-σ(x))ReLU的梯度是0或1PyTorch把这些局部导数全部封装好了但你理解它们长什么样、有什么特性对调试网络非常有帮助。比如Sigmoid在输入绝对值较大时梯度接近0这就是梯度消失的一个来源。这些知识全都在反向传播的基本原理之上值得花时间掌握。6. 实操心得与经验总结整个实验做下来我最大的体会是反向传播这个概念听起来很难但真正动手跑一遍代码、把每一行都拆开来看之后它的核心逻辑其实非常清晰。就是四步前向传播算损失梯度清零防累加反向传播算梯度优化器更新参数。后面学的所有深度学习内容都离不开这个四步框架。再补充一个我在实际写代码时发现的小技巧在训练过程中隔一定轮数打印一次损失和权重值。比如每10轮打印一次可以直观观察权重是如何从初始值逐渐逼近理想值的。别小看这个习惯它能帮你快速判断训练是否正常、有没有发散或振荡。最后还有一个建议如果你真的想把反向传播理解透可以在PyTorch自动求导的基础上手动推导一遍这个线性回归案例的梯度计算过程然后自己写一段代码把梯度值和model.w.grad打印出来的数值对比。两边对得上你就真正掌握这一节的核心内容了。