模型参数的梯度,是怎么算出来的?它又告诉了我们什么?
完整流程
在 PyTorch 中训练模型时,梯度计算分三步:
output = model(x) # 第一步:前向传播,得到预测
loss = criterion(output, target) # 第二步:计算损失
loss.backward() # 第三步:反向传播,算出梯度
optimizer.step() # 第四步:用梯度更新参数
这四行代码是深度学习训练的核心。下面逐一拆解。
第一步:什么是梯度?
梯度告诉每个参数:"往哪个方向调整,能让损失变小。"
在数学上,梯度是损失函数对参数的偏导数。比如 ∂L/∂w11 就是损失 L 对权重 w11 的偏导数。
它的含义很简单:
∂L/∂w11 > 0:w11 增加会让损失变大 → 应该减小 w11∂L/∂w11 < 0:w11 增加会让损失变小 → 应该增大 w11∂L/∂w11 = 0:w11 变化不影响损失 → 不用动
绝对值越大,说明这个参数对损失的影响越大。
第二步:从一个简单例子开始
先看一个标量例子,手动算一遍梯度。
import torch
x = torch.tensor(1., requires_grad=True)
w = torch.tensor(2., requires_grad=True)
b = torch.tensor(3., requires_grad=True)
y = w * x + b # y = 2 * 1 + 3 = 5
y.backward() # 反向传播
print(x.grad) # tensor(2.)
print(w.grad) # tensor(1.)
print(b.grad) # tensor(1.)
梯度是怎么算出来的?
用链式法则,逐个推导。
求 ∂y/∂x:
y = w * x + b
∂y/∂x = w = 2
→ x.grad = 2 ✓
求 ∂y/∂w:
y = w * x + b
∂y/∂w = x = 1
→ w.grad = 1 ✓
求 ∂y/∂b:
y = w * x + b
∂y/∂b = 1
→ b.grad = 1 ✓
这就是梯度的本质:每个参数对输出的"贡献率"。
requires_grad 的作用
requires_grad=True 告诉 PyTorch:记录这个变量的所有运算,以便后面求梯度。
x = torch.tensor(1., requires_grad=True) # 会记录运算
y = torch.tensor(1.) # 不会记录运算
z = x * 2 # z.grad_fn = <MulBackward0>,记录了
z = y * 2 # z.grad_fn = None,没记录
第三步:全连接层的梯度
有了标量的基础,现在看一个真实的深度学习场景。
import torch
import torch.nn as nn
x = torch.randn(10, 3) # 10个样本,每个3个特征
y = torch.randn(10, 2) # 10个样本,每个2个目标值
linear = nn.Linear(3, 2) # 输入3维,输出2维
print ('w: ', linear.weight)
print ('b: ', linear.bias)
pred = linear(x) # 前向传播
criterion = nn.MSELoss()
loss = criterion(pred, y)
loss.backward() # 反向传播
print('dL/dw: ', linear.weight.grad) # shape: (2, 3)
print('dL/db: ', linear.bias.grad) # shape: (2,)
线性层内部在算什么?
nn.Linear(3, 2) 有两个可学习参数:
weight: (2, 3) 的矩阵
bias: (2,) 的向量
计算: y = x @ weight.T + bias # @ 表示矩阵乘法,不是逐元素相乘
@是 Python 的矩阵乘法运算符。(batch, 3) @ (3, 2) → (batch, 2),把每个输入的 3 个特征加权求和得到 2 个输出。用*会做逐元素相乘,形状不匹配会报错。
weight 的结构:
weight = [[w11, w12, w13], ← 第0行,负责计算输出 y1
[w21, w22, w23]] ← 第1行,负责计算输出 y2
每一行对应一个输出。第 0 行的 3 个权重(w11, w12, w13)决定输入 [x1, x2, x3] 如何组合成 y1;第 1 行的 3 个权重(w21, w22, w23)决定如何组合成 y2。
bias 的结构:
bias = [b1, b2]
↑ ↑
│ └─ 加到 y2 上
└───── 加到 y1 上
每个输出有一个独立的偏置,形状和输出维度一致。
展开来看:
输入 x = [x1, x2, x3]
输出 y1 = w11*x1 + w12*x2 + w13*x3 + b1 ← weight 第0行 + bias[0]
输出 y2 = w21*x1 + w22*x2 + w23*x3 + b2 ← weight 第1行 + bias[1]
梯度矩阵 dL/dw 的含义
dL/dw = [[∂L/∂w11, ∂L/∂w12, ∂L/∂w13],
[∂L/∂w21, ∂L/∂w22, ∂L/∂w23]]
每个元素是损失对对应权重的偏导数,形状和权重完全一致。
∂L/∂w11 具体怎么算?
用 MSE 损失 L = Σ(y_pred - y_target)² = (y1 - t1)² + (y2 - t2)²,对 w11 用链式法则:
链式: ∂L/∂w11 = ∂L/∂y1 · ∂y1/∂w11
求 ∂L/∂y1
L = (y1 - t1)² + (y2 - t2)²
对 y1 求偏导:
∂L/∂y1 = ∂[(y1 - t1)²]/∂y1 + ∂[(y2 - t2)²]/∂y1
= 2(y1 - t1) + 0
= 2(y1 - t1)
第二项对 y1 求导为 0,因为 (y2 - t2)² 不含 y1。
求 ∂y1/∂w11
y1 = w11*x1 + w12*x2 + w13*x3 + b1
对 w11 求偏导:
∂y1/∂w11 = ∂[w11*x1]/∂w11 + ∂[w12*x2]/∂w11 + ∂[w13*x3]/∂w11 + ∂[b1]/∂w11
= x1 + 0 + 0 + 0
= x1
只有 w11*x1 这一项含 w11,其他项对 w11 求导都是 0。
合并
∂L/∂w11 = ∂L/∂y1 · ∂y1/∂w11
= 2(y1 - t1) × x1
这就是梯度的来源:预测误差 × 输入值。
如果预测值 y1 比目标值 t1 大很多,∂L/∂w11 就很大,说明 w11 需要大幅调整。如果预测已经很准了,梯度就接近 0,说明 w11 不用怎么动。
∂L/∂w21 呢?
同理,但走的是 y2 这条路径:
∂L/∂w21 = ∂L/∂y2 · ∂y2/∂w21
= 2(y2 - t2) × x1
注意这里用的是 y2 和 t2,因为 w21 影响的是 y2 而不是 y1。
为什么梯度的形状和参数一样?
因为每个权重参数都有自己独立的梯度:
权重矩阵: 梯度矩阵:
[[w11, w12, w13], [[∂L/∂w11, ∂L/∂w12, ∂L/∂w13],
[w21, w22, w23]] [∂L/∂w21, ∂L/∂w22, ∂L/∂w23]]
一一对应,形状必须是 (2, 3)。
第四步:参数更新的直观理解
拿到梯度后,用梯度下降更新参数:
optimizer.step()
内部大致做了:
for param in linear.parameters():
param.data -= lr * param.grad
也就是:
new_w11 = w11 - lr × ∂L/∂w11
new_w12 = w12 - lr × ∂L/∂w12
...
梯度告诉参数"往哪走",学习率控制"走多远"。
回顾一下前面的规则:
∂L/∂w11 > 0:w11 增加会让损失变大 → 应该减小 w11∂L/∂w11 < 0:w11 增加会让损失变小 → 应该增大 w11∂L/∂w11 = 0:w11 变化不影响损失 → 不用动
更新公式 new_w11 = w11 - lr × ∂L/∂w11 正是按照这个规则工作的:梯度为正时减去一个正数(减小),梯度为负时减去一个负数(增大),梯度为零时不变。
具体数值演示
假设当前权重和梯度如下:
w11 当前值 = 0.5
w12 当前值 = -0.3
梯度:
∂L/∂w11 = +2.0 ← 正梯度,说明 w11 太大,需要减小
∂L/∂w12 = -1.5 ← 负梯度,说明 w12 太小,需要增大
学习率 lr = 0.01
更新后:
new_w11 = 0.5 - 0.01 × (+2.0) = 0.5 - 0.02 = 0.48 ← 减小了
new_w12 = -0.3 - 0.01 × (-1.5) = -0.3 + 0.015 = -0.285 ← 增大了
梯度为正,参数减小;梯度为负,参数增大。这就是为什么叫"梯度下降"——参数总是往梯度的反方向走。
学习率的影响
学习率决定了步子迈多大:
lr = 0.01(小步慢走):
new_w11 = 0.5 - 0.01 × 2.0 = 0.48 ← 微调
lr = 0.1(大步快走):
new_w11 = 0.5 - 0.1 × 2.0 = 0.3 ← 跳得远
lr = 1.0(步子太大):
new_w11 = 0.5 - 1.0 × 2.0 = -1.5 ← 可能跳过头
学习率太小,收敛慢;学习率太大,可能震荡甚至发散。一般从 0.01 或 0.001 开始试。
更新后损失会变小吗?
理论上,如果学习率足够小,更新后的损失应该比之前小:
# 更新前
pred = linear(x)
loss_before = criterion(pred, y)
print('loss before: ', loss_before.item()) # 比如 0.4521
# 更新
optimizer.step()
# 更新后
pred = linear(x)
loss_after = criterion(pred, y)
print('loss after: ', loss_after.item()) # 比如 0.4387 ← 变小了
每次迭代都让损失往下降的方向走一点,这就是训练的本质。
梯度累加
PyTorch 有一个重要特性:多次 .backward() 的梯度会累加到 .grad 上。
现象演示
x = torch.tensor(1., requires_grad=True)
y1 = x ** 2 # y1 = x²
y1.backward()
print(x.grad) # tensor(2.) ← dy1/dx = 2x = 2
y2 = x ** 3 # y2 = x³
y2.backward()
print(x.grad) # tensor(5.) ← 2 + 3 = 5,累加了!
第二次 backward 后,x.grad = 2 + 3 = 5,而不是 3。PyTorch 把新梯度加到了已有的 .grad 上。
为什么会这样设计?
有两个重要原因。
原因一:多损失函数的场景
实际模型经常有多个损失需要同时优化:
loss_main = cross_entropy(pred, target) # 主任务损失
loss_aux = mse(aux_pred, aux_target) # 辅助任务损失
total_loss = loss_main + 0.3 * loss_aux
total_loss.backward() # 一次性算出所有梯度
这时候梯度累加是正确且必要的——多个损失对同一个参数的贡献需要叠加。
原因二:显存不够时的技巧
当 batch_size 太大、显存放不下时,可以分批计算梯度,累加后再更新:
# 目标 batch_size = 32,但显存只能放下 8 个
# 分批计算 4 次,梯度累加
model.zero_grad()
for i in range(0, 32, 8):
batch_x = x_data[i:i+8]
batch_y = y_data[i:i+8]
loss = criterion(model(batch_x), batch_y)
loss = loss / 4 # 除以累积次数,取平均
loss.backward() # 梯度累加到 .grad 上
optimizer.step() # 4 次累加后统一更新
这等价于用 32 个样本做一次反向传播,但显存只用了一半。
梯度累加的过程
第1次(i=0): loss.backward() → param.grad = g1/4
第2次(i=8): loss.backward() → param.grad = g1/4 + g2/4
第3次(i=16): loss.backward() → param.grad = g1/4 + g2/4 + g3/4
第4次(i=24): loss.backward() → param.grad = g1/4 + g2/4 + g3/4 + g4/4
= (g1+g2+g3+g4)/4 ← 等价于完整 batch 的梯度
optimizer.step() → 用累加后的梯度更新参数
注意 loss.backward() 必须在循环里面,optimizer.step() 在循环外面。
训练循环中必须清零
正因为梯度会累加,每次训练迭代前要清零:
for epoch in range(num_epochs):
optimizer.zero_grad() # ① 清零上次的梯度
output = model(x)
loss = criterion(output, y)
loss.backward() # ② 计算梯度
optimizer.step() # ③ 更新参数
如果不清零,梯度会不断叠加:
epoch 1: grad = g1
epoch 2: grad = g1 + g2 ← 没有清零,累加了
epoch 3: grad = g1 + g2 + g3 ← 越来越大
→ 梯度爆炸,模型不收敛
清零的三种方式
| 方式 | 代码 | 适用场景 |
|---|---|---|
| Optimizer 清零 | optimizer.zero_grad() | 最常用,清零所有参数 |
| 张量原地清零 | x.grad.zero_() | 手动管理梯度时 |
| 设为 None | x.grad = None | 节省内存 |
梯度爆炸/消失怎么办?
梯度消失
梯度消失是指梯度在反向传播过程中变得越来越小,最终接近 0。
深层网络: x → Layer1 → Layer2 → Layer3 → Layer4 → y
反向传播: ∂L/∂w4 → ∂L/∂w3 → ∂L/∂w2 → ∂L/∂w1
如果每层梯度都乘以 0.1:
∂L/∂w4 = 1.0
∂L/∂w3 = 0.1
∂L/∂w2 = 0.01
∂L/∂w1 = 0.001 ← 前面的层几乎学不到东西
为什么会发生?
- 使用 sigmoid/tanh 激活函数,它们的导数最大只有 0.25/1.0
- 权重初始化太小
- 网络太深,梯度连乘后趋近于 0
怎么解决?
| 方法 | 说明 |
|---|---|
| 换 ReLU | ReLU 在正数区间的梯度恒为 1,不会衰减 |
| BatchNorm | 对每一层的输出做归一化,稳定梯度流 |
| ResNet 残差连接 | shortcut 让梯度可以直接传到前面的层 |
| 合适的初始化 | 如 Kaiming 初始化,让方差保持不变 |
# 好的做法
nn.Linear(64, 64),
nn.BatchNorm1d(64), # 稳定梯度
nn.ReLU(), # 不会梯度消失
# 不好的做法
nn.Linear(64, 64),
nn.Sigmoid(), # 梯度最大 0.25,深层网络会消失
梯度爆炸
梯度爆炸是指梯度在反向传播过程中变得非常大,导致参数更新幅度过大,模型不收敛。
梯度爆炸时参数更新:
w = 0.5
∂L/∂w = 1000.0 ← 梯度过大
lr = 0.01
new_w = 0.5 - 0.01 × 1000.0 = -9.5 ← 一步跳到很远的地方
→ 下一轮损失变成 NaN,模型崩溃
为什么会发生?
- 权重初始化太大
- 网络太深,梯度连乘后变得极大
- 序列模型(如 RNN)中常见
怎么解决?
| 方法 | 说明 |
|---|---|
| 梯度裁剪 | torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) |
| 合适的初始化 | 如 Xavier/Kaiming 初始化 |
| BatchNorm | 稳定每层的输出范围 |
| 降低学习率 | 减小步子,避免跳得太远 |
# 梯度裁剪 - 防止爆炸
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
# 效果:如果梯度的 L2 范数超过 1.0,就等比例缩小所有梯度
梯度裁剪是最直接有效的方法。它不改变梯度方向,只是限制幅度,相当于给优化器加了个"安全阀"。
7. 总结
梯度是深度学习的心脏:
- 梯度是方向:它告诉每个参数"往哪走能让损失变小"。没有梯度,参数就是瞎调。
- 梯度是信号:反向传播把最终输出的误差,通过链式法则逐层传递回来,让每一层都知道自己该承担多少责任。
- 梯度是桥梁:它连接了"模型的预测"和"参数的优化",让模型能够从数据中自动学习,而不是靠人工设计规则。
理解梯度,就理解了深度学习为什么能工作。
深度学习不是魔法,而是一个基于梯度的优化过程。模型通过梯度知道哪里做得不好,然后通过参数更新一点点改进。每一次迭代都是一次"试错-反馈-调整"的循环。
创建参数 前向计算 计算损失 反向传播 更新参数
┌───────────────────┐ ┌────────────────┐ ┌───────────────┐ ┌────────────────┐ ┌───────────────┐
│ requires_grad=True│─→│ y = f(x, w) │─→│ loss = L(y) │─→│ loss.backward()│─→│ optimizer.step│
│ │ │ 构建计算图 │ │ │ │ 链式法则算梯度 │ │ w = w-lr·grad │
└───────────────────┘ └────────────────┘ └───────────────┘ └────────────────┘ └───────────────┘
- requires_grad:开启梯度追踪,记录所有运算
- 前向传播:模型计算预测,同时构建计算图
- 损失计算:衡量预测和目标的差距
- 反向传播:沿计算图反向应用链式法则,算出每个参数的梯度
- 参数更新:用梯度下降调整参数,让损失变小
这五步缺一不可,共同完成了从"模型的猜测"到"参数优化"的转换。