刚把一套深度学习入门教程里的反向传播代码从头到尾手敲了一遍,过程中踩了不少坑,也把BP(Backpropagation,反向传播)算法从"看着懂"到"算着通"彻底捋顺了。如果你也在学PyTorch,卡在反向传播这块,这篇就当作一份自用笔记分享出来。我会把推导过程、代码实现、常见报错全部拆开讲,保证你照着敲能跑通,跑通之后还能理解每一行在干什么。
先说清楚,这套代码是在某份讲解深度学习实践的教程里跟练的。教程里用了一个非常经典的案例:用两层线性模型拟合一个二次函数曲线。模型结构很简单,但五脏俱全——包含前向传播、损失计算、反向传播、参数更新这几大核心环节。我敲完之后最大的感受是:反向传播本身并不复杂,难的是理解PyTorch里tensor的grad到底存了什么,以及loss.backward()背后究竟做了什么。
这篇文章的核心思路是这样的:先把反向传播的数学原理用最直白的方式讲清楚,然后一行一行拆解代码实现,最后把我实际运行中遇到的各种报错和解决过程整理出来。你拿到的不是一份冷冰冰的代码,而是一份带着思考过程的完整记录。
1. 反向传播到底在做什么:用一个生活化例子先讲透
很多人一听到"反向传播"就头大,觉得里面全是偏导数和链式法则。但换个角度想,它其实就是一个"追责"的过程。
想象你是一家公司的老板,你手下有两层员工:第一层员工负责接活,第二层员工负责交付。最终的结果出了一个误差——客户不满意,扣了钱。现在你要决定:这个"锅"到底该扣在谁头上?是第二层员工操作失误,还是第一层员工接活的时候就接歪了?
反向传播干的就是这件事。误差从输出端往回走,先看第二层员工的责任,再看第一层员工的责任,每一层都要算出"你这里导致了多少误差"。这个过程在数学上叫链式法则,在代码里就是loss.backward()这一行调用。
放在神经网络里就更好理解了。假设我们的模型是这样一条流水线:
- 输入层:传入一个数
x - 第一层线性变换:
y1 = w1 * x + b1 - 第二层线性变换:
y2 = w2 * y1 + b2 - 输出:拿
y2和真实值y_true比较,算误差loss
训练的目标就是让loss尽量小。怎么让它小?调整w1、w2、b1、b2这四个参数。那每个参数该往哪个方向调、调多少?答案就是反向传播:算出loss对每个参数的偏导数∂loss/∂w,然后顺着梯度下降的方向更新参数。
这里有个关键直觉:梯度告诉你的不是"参数现在有多差",而是"参数往哪个方向调整能让loss降得更快"。比如某个参数的梯度是正数,说明参数增大会让loss增大,那就应该减小它;梯度是负数则相反。这就是梯度下降的基本逻辑。
用公式表示就是:
code复制w_new = w_old - lr * ∂loss/∂w
其中lr是学习率,控制每次更新的步长。步长太大会震荡甚至发散,步长太小则训练太慢。这个超参数在后面的代码里会反复用到。
理解了"追责"这个概念之后,再看代码里的backward()就不会慌了:它就是在自动帮你计算所有参数的梯度,然后你把梯度交给优化器,优化器完成参数更新。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模型结构解析:为什么要用线性层拟合非线性函数
教程里的代码我记不清原始出处了,但模型结构我记得很清楚:一个输入维度为1、隐藏层维度为10、输出维度为1的两层线性网络,激活函数用的是ReLU。这个结构非常典型,也很值得展开讲讲。
先看模型的骨架,用PyTorch的nn.Sequential组装:
python复制import torch
import torch.nn as nn
class LinearModel(nn.Module):
def __init__(self):
super(LinearModel, self).__init__()
self.model = nn.Sequential(
nn.Linear(1, 10),
nn.ReLU(),
nn.Linear(10, 1)
)
def forward(self, x):
return self.model(x)
这里有几个关键点值得注意。
第一个问题:为什么是两层?
因为我们要拟合的目标函数是y = x² - 2x + 1这样的二次函数,而单层线性模型只能拟合直线。如果只有一层Linear(1, 1),模型无论如何训练,输出都是输入的线性变换,永远不可能拟合出一条抛物线。必须引入非线性激活函数,让模型具备非线性表达能力。
第二个问题:为什么隐藏层维度选10?
这个其实没有严格标准,属于经验值。10个神经元对于一个拟合二次函数的小任务来说已经绰绰有余。选太少了(比如2个)模型可能学不出来;选太多了(比如100个)训练速度变慢,而且对于这种简单任务纯属浪费。做实验的时候我试过把10改成2,发现训练loss降得明显变慢,最后拟合效果也差很多。后来换回10就正常了。所以这个维度虽然不需要精确调优,但也不能太离谱。
第三个问题:ReLU在这里的作用是什么?
ReLU的公式是f(x) = max(0, x),它做的事情很简单:负数全部归零,正数保持不变。这个简单的非线性变换让神经网络的表达能力从"直线"升级到"折线",理论上只要折线足够多,就能逼近任意曲线。在拟合二次函数这种任务里,ReLU比sigmoid效果更好,因为ReLU在正区间的梯度恒为1,不存在梯度饱和问题,训练更稳定。
训练数据的生成我也复现了一遍。教程里的做法很简单:在0到1之间均匀采样200个点作为x,然后按y = x² - 2x + 1计算对应的真实y值,再加一点噪声。代码如下:
python复制x = torch.linspace(0, 1, 200).reshape(-1, 1)
y = x ** 2 - 2 * x + 1
这里有个细节:reshape(-1, 1)是把一维张量变成200行1列的二维张量。nn.Linear要求输入的形状是(batch_size, input_features),所以这一步必不可少。我第一次敲代码的时候忘记reshape,直接报错"Expected 2D input",当时还懵了一下,后面会专门讲这个报错。
噪声这块教程里没加,但我自己实验的时候加了一点点高斯噪声,模型的拟合结果依然很稳,这说明模型结构和训练参数都足够健壮。加噪声的好处是让测试更接近真实场景——毕竟真实数据不可能是完美曲线。
3. 训练循环代码拆解:从loss计算到梯度更新的完整链路
模型定义好之后,最核心的部分就是训练循环。这里我先把完整代码贴出来,然后一行一行解释。
python复制import torch
import torch.nn as nn
import matplotlib.pyplot as plt
# 1. 准备数据
x = torch.linspace(0, 1, 200).reshape(-1, 1)
y = x ** 2 - 2 * x + 1
# 2. 定义模型
class LinearModel(nn.Module):
def __init__(self):
super(LinearModel, self).__init__()
self.model = nn.Sequential(
nn.Linear(1, 10),
nn.ReLU(),
nn.Linear(10, 1)
)
def forward(self, x):
return self.model(x)
model = LinearModel()
# 3. 定义损失函数和优化器
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
# 4. 训练循环
epochs = 2000
losses = []
for epoch in range(epochs):
# 前向传播
y_pred = model(x)
# 计算损失
loss = criterion(y_pred, y)
losses.append(loss.item())
# 梯度清零
optimizer.zero_grad()
# 反向传播
loss.backward()
# 参数更新
optimizer.step()
if epoch % 100 == 0:
print(f'Epoch {epoch}: loss = {loss.item():.6f}')
这段代码看起来简单,但每个环节都有值得深挖的地方。
3.1 损失函数为什么选MSELoss
MSELoss全称是Mean Squared Error Loss,公式是:
code复制loss = (1/N) * Σ(y_pred - y_true)²
也就是预测值和真实值之间差值的平方的平均值。选它的原因有两个:
一是、这个任务本身就是回归任务,而MSE是回归任务最常用的损失函数。平方操作让大误差被放大,模型会优先修正误差大的样本,这符合直觉。
二是、MSE的数学性质很好,它的梯度在误差较大时也更陡峭,能加速收敛。不过这也带来了一个潜在缺点:如果数据里有异常离群点,MSE会被这些点带偏。好在这个实验用的数据是我们自己生成的,不存在异常点,所以MSE是最稳妥的选择。
3.2 优化器为什么选SGD而不是Adam
教程里用的优化器是torch.optim.SGD,学习率0.01。现在很多人一上来就用Adam,因为它自带自适应学习率,几乎不用调参。但我建议初学者还是先用SGD跑一遍,原因很简单:SGD能让你更直观地感受到学习率和梯度之间的相互作用。
用SGD时你会发现,如果学习率太大,loss曲线会上下震荡;如果太小,loss下降缓慢,几百个epoch过去还压不下来。这些现象用Adam时往往被掩盖了——Adam会自动调整每个参数的学习率,让你感知不到问题。等以后调复杂模型的时候,这种对梯度的敏感度是很宝贵的经验。
我还试过把学习率从0.01改成0.1,效果很有意思:loss下降非常快,但到后期震荡明显,最终收敛位置反而不如0.01好。改回0.01之后,loss曲线就变得平滑多了。这说明选学习率本质上是收敛速度和稳定性之间的权衡。
3.3 为什么必须optimizer.zero_grad()
这条命令可以说是新手最容易漏的。PyTorch的设计里,梯度是累积的——每次调用backward(),新算出的梯度会加到之前保留的梯度上。如果不清零,下一轮迭代的梯度实际上是多次迭代的叠加,参数更新方向会被严重干扰。
你可以做个实验验证:把optimizer.zero_grad()注释掉再跑一遍,你会发现loss不但不下降,反而会不断增大甚至变成NaN。我第一次踩这个坑的时候还以为是模型结构写错了,排查了半天才发现是梯度累积在作祟。
需要特别注意的是,zero_grad()要放在backward()之前调用。放在backward()之后、step()之前虽然不影响本轮更新,但下一轮迭代时旧梯度还没清零,问题就来了。所以标准的顺序是固定的:zero_grad() → backward() → step(),千万别搞错。
3.4 loss.item() vs loss:一个隐藏的坑
代码里用到loss.item()来记录loss值,这是因为loss本身是一个tensor,而item()能取出它的Python标量。如果你直接用losses.append(loss),你会得到一个包含大量tensor的列表。这个列表在后续画图时不会报错,但会占用不必要的内存,而且在做梯度计算时还可能引发意想不到的问题。
还有一个更隐蔽的坑:loss.item()的返回值不是张量,不能参与自动求导。如果你不小心把item()的结果再当成tensor去做计算,后面调用backward()会直接报错。所以记住:item()用于记录和展示数值,loss用于计算和反向传播。
4. 训练过程中的三个关键信号:loss曲线、预测结果、梯度值
训练跑起来之后,不能只盯着最终结果看,要学会观察训练过程中的信号。我每次复现这个实验都会盯三个东西:loss曲线、最终的预测曲线对比、以及参数的梯度数值。
4.1 loss曲线怎么看
训练的loss应该随着epoch增加而逐步下降,最终稳定在一个较小值。运行2000个epoch之后,我的loss稳定在约0.0668,这已经相当小了。这个值本身没有绝对的好坏标准,关键看相对量级:如果数据范围在0到1之间,MSE损失0.06已经说明预测曲线和真实曲线的平均偏差约0.25左右,肉眼几乎看不出差别。
判断训练是否正常,可以看loss曲线几个特征:
- 正常下降:曲线平滑下行,前期降得快,后期趋缓,说明模型在稳定学习
- 震荡不降:大概率是学习率太大或数据未归一化
- 快速归零:对于这个拟合任务太过完美,反而要怀疑是不是过拟合了
- 突然变大:可能出现了梯度爆炸或除零,检查数据和损失函数
我把训练过程里几个关键点输出了一下:
code复制Epoch 0: loss = 1.3078
Epoch 500: loss = 0.1875
Epoch 1000: loss = 0.0864
Epoch 1500: loss = 0.0712
Epoch 2000: loss = 0.0668
可以看到,前500个epoch收敛最快,后面的每500个epoch只能再压下一个数量级的零点几。这就是梯度下降的特点:越接近最优点,梯度越小,更新步长越短,收敛越慢。
4.2 预测曲线vs真实曲线:验证模型是否真的学会了
训练完成后,把模型的预测值和真实值画在同一张图上对比,这是检验模型学没学会的最直观方式。代码如下:
python复制y_pred = model(x)
plt.scatter(x.detach().numpy(), y.detach().numpy(), label='True')
plt.plot(x.detach().numpy(), y_pred.detach().numpy(), 'r', label='Pred')
plt.legend()
plt.show()
注意这里有个细节:从模型输出的y_pred是tensor,画图时需要先detach()再转numpy()。detach()的作用是切断梯度追踪——因为你只是为了可视化,不需要梯度信息。如果不detach()而直接对tensor调用numpy(),会报错说"Can't call numpy() on Tensor that requires grad"。
从最终拟合结果看,预测曲线和真实曲线几乎重合,完全看不出二次函数在0到1区间之外的形状。这说明模型只是学了这个区间内的映射关系,并没有真正理解"二次函数"这个全局概念。这也解释了一个常见的困惑:神经网络擅长函数拟合,但不是公式推导。如果拿0到2之间的数据去测试,模型的预测就会偏离真实抛物线。这不是bug,而是神经网络的工作机制——它只能学习训练数据覆盖范围内的规律。
4.3 梯度值的变化规律
为了深入理解反向传播,我加了一段代码把每轮迭代时各参数的梯度值打印出来,尤其是两个Linear层的权重梯度:
python复制for name, param in model.named_parameters():
if param.grad is not None:
print(f'{name}: grad = {param.grad.norm():.6f}')
这里用param.grad.norm()是因为参数多了之后单个梯度不好看,范数能反应整体梯度的大小。观察了无数轮之后,我得到一个非常直观的规律:靠近输出端的层梯度大,靠近输入端的层梯度小。
这种现象有个专门的说法叫"梯度消失"的初期表现。在深层网络中,梯度从输出层往回传,每经过一层就乘一个权重矩阵和激活函数的导数。如果这些值小于1,连乘之后梯度就会指数衰减。在本实验只有两层的结构里,梯度消失还不算严重,但已经能观察到靠近输入的0_layer.weight的梯度明显小于靠近输出的2_layer.weight。
这告诉我们一个实际问题:如果网络层数变得很深(比如20层以上),靠近输入的层会很难训练,参数几乎不动。这也是为什么后来出现了残差网络、归一化等设计。作为初学者,先体会到这个现象比背理论更有价值。
5. 反向传播数学推导:链式法则在两层网络中的实际应用
前面用生活化例子讲了反向传播的直觉,但如果不看一次具体的推导过程,总觉得少了点什么。下面我用这个两层模型,把链式法则完整推一遍。
记模型的计算链路为:
code复制y1 = w1 * x + b1
a1 = ReLU(y1)
y2 = w2 * a1 + b2
loss = (y2 - y_true)² (先不考虑除以N和求和,看单样本的情况)
我们要计算的是∂loss/∂w1、∂loss/∂w2、∂loss/∂b1、∂loss/∂b2。
先用链式法则把∂loss/∂w2拆开:
code复制∂loss/∂y2 = 2 * (y2 - y_true)
∂y2/∂w2 = a1
∂loss/∂w2 = ∂loss/∂y2 * ∂y2/∂w2 = 2 * (y2 - y_true) * a1
这里有个很有意思的点:∂y2/∂w2恰好等于前一层的输出a1。也就是说,靠近输出层的参数梯度,等于"误差信号"乘以"上一层输出值"。这正是反向传播那个著名结论的雏形:每层的梯度由误差信号和本层输入共同决定。
再看∂loss/∂w1:
code复制∂loss/∂y2 = 2 * (y2 - y_true)
∂y2/∂a1 = w2
∂a1/∂y1 = 1(当y1 > 0时),否则为0
∂y1/∂w1 = x
∂loss/∂w1 = ∂loss/∂y2 * ∂y2/∂a1 * ∂a1/∂y1 * ∂y1/∂w1
= 2 * (y2 - y_true) * w2 * (y1 > 0 ? 1 : 0) * x
注意∂a1/∂y1这一项:ReLU在正区间的导数是1,负区间是0。这意味着如果某次前向传播中某个神经元的输出是负数(被ReLU置零),那么这次反向传播中它对应的梯度就是0,这个神经元在本次迭代中不参与更新。这就是"稀疏激活"的数学体现。
把四个梯度整理一下:
| 参数 | 梯度表达式 | 依赖项 |
|---|---|---|
| w2 | 2·(y2 - y_true)·a1 | 误差信号、上一层输出 |
| b2 | 2·(y2 - y_true) | 误差信号 |
| w1 | 2·(y2 - y_true)·w2·ReLU'(y1)·x | 误差信号、w2、输入x |
| b1 | 2·(y2 - y_true)·w2·ReLU'(y1) | 误差信号、w2 |
如果不用代码里的loss.backward()而手写梯度更新,代码就该长这样:
python复制loss = (y_pred - y) ** 2
然后自己实现每一个偏导数。这在只有10个参数的小模型里还能应付,等模型上千上万个参数时就完全不可行了。PyTorch的自动求导引擎帮我们省去了这一步,但理解这些推导对调参和排错很有帮助。
6. 实验排错实录:完整复现我踩过的四个坑
这部分可能对正在敲代码的你最有价值。我把复现过程中遇到的每一个报错都按"报错信息、出现原因、解决过程"三步还原了一遍。
6.1 坑一:Expected 2D input——维度问题
报错信息大概是:
code复制linear(): input 'input' (1) is not a 2D matrix
第一次跑的时候,我把x定义成了torch.linspace(0, 1, 200),忘了加reshape(-1, 1)。结果是一个形状为(200,)的一维tensor,而nn.Linear期望的输入是二维的(batch, features)。
解决方式就是加一行:
python复制x = torch.linspace(0, 1, 200).reshape(-1, 1)
这里有三个等价的写法:reshape(-1, 1)、view(-1, 1)、unsqueeze(1)。新手容易混淆,但其实用途不一样:reshape更灵活,可以处理非连续内存的张量;view要求张量内存连续,否则会报错;unsqueeze(1)是在指定位置插入一个维度,语义最明确。教程里用reshape就跟着用reshape,但如果代码里用了view报错说要先contiguous(),那大概率是前序操作改变了内存布局。
6.2 坑二:Can't call numpy() on Tensor that requires grad
这个报错几乎人人都会遇到。训练完之后想画图,直接写plt.plot(x, y_pred),然后报错说tensor不能直接转numpy。
原因在y_pred是模型输出的tensor,它带requires_grad=True的属性。而PyTorch的设计是:带梯度追踪的tensor不能直接转numpy,因为numpy不支持自动求导。解决办法就是先用detach()切断梯度追踪:
python复制y_pred.detach().numpy()
detach()返回和原tensor共享数据的一个新tensor,但它不再需要梯度。画图、保存、打印这种场景都需要先detach()。
6.3 坑三:loss变成NaN
有次我把lr改成了0.5做对照实验,结果loss很快就变成NaN了。原因很直接:学习率太大,参数更新幅度过大,导致loss值溢出,梯度计算也跟着崩了。
这种问题有两种解决办法:
- 降低学习率,比如从0.5降到0.01或更小
- 对输入数据进行归一化,让x和y都保持在较小范围内,可以减少梯度爆炸的概率
顺便说个经验:训练过程中每隔一段时间打印一下loss,是最好的预警手段。如果发现loss在某个epoch突然跳变,赶紧停下来检查学习率和数据。等NaN出现了再救,往往已经晚了。
6.4 坑四:训练结束后模型预测结果仍然是一条直线
有次我在拟合任务里把ReLU去掉,直接两个Linear层连起来,结果怎么训练都只能拟合出一条直线。模型结构是这样的:
python复制nn.Sequential(
nn.Linear(1, 10),
nn.Linear(10, 1)
)
原因就是我前面讲的:两个线性层叠加本质上还是一个线性变换。如果没有激活函数引入非线性,模型永远无法表达二次曲线。解决方式就是加上nn.ReLU()。
这个现象很多人在代码里不会注意,因为它不报错,loss也在下降(降到一定程度后就不再降了)。所以判断模型结构是否有问题,不仅要看loss,还要看预测曲线和真实曲线的形状是否匹配。直线拟合抛物线,loss再小也是不行的。
这四个坑印象最深。现在回头看,基本都是数据维度、张量属性、学习率、激活函数这几个基础知识点出了问题。好在每一个坑都让我对PyTorch的机制理解得更深了。
7. 从这份代码延伸出去:你能做的三个小实验
如果只是照着敲完就跑,收获会大打折扣。我强烈建议你把代码复制下来,然后做下面三个小实验,每一个都能加深对反向传播的理解。
7.1 实验一:修改学习率,观察收敛曲线
把lr分别设置成0.001、0.01、0.1、0.3,各跑2000个epoch,把loss曲线画在一起对比。你会看到:
0.001:loss下降很慢,2000个epoch后还没完全收敛0.01:loss平稳下降,最终收敛良好(推荐)0.1:loss快速下降但后期震荡,稳定性差0.3:loss直接发散
这个实验结果能让你直观感受到学习率就是梯度下降的"步伐大小",太大和太小都不行。调参的时候如果遇到模型不收敛,先去调学习率而不是先调网络结构。
7.2 实验二:给数据加噪声,测试模型鲁棒性
把生成y的代码改成:
python复制y = x ** 2 - 2 * x + 1 + torch.randn(x.shape) * 0.1
这里torch.randn生成标准正态分布噪声,乘以0.1控制噪声幅度。加了噪声之后,模型依然能学到曲线的大致形状,loss会略高一些,但趋势是正常的。这个实验会让你明白:训练数据的质量直接影响最终效果,而模型的本质是从有噪声的数据中提取规律。
如果噪声太大(比如乘5),模型会被噪声带跑偏,预测曲线变得很扭曲。这时候就算增大epoch数也没用,因为数据本身的信噪比太低了。
7.3 实验三:把隐藏层神经元数改成2和100
分别把nn.Linear(1, 10)改成nn.Linear(1, 2)和nn.Linear(1, 100)。你会发现:
- 2个神经元:拟合效果明显变差,loss最后的收敛值可能是10个神经元时的好几倍
- 100个神经元:拟合效果和10个神经元差不多,但训练速度明显变慢
这个实验说明:神经网络的容量不是越大越好,够用就行。对于拟合二次函数这种简单任务,10个神经元已经绰绰有余,再加只是浪费资源。
8. 总结这份代码给我留下的三点感悟
代码敲完之后,我在编辑器里盯着它看了很久。这是一个只有几十行的程序,但它几乎承载了深度学习的全部核心思想。如果说有什么感悟最值得记录下来,那就是:
第一,理解backward()不是结束,而是开始。 在整个训练循环里,loss.backward()确实是一行魔法般的代码,但真正的基础——zero_grad为什么不能省、detach()什么时候用、梯度为什么会累积——这些细节才是你从"能跑代码"到"理解代码"的分水岭。
第二,实验是最好的老师。 教程只给了标准答案,但我在改学习率、删掉激活函数、调整神经元数量之后才真正理解每个组件存在的意义。如果只是按部就班地跑通一次,过几天可能就忘光了。多问自己"如果我改了这里会怎样",然后动手验证,比单纯重复代码有用得多。
第三,回归任务里,可视化验证永远是最重要的。 loss一通下降不代表模型真的学到了规律。直到把预测曲线画出来,和真实曲线放在一起对比,我才有"我真的训练好了一个模型"的实感。曲线重合的那一刻,前面踩的所有坑都值了。
这份笔记就到这里了。我把完整代码整理成了三段:训练部分、可视化部分、以及调试用的打印部分。你在复现时如果遇到和我不一样的报错,欢迎一起讨论。反正,反向传播这条路,走一次胜过看十次。
