CSDN博客-第4天-PyTorch自动求导与XOR

【深度学习入门 Day 4】PyTorch 自动求导:用 loss.backward() 训练 XOR

本文记录深度学习学习第 4 天的内容:把昨天用 NumPy 手写的 XOR 两层 MLP 改写成 PyTorch 版本,重点理解 Tensorrequires_gradloss.backward().gradtorch.no_grad()zero_()。今天的核心目标不是追求复杂模型,而是看懂 PyTorch 如何自动完成反向传播。

文章目录

  • 一、从 NumPy 手写反向传播到 PyTorch 自动求导
  • 二、准备 XOR 数据
  • 三、手动创建可求导参数
  • 四、前向传播:计算预测值
  • 五、计算 BCE 损失
  • 六、核心一步:loss.backward()
  • 七、一个重要细节:leaf tensor
  • 八、更新参数为什么要用 torch.no_grad()
  • 九、为什么每轮都要 zero grad
  • 十、完整训练代码
  • 十一、今日总结
  • 十二、课后自测

一、从 NumPy 手写反向传播到 PyTorch 自动求导

昨天我们用 NumPy 手写了两层 MLP:

text 复制代码
X -> tanh hidden -> sigmoid output

并且手动推了梯度:

python 复制代码
dZ2 = (a2 - y) / N
dW2 = a1.T @ dZ2
db2 = np.sum(dZ2, axis=0, keepdims=True)

dA1 = dZ2 @ W2.T
dZ1 = dA1 * (1 - a1 ** 2)
dW1 = X.T @ dZ1
db1 = np.sum(dZ1, axis=0, keepdims=True)

这非常重要,因为它让我们知道反向传播到底在算什么。

但真实项目里,我们不会每次都手写这些导数。PyTorch 的核心价值之一就是:

只要用 tensor 搭出前向计算图,PyTorch 就能自动反向传播,计算每个参数的梯度。

今天要记住三个关键词:

text 复制代码
requires_grad
loss.backward()
zero_()

它们分别对应:

text 复制代码
requires_grad=True  告诉 PyTorch:这个参数需要求梯度
loss.backward()     从 loss 开始自动反向传播
grad.zero_()        清空上一轮留下的梯度

二、准备 XOR 数据

先导入 PyTorch:

python 复制代码
import torch

准备 XOR 数据:

python 复制代码
X = torch.tensor([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
])

y = torch.tensor([
    [0.0],
    [1.0],
    [1.0],
    [0.0],
])

打印形状:

python 复制代码
print("X shape:", X.shape)
print("y shape:", y.shape)

输出:

text 复制代码
X shape: torch.Size([4, 2])
y shape: torch.Size([4, 1])

这和 NumPy 版一样:

text 复制代码
4 个样本
每个样本 2 个特征
每个样本 1 个标签

三、手动创建可求导参数

今天先不用 nn.Module,而是手动创建参数。这样最容易看清自动求导的过程。

python 复制代码
torch.manual_seed(42)

W1 = (torch.randn(2, 4) * 0.1).requires_grad_()
b1 = torch.zeros(1, 4, requires_grad=True)

W2 = (torch.randn(4, 1) * 0.1).requires_grad_()
b2 = torch.zeros(1, 1, requires_grad=True)

这里的网络结构是:

text 复制代码
输入层:2 个特征
隐藏层:4 个神经元
输出层:1 个神经元

所以参数形状是:

text 复制代码
W1.shape = (2, 4)
b1.shape = (1, 4)
W2.shape = (4, 1)
b2.shape = (1, 1)

requires_grad=True 的意思是:

这个 tensor 是需要训练的参数,请 PyTorch 记录它参与过的计算,并在反向传播时计算它的梯度。

对于 W1W2,这里用了:

python 复制代码
.requires_grad_()

最后的下划线表示原地操作,也就是把当前 tensor 标记为需要梯度。


四、前向传播:计算预测值

前向传播和昨天的 NumPy 版几乎一模一样:

python 复制代码
z1 = X @ W1 + b1
a1 = torch.tanh(z1)

z2 = a1 @ W2 + b2
a2 = torch.sigmoid(z2)

打印形状:

python 复制代码
print("z1 shape:", z1.shape)
print("a1 shape:", a1.shape)
print("z2 shape:", z2.shape)
print("a2 shape:", a2.shape)
print("a2:", a2)

输出类似:

text 复制代码
z1 shape: torch.Size([4, 4])
a1 shape: torch.Size([4, 4])
z2 shape: torch.Size([4, 1])
a2 shape: torch.Size([4, 1])
a2: tensor([[0.5000],
        [0.5002],
        [0.5013],
        [0.5014]], grad_fn=<SigmoidBackward0>)

这里最值得注意的是:

text 复制代码
grad_fn=<SigmoidBackward0>

它说明 a2 不是一个普通结果,而是由 sigmoid 计算得到的,PyTorch 记住了它的来源。

也就是说,PyTorch 在背后已经记录了这条计算链:

text 复制代码
W1, b1, W2, b2
        ↓
z1 -> tanh -> a1 -> z2 -> sigmoid -> a2

这就是自动求导的基础。


五、计算 BCE 损失

二分类任务使用 BCE:

python 复制代码
loss = -(y * torch.log(a2 + 1e-8) + (1 - y) * torch.log(1 - a2 + 1e-8)).mean()

print("loss:", loss)
print("loss grad_fn:", loss.grad_fn)

初始预测接近 0.5,所以 loss 通常接近:

text 复制代码
0.693

输出类似:

text 复制代码
loss: tensor(0.6931, grad_fn=<NegBackward0>)
loss grad_fn: <NegBackward0 object at ...>

loss.grad_fn 不是 None,说明这个 loss 也是通过一串可求导计算得到的。

换句话说,PyTorch 知道:

text 复制代码
loss 来自 a2
a2 来自 sigmoid
sigmoid 来自 z2
z2 来自 a1、W2、b2
a1 来自 tanh
z1 来自 X、W1、b1

六、核心一步:loss.backward()

现在进入今天最核心的一句:

python 复制代码
loss.backward()

它会从 loss 开始,沿着计算图反向传播,自动计算:

text 复制代码
dLoss/dW1
dLoss/db1
dLoss/dW2
dLoss/db2

这些梯度会被保存到参数的 .grad 属性里:

python 复制代码
print("W1 grad:", W1.grad)
print("b1 grad:", b1.grad)
print("W2 grad:", W2.grad)
print("b2 grad:", b2.grad)

打印形状:

python 复制代码
print("W1 grad shape:", W1.grad.shape)
print("b1 grad shape:", b1.grad.shape)
print("W2 grad shape:", W2.grad.shape)
print("b2 grad shape:", b2.grad.shape)

输出:

text 复制代码
W1 grad shape: torch.Size([2, 4])
b1 grad shape: torch.Size([1, 4])
W2 grad shape: torch.Size([4, 1])
b2 grad shape: torch.Size([1, 1])

可以看到:

text 复制代码
W1.grad.shape == W1.shape
b1.grad.shape == b1.shape
W2.grad.shape == W2.shape
b2.grad.shape == b2.shape

这和昨天 NumPy 手写的梯度完全对应:

text 复制代码
NumPy:   手写 dW1、db1、dW2、db2
PyTorch: loss.backward() 自动得到 W1.grad、b1.grad、W2.grad、b2.grad

七、一个重要细节:leaf tensor

一开始可能会写出这样的参数初始化:

python 复制代码
W1 = torch.randn(2, 4, requires_grad=True) * 0.1
W2 = torch.randn(4, 1, requires_grad=True) * 0.1

看起来没问题,但运行后可能会发现:

text 复制代码
W1.grad = None
W2.grad = None

并且 PyTorch 会提示:

text 复制代码
The .grad attribute of a Tensor that is not a leaf Tensor is being accessed.

原因是:

python 复制代码
torch.randn(2, 4, requires_grad=True)

这个原始 tensor 是 leaf tensor。

但后面又乘了:

python 复制代码
* 0.1

乘完以后得到的新 W1 已经不是 leaf tensor,而是由一次乘法运算生成的中间结果。

PyTorch 默认只把梯度保存到 leaf tensor 的 .grad 里,所以 W1.grad 会是 None

正确写法之一是:

python 复制代码
W1 = (torch.randn(2, 4) * 0.1).requires_grad_()
W2 = (torch.randn(4, 1) * 0.1).requires_grad_()

今天要记住这句话:

PyTorch 默认只把梯度保存在 leaf tensor 的 .grad 里;如果一个 tensor 是由别的 tensor 运算得到的,它通常不是 leaf tensor。


八、更新参数为什么要用 torch.no_grad()

有了梯度以后,就可以更新参数。

NumPy 里我们写:

python 复制代码
W1 = W1 - lr * dW1
b1 = b1 - lr * db1
W2 = W2 - lr * dW2
b2 = b2 - lr * db2

PyTorch 手动更新可以写成:

python 复制代码
lr = 0.1

with torch.no_grad():
    W1 -= lr * W1.grad
    b1 -= lr * b1.grad
    W2 -= lr * W2.grad
    b2 -= lr * b2.grad

这里必须理解:

python 复制代码
with torch.no_grad():

意思是:

这一段只是更新参数,不要把"参数更新"本身也记录进计算图。

如果不加它,PyTorch 会继续追踪:

text 复制代码
W1 -> W1 - lr * W1.grad

这会让计算图变复杂,也不符合训练逻辑。

训练时,我们希望 PyTorch 记录的是:

text 复制代码
参数如何参与 forward 并产生 loss

而不是记录:

text 复制代码
参数更新这件事本身

所以参数更新要放在 torch.no_grad() 里。


九、为什么每轮都要 zero grad

参数更新完以后,还要清空梯度:

python 复制代码
W1.grad.zero_()
b1.grad.zero_()
W2.grad.zero_()
b2.grad.zero_()

为什么?

因为 PyTorch 的梯度默认是累加的,不是覆盖。

假设第一次反向传播后:

text 复制代码
W1.grad = 0.3

如果不清空,第二次调用:

python 复制代码
loss.backward()

假设新梯度是:

text 复制代码
0.2

那么 PyTorch 会得到:

text 复制代码
W1.grad = 0.3 + 0.2 = 0.5

而不是:

text 复制代码
W1.grad = 0.2

所以每一轮训练通常是:

text 复制代码
1. forward       计算预测
2. loss          计算损失
3. backward      计算梯度,把梯度存到 .grad
4. update        用 .grad 更新参数
5. zero grad     清空 .grad,准备下一轮

这里的:

python 复制代码
zero_()

下划线表示原地操作,直接把原来的梯度 tensor 改成 0。

以后使用优化器时,常见写法是:

python 复制代码
optimizer.zero_grad()
loss.backward()
optimizer.step()

其中:

python 复制代码
optimizer.zero_grad()

做的就是清空上一轮梯度。


十、完整训练代码

python 复制代码
import torch

X = torch.tensor([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
])

y = torch.tensor([
    [0.0],
    [1.0],
    [1.0],
    [0.0],
])

torch.manual_seed(42)

W1 = (torch.randn(2, 4) * 0.1).requires_grad_()
b1 = torch.zeros(1, 4, requires_grad=True)

W2 = (torch.randn(4, 1) * 0.1).requires_grad_()
b2 = torch.zeros(1, 1, requires_grad=True)

lr = 0.1

for step in range(10001):
    # forward
    z1 = X @ W1 + b1
    a1 = torch.tanh(z1)

    z2 = a1 @ W2 + b2
    a2 = torch.sigmoid(z2)

    loss = -(y * torch.log(a2 + 1e-8) + (1 - y) * torch.log(1 - a2 + 1e-8)).mean()

    # backward
    loss.backward()

    # update
    with torch.no_grad():
        W1 -= lr * W1.grad
        b1 -= lr * b1.grad
        W2 -= lr * W2.grad
        b2 -= lr * b2.grad

    # zero grad
    W1.grad.zero_()
    b1.grad.zero_()
    W2.grad.zero_()
    b2.grad.zero_()

    if step % 1000 == 0:
        pred = (a2 >= 0.5).int()
        print(
            f"step={step:05d}, "
            f"loss={loss.item():.6f}, "
            f"a2={a2.detach().view(-1).numpy().round(3)}, "
            f"pred={pred.view(-1).numpy()}"
        )

最终输出类似:

text 复制代码
step=05000, loss=0.011665, a2=[0.014 0.992 0.99  0.014], pred=[0 1 1 0]
step=06000, loss=0.007604, a2=[0.009 0.995 0.994 0.009], pred=[0 1 1 0]
step=07000, loss=0.005605, a2=[0.007 0.996 0.995 0.007], pred=[0 1 1 0]
step=08000, loss=0.004423, a2=[0.006 0.997 0.996 0.005], pred=[0 1 1 0]
step=09000, loss=0.003645, a2=[0.005 0.998 0.997 0.004], pred=[0 1 1 0]
step=10000, loss=0.003094, a2=[0.004 0.998 0.997 0.004], pred=[0 1 1 0]

可以看到模型已经成功学会 XOR:

text 复制代码
[0, 0] -> 0
[0, 1] -> 1
[1, 0] -> 1
[1, 1] -> 0

十一、今日总结

今天的核心内容可以压缩成 6 点:

  1. PyTorch 的 Tensor 可以记录计算过程,并支持自动求导。
  2. requires_grad=True 表示这个参数需要计算梯度。
  3. 前向传播得到的 a2loss 都带有 grad_fn,说明它们属于计算图。
  4. loss.backward() 会沿计算图反向传播,自动把梯度存到参数的 .grad 中。
  5. PyTorch 默认只把梯度保存在 leaf tensor 的 .grad 里。
  6. 每轮更新后必须清空梯度,因为 PyTorch 的梯度默认会累加。

最终要记住这句话:

NumPy 让我们理解反向传播的细节,PyTorch 让我们把反向传播交给计算图和自动求导系统。


十二、课后自测

  1. requires_grad=True 的作用是什么?
  2. 为什么 a2 会显示 grad_fn=<SigmoidBackward0>
  3. loss.backward() 到底做了什么?
  4. 为什么 W1.grad.shapeW1.shape 一样?
  5. 为什么 torch.randn(..., requires_grad=True) * 0.1 得到的 W1.grad 可能是 None
  6. 什么是 leaf tensor?
  7. 为什么参数更新要放在 torch.no_grad() 里面?
  8. 为什么每轮训练后都要调用 zero_()