梯度是什么:PyTorch 自动求导详解

10 阅读11分钟

模型参数的梯度,是怎么算出来的?它又告诉了我们什么?

完整流程

在 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 = 2x.grad = 2

求 ∂y/∂w

y = w * x + b
∂y/∂w = x = 1w.grad = 1

求 ∂y/∂b

y = w * x + b
∂y/∂b = 1b.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_()手动管理梯度时
设为 Nonex.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

怎么解决?

方法说明
换 ReLUReLU 在正数区间的梯度恒为 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:开启梯度追踪,记录所有运算
  • 前向传播:模型计算预测,同时构建计算图
  • 损失计算:衡量预测和目标的差距
  • 反向传播:沿计算图反向应用链式法则,算出每个参数的梯度
  • 参数更新:用梯度下降调整参数,让损失变小

这五步缺一不可,共同完成了从"模型的猜测"到"参数优化"的转换。