
文章目录
-
- 一、训练循环里发生了什么
- [二、一个两层 MLP](#二、一个两层 MLP)
- [三、反向传播从 loss 开始](#三、反向传播从 loss 开始)
- [四、MSE Loss 的梯度](#四、MSE Loss 的梯度)
- 五、输出层参数梯度
- [六、ReLU 的梯度](#六、ReLU 的梯度)
- 七、第一层参数梯度
- 八、参数更新:梯度真正发挥作用
- [九、把前向和反向串成一个 NumPy 训练循环](#九、把前向和反向串成一个 NumPy 训练循环)
- [十、PyTorch autograd 验证](#十、PyTorch autograd 验证)
- 十一、计算图和链式法则
- 十二、为什么要清空梯度
- 十三、反向传播在大模型里有什么不同
- 十四、常见误区
- 十五、你应该记住的最小心智模型
- 总结
- 大模型视角
- 下一篇
摘要 :训练神经网络时,我们每天都在写
loss.backward(),但这行代码背后发生了什么?前向传播负责把输入一步步变成预测,损失函数衡量预测和目标的差距,反向传播用链式法则把误差信号从 loss 传回每个参数,优化器再根据梯度更新参数。本文用一个两层 MLP 作为例子,从矩阵形状、前向公式、MSE loss、ReLU 梯度、参数梯度一步步推导到 PyTorch autograd 验证。目标不是让你手写大模型反传,而是让你真正看懂训练循环里的每一步。
前置知识 :第 3 篇矩阵乘法,第 4 篇导数与链式法则,第 5 篇梯度下降,第 10 篇 Softmax
阅读时间 :约 70 分钟
代码环境:Python 3.10+,numpy >= 1.24,torch >= 2.0
入门导读:先抓住主线
神经网络训练可以压缩成四句话:
text
前向传播:用当前参数算预测
损失函数:衡量预测错多少
反向传播:计算每个参数该怎么改
优化器:根据梯度更新参数
对应到 PyTorch:
python
logits = model(x)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
optimizer.zero_grad()
很多初学者会把 backward() 当成黑盒。其实它的核心就是链式法则:如果 loss 依赖输出,输出依赖隐藏层,隐藏层依赖参数,那么 loss 对参数的影响可以沿着计算图一层层传回去。
读完先达到这个程度就够了:
- 能解释前向传播、loss、反向传播、参数更新的关系;
- 能看懂两层 MLP 的矩阵形状;
- 能手推 MSE loss 下输出层和隐藏层梯度;
- 能理解 ReLU 的梯度为什么是 0 或 1;
- 能知道 PyTorch autograd 如何自动做这些事;
- 能解释为什么每轮训练前后要
zero_grad()。
带着这 3 个问题读:
loss.backward()到底在计算什么?- 为什么梯度能从最后一层传回第一层?
- 为什么 PyTorch 默认会累积梯度,而不是自动清空?
一、训练循环里发生了什么
先看最小训练循环:
python
for x, y in dataloader:
pred = model(x)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
这几行代码对应一整套数学过程。
| 代码 | 数学含义 |
|---|---|
pred = model(x) |
前向传播,计算预测 |
loss = criterion(pred, y) |
计算预测与目标的差距 |
zero_grad() |
清空上一轮梯度 |
loss.backward() |
反向传播,计算参数梯度 |
optimizer.step() |
根据梯度更新参数 |
训练的目标不是让模型"记住代码",而是让参数沿着能降低 loss 的方向移动。
梯度就是这个方向的局部线索。
二、一个两层 MLP
我们用一个最小神经网络做推导。
输入:
text
X: [batch, input_dim]
第一层:
text
Z1 = X W1 + b1
A1 = ReLU(Z1)
第二层:
text
Y_hat = A1 W2 + b2
损失函数用均方误差 MSE:
text
Loss = mean((Y_hat - Y)^2)
形状如下:
| 符号 | 形状 | 含义 |
|---|---|---|
X |
[B, D_in] |
输入 batch |
W1 |
[D_in, H] |
第一层权重 |
b1 |
[H] |
第一层偏置 |
Z1 |
[B, H] |
第一层线性输出 |
A1 |
[B, H] |
ReLU 后隐藏表示 |
W2 |
[H, D_out] |
第二层权重 |
b2 |
[D_out] |
第二层偏置 |
Y_hat |
[B, D_out] |
预测 |
Y |
[B, D_out] |
目标 |
用 NumPy 写前向:
python
import numpy as np
np.random.seed(0)
B, D_in, H, D_out = 4, 3, 5, 2
X = np.random.randn(B, D_in)
Y = np.random.randn(B, D_out)
W1 = np.random.randn(D_in, H) * 0.1
b1 = np.zeros(H)
W2 = np.random.randn(H, D_out) * 0.1
b2 = np.zeros(D_out)
Z1 = X @ W1 + b1
A1 = np.maximum(0, Z1)
Y_hat = A1 @ W2 + b2
loss = np.mean((Y_hat - Y) ** 2)
print("Y_hat shape:", Y_hat.shape)
print("loss:", loss)
前向传播只是按照计算图从输入算到输出。
三、反向传播从 loss 开始
反向传播的目标是求:
text
dLoss/dW1, dLoss/db1, dLoss/dW2, dLoss/db2
也就是每个参数变化一点点,loss 会怎么变。
如果某个参数的梯度是正数,说明它增大可能让 loss 增大,梯度下降会让它减小。
如果某个参数的梯度是负数,说明它增大可能让 loss 减小,梯度下降会让它增大。
参数更新最简单写法是:
θ ← θ − η ∂ L ∂ θ \theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta} θ←θ−η∂θ∂L
其中 η \eta η 是学习率。
反向传播从最后的 loss 开始,一步步往前传:
text
Loss -> Y_hat -> W2, b2, A1 -> Z1 -> W1, b1, X
链式法则负责把这些局部导数组合起来。
四、MSE Loss 的梯度
我们定义:
L = 1 N ∑ ( Y ^ − Y ) 2 L = \frac{1}{N}\sum (\hat{Y} - Y)^2 L=N1∑(Y^−Y)2
其中 N N N 是所有输出元素数量。
对预测值求导:
∂ L ∂ Y ^ = 2 N ( Y ^ − Y ) \frac{\partial L}{\partial \hat{Y}} = \frac{2}{N}(\hat{Y} - Y) ∂Y^∂L=N2(Y^−Y)
代码:
python
N = Y_hat.size
dY_hat = 2.0 * (Y_hat - Y) / N
print(dY_hat.shape)
dY_hat 表示 loss 对每个预测元素的梯度。它是反向传播的起点。
如果预测值比目标大,梯度为正,更新会倾向于让预测变小。
如果预测值比目标小,梯度为负,更新会倾向于让预测变大。
五、输出层参数梯度
输出层公式是:
Y ^ = A 1 W 2 + b 2 \hat{Y} = A_1 W_2 + b_2 Y^=A1W2+b2
已知 dY_hat,要求 dW2、db2、dA1。
根据矩阵乘法反向规则:
text
dW2 = A1.T @ dY_hat
db2 = sum(dY_hat, axis=0)
dA1 = dY_hat @ W2.T
代码:
python
dW2 = A1.T @ dY_hat
db2 = dY_hat.sum(axis=0)
dA1 = dY_hat @ W2.T
print("dW2:", dW2.shape)
print("db2:", db2.shape)
print("dA1:", dA1.shape)
检查形状:
dW2和W2一样,是[H, D_out];db2和b2一样,是[D_out];dA1和A1一样,是[B, H]。
反向传播时,最重要的调试习惯就是检查梯度形状是否和对应变量一致。
六、ReLU 的梯度
隐藏层激活是:
A 1 = R e L U ( Z 1 ) = m a x ( 0 , Z 1 ) A_1 = ReLU(Z_1) = max(0, Z_1) A1=ReLU(Z1)=max(0,Z1)
ReLU 的导数很简单:
text
Z1 > 0 -> 梯度为 1
Z1 <= 0 -> 梯度为 0
所以:
python
dZ1 = dA1 * (Z1 > 0)
print(dZ1.shape)
这一步的意思是:只有前向时被 ReLU 放行的位置,反向时梯度才能通过。前向时被压成 0 的位置,反向梯度也会被截断。
这解释了 ReLU 的一个问题:如果大量神经元长期输出负值,它们可能很少得到梯度更新,出现所谓 dead ReLU。后面的激活函数篇会继续讲。
七、第一层参数梯度
第一层公式是:
Z 1 = X W 1 + b 1 Z_1 = XW_1 + b_1 Z1=XW1+b1
已知 dZ1,可以求:
text
dW1 = X.T @ dZ1
db1 = sum(dZ1, axis=0)
dX = dZ1 @ W1.T
代码:
python
dW1 = X.T @ dZ1
db1 = dZ1.sum(axis=0)
dX = dZ1 @ W1.T
print("dW1:", dW1.shape)
print("db1:", db1.shape)
print("dX:", dX.shape)
至此,所有参数梯度都算出来了:
text
dW1, db1, dW2, db2
这就是两层 MLP 的完整反向传播。
八、参数更新:梯度真正发挥作用
有了梯度后,用梯度下降更新参数:
python
lr = 0.1
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
这个更新的直觉是:沿着让 loss 增长最快的反方向走一小步。
学习率 lr 决定步子多大。
- 太大:可能越过最低点,甚至发散;
- 太小:收敛很慢;
- 合适:loss 稳定下降。
真实训练中,优化器会更复杂,比如 Momentum、Adam、AdamW,但它们本质上仍然依赖梯度。
九、把前向和反向串成一个 NumPy 训练循环
现在写一个完整的手动训练循环。
python
import numpy as np
np.random.seed(1)
B, D_in, H, D_out = 16, 4, 8, 2
X = np.random.randn(B, D_in)
Y = np.random.randn(B, D_out)
W1 = np.random.randn(D_in, H) * 0.1
b1 = np.zeros(H)
W2 = np.random.randn(H, D_out) * 0.1
b2 = np.zeros(D_out)
lr = 0.05
for step in range(200):
# forward
Z1 = X @ W1 + b1
A1 = np.maximum(0, Z1)
Y_hat = A1 @ W2 + b2
loss = np.mean((Y_hat - Y) ** 2)
# backward
dY_hat = 2.0 * (Y_hat - Y) / Y_hat.size
dW2 = A1.T @ dY_hat
db2 = dY_hat.sum(axis=0)
dA1 = dY_hat @ W2.T
dZ1 = dA1 * (Z1 > 0)
dW1 = X.T @ dZ1
db1 = dZ1.sum(axis=0)
# update
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
if step % 50 == 0:
print(step, loss)
这段代码没有 PyTorch,却完成了神经网络训练最核心的逻辑。
PyTorch 的 autograd 做的就是自动构建计算图、记录局部操作,然后按链式法则自动计算梯度。
十、PyTorch autograd 验证
用 PyTorch 写同样的模型,并和 autograd 对齐。
python
import torch
import torch.nn as nn
B, D_in, H, D_out = 4, 3, 5, 2
torch.manual_seed(0)
X = torch.randn(B, D_in)
Y = torch.randn(B, D_out)
model = nn.Sequential(
nn.Linear(D_in, H),
nn.ReLU(),
nn.Linear(H, D_out),
)
criterion = nn.MSELoss()
Y_hat = model(X)
loss = criterion(Y_hat, Y)
loss.backward()
for name, param in model.named_parameters():
print(name, param.shape, param.grad.shape)
你会看到每个可训练参数都有 .grad,形状和参数本身一致。
例如:
text
0.weight [H, D_in]
0.bias [H]
2.weight [D_out, H]
2.bias [D_out]
PyTorch 里 nn.Linear 的权重形状是 [out_features, in_features],和我们前面 NumPy 里写的 W1: [D_in, H] 是转置关系。这是常见细节,读源码时要注意。
十一、计算图和链式法则
Autograd 的核心是计算图。
前向传播时,每个操作都会记录自己如何由输入得到输出:
text
X, W1, b1 -> Z1 -> A1 -> Y_hat -> Loss
反向传播时,从 Loss 开始,每个节点根据自己的局部导数,把梯度传给前面的节点。
这就是链式法则的自动化。
如果你写:
python
x = torch.tensor(2.0, requires_grad=True)
y = x * x + 3 * x
y.backward()
print(x.grad)
数学上:
y = x 2 + 3 x y = x^2 + 3x y=x2+3x
d y d x = 2 x + 3 \frac{dy}{dx} = 2x + 3 dxdy=2x+3
当 x=2,梯度是 7。PyTorch 会自动算出这个结果。
十二、为什么要清空梯度
PyTorch 默认梯度会累积,而不是每次 backward() 自动覆盖。
python
x = torch.tensor(1.0, requires_grad=True)
for _ in range(3):
y = x * 2
y.backward()
print(x.grad)
你会看到梯度不断增加。
为什么这样设计?因为有些训练需要梯度累积。比如显存不够时,把一个大 batch 拆成多个小 batch,多次 backward 后再 step。
但普通训练中,如果不清空梯度,上一轮梯度会混进下一轮,更新就错了。
所以训练循环里通常写:
python
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
也有人把 zero_grad() 放在 step 后面,只要每次 backward 前梯度是干净的即可。
十三、反向传播在大模型里有什么不同
大模型里的反向传播原理没有变,仍然是链式法则。但工程复杂度高很多。
原因包括:
- 参数量巨大,梯度和优化器状态占用大量显存;
- Transformer 层数多,激活值显存很大;
- 混合精度可能引入数值问题;
- 分布式训练需要同步或分片梯度;
- 长序列导致 attention 激活很大;
- 梯度裁剪、checkpointing、ZeRO/FSDP 都会影响训练流程。
但无论多复杂,核心链条仍然是:
text
forward -> loss -> backward -> optimizer step
理解本篇后,你看到大模型训练脚本时,就能把复杂工程放回这条主线。
十四、常见误区
误区 1:反向传播是另一套独立算法,和前向没关系。
反向传播依赖前向计算图和中间值。没有前向记录,就无法按链式法则传梯度。
误区 2:梯度就是参数应该更新后的值。
梯度是 loss 对参数的局部变化率,不是新参数。优化器根据梯度计算更新量。
误区 3:backward() 会自动更新参数。
不会。backward() 只计算 .grad,真正更新发生在 optimizer.step()。
误区 4:PyTorch 会自动清空梯度。
不会。梯度默认累积,需要手动 zero_grad()。
误区 5:只要 loss 下降,梯度一定没问题。
不一定。可能存在部分层梯度为 0、梯度爆炸、NaN 被掩盖、学习率过小等问题。
误区 6:手推梯度没必要。
真实项目不用手写反传,但理解一次推导能帮助你排查 shape、loss、梯度和训练稳定性问题。
十五、你应该记住的最小心智模型
神经网络训练可以这样记:
text
Forward:
X -> Z1 -> A1 -> Y_hat -> Loss
Backward:
Loss -> dY_hat -> dW2/db2 -> dA1 -> dZ1 -> dW1/db1
Update:
W = W - lr * dW
PyTorch 对应:
python
pred = model(x)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
其中:
- 前向决定预测;
- loss 决定目标;
- backward 计算梯度;
- optimizer 使用梯度更新参数。
总结
前向传播把输入经过一层层计算变成预测,损失函数衡量预测与目标的差距,反向传播用链式法则把 loss 的影响传回每个参数,优化器再根据梯度更新参数。两层 MLP 的手推过程展示了神经网络训练最核心的机制:每个局部操作只需要知道自己的导数,整体梯度就能沿计算图自动组合出来。
PyTorch autograd 自动完成反向传播,但它没有改变数学本质。理解 loss.backward() 背后的链式法则、梯度形状和累积机制,是读懂优化器、混合精度、分布式训练和大模型微调脚本的基础。
第一遍记住一句话:反向传播不是魔法,它只是沿着前向计算图反方向应用链式法则。
大模型视角
现代大模型训练脚本看起来复杂:AMP、DDP、FSDP、gradient checkpointing、gradient accumulation、clip grad、scheduler。但它们都围绕同一条主线服务:更稳定、更省显存、更高吞吐地完成 forward、loss、backward 和 optimizer step。
下一篇
激活函数演进:从 Sigmoid 到 SwiGLU ------ 反向传播依赖每个模块的导数。下一篇看激活函数如何影响梯度、非线性表达和现代 Transformer FFN 的设计。