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

发布时间:2026/7/3 9:46:39
CSDN博客-第4天-PyTorch自动求导与XOR 【深度学习入门 Day 4】PyTorch 自动求导用 loss.backward() 训练 XOR本文记录深度学习学习第 4 天的内容把昨天用 NumPy 手写的 XOR 两层 MLP 改写成 PyTorch 版本重点理解Tensor、requires_grad、loss.backward()、.grad、torch.no_grad()和zero_()。今天的核心目标不是追求复杂模型而是看懂 PyTorch 如何自动完成反向传播。文章目录一、从 NumPy 手写反向传播到 PyTorch 自动求导二、准备 XOR 数据三、手动创建可求导参数四、前向传播计算预测值五、计算 BCE 损失六、核心一步loss.backward()七、一个重要细节leaf tensor八、更新参数为什么要用 torch.no_grad()九、为什么每轮都要 zero grad十、完整训练代码十一、今日总结十二、课后自测一、从 NumPy 手写反向传播到 PyTorch 自动求导昨天我们用 NumPy 手写了两层 MLPX - tanh hidden - sigmoid output并且手动推了梯度dZ2(a2-y)/N dW2a1.T dZ2 db2np.sum(dZ2,axis0,keepdimsTrue)dA1dZ2 W2.T dZ1dA1*(1-a1**2)dW1X.T dZ1 db1np.sum(dZ1,axis0,keepdimsTrue)这非常重要因为它让我们知道反向传播到底在算什么。但真实项目里我们不会每次都手写这些导数。PyTorch 的核心价值之一就是只要用 tensor 搭出前向计算图PyTorch 就能自动反向传播计算每个参数的梯度。今天要记住三个关键词requires_grad loss.backward() zero_()它们分别对应requires_gradTrue 告诉 PyTorch这个参数需要求梯度 loss.backward() 从 loss 开始自动反向传播 grad.zero_() 清空上一轮留下的梯度二、准备 XOR 数据先导入 PyTorchimporttorch准备 XOR 数据Xtorch.tensor([[0.0,0.0],[0.0,1.0],[1.0,0.0],[1.0,1.0],])ytorch.tensor([[0.0],[1.0],[1.0],[0.0],])打印形状print(X shape:,X.shape)print(y shape:,y.shape)输出X shape: torch.Size([4, 2]) y shape: torch.Size([4, 1])这和 NumPy 版一样4 个样本 每个样本 2 个特征 每个样本 1 个标签三、手动创建可求导参数今天先不用nn.Module而是手动创建参数。这样最容易看清自动求导的过程。torch.manual_seed(42)W1(torch.randn(2,4)*0.1).requires_grad_()b1torch.zeros(1,4,requires_gradTrue)W2(torch.randn(4,1)*0.1).requires_grad_()b2torch.zeros(1,1,requires_gradTrue)这里的网络结构是输入层2 个特征 隐藏层4 个神经元 输出层1 个神经元所以参数形状是W1.shape (2, 4) b1.shape (1, 4) W2.shape (4, 1) b2.shape (1, 1)requires_gradTrue的意思是这个 tensor 是需要训练的参数请 PyTorch 记录它参与过的计算并在反向传播时计算它的梯度。对于W1和W2这里用了.requires_grad_()最后的下划线表示原地操作也就是把当前 tensor 标记为需要梯度。四、前向传播计算预测值前向传播和昨天的 NumPy 版几乎一模一样z1X W1b1 a1torch.tanh(z1)z2a1 W2b2 a2torch.sigmoid(z2)打印形状print(z1 shape:,z1.shape)print(a1 shape:,a1.shape)print(z2 shape:,z2.shape)print(a2 shape:,a2.shape)print(a2:,a2)输出类似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_fnSigmoidBackward0)这里最值得注意的是grad_fnSigmoidBackward0它说明a2不是一个普通结果而是由sigmoid计算得到的PyTorch 记住了它的来源。也就是说PyTorch 在背后已经记录了这条计算链W1, b1, W2, b2 ↓ z1 - tanh - a1 - z2 - sigmoid - a2这就是自动求导的基础。五、计算 BCE 损失二分类任务使用 BCEloss-(y*torch.log(a21e-8)(1-y)*torch.log(1-a21e-8)).mean()print(loss:,loss)print(loss grad_fn:,loss.grad_fn)初始预测接近 0.5所以 loss 通常接近0.693输出类似loss: tensor(0.6931, grad_fnNegBackward0) loss grad_fn: NegBackward0 object at ...loss.grad_fn不是None说明这个 loss 也是通过一串可求导计算得到的。换句话说PyTorch 知道loss 来自 a2 a2 来自 sigmoid sigmoid 来自 z2 z2 来自 a1、W2、b2 a1 来自 tanh z1 来自 X、W1、b1六、核心一步loss.backward()现在进入今天最核心的一句loss.backward()它会从loss开始沿着计算图反向传播自动计算dLoss/dW1 dLoss/db1 dLoss/dW2 dLoss/db2这些梯度会被保存到参数的.grad属性里print(W1 grad:,W1.grad)print(b1 grad:,b1.grad)print(W2 grad:,W2.grad)print(b2 grad:,b2.grad)打印形状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)输出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])可以看到W1.grad.shape W1.shape b1.grad.shape b1.shape W2.grad.shape W2.shape b2.grad.shape b2.shape这和昨天 NumPy 手写的梯度完全对应NumPy: 手写 dW1、db1、dW2、db2 PyTorch: loss.backward() 自动得到 W1.grad、b1.grad、W2.grad、b2.grad七、一个重要细节leaf tensor一开始可能会写出这样的参数初始化W1torch.randn(2,4,requires_gradTrue)*0.1W2torch.randn(4,1,requires_gradTrue)*0.1看起来没问题但运行后可能会发现W1.grad None W2.grad None并且 PyTorch 会提示The .grad attribute of a Tensor that is not a leaf Tensor is being accessed.原因是torch.randn(2,4,requires_gradTrue)这个原始 tensor 是 leaf tensor。但后面又乘了*0.1乘完以后得到的新W1已经不是 leaf tensor而是由一次乘法运算生成的中间结果。PyTorch 默认只把梯度保存到 leaf tensor 的.grad里所以W1.grad会是None。正确写法之一是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 里我们写W1W1-lr*dW1 b1b1-lr*db1 W2W2-lr*dW2 b2b2-lr*db2PyTorch 手动更新可以写成lr0.1withtorch.no_grad():W1-lr*W1.grad b1-lr*b1.grad W2-lr*W2.grad b2-lr*b2.grad这里必须理解withtorch.no_grad():意思是这一段只是更新参数不要把“参数更新”本身也记录进计算图。如果不加它PyTorch 会继续追踪W1 - W1 - lr * W1.grad这会让计算图变复杂也不符合训练逻辑。训练时我们希望 PyTorch 记录的是参数如何参与 forward 并产生 loss而不是记录参数更新这件事本身所以参数更新要放在torch.no_grad()里。九、为什么每轮都要 zero grad参数更新完以后还要清空梯度W1.grad.zero_()b1.grad.zero_()W2.grad.zero_()b2.grad.zero_()为什么因为 PyTorch 的梯度默认是累加的不是覆盖。假设第一次反向传播后W1.grad 0.3如果不清空第二次调用loss.backward()假设新梯度是0.2那么 PyTorch 会得到W1.grad 0.3 0.2 0.5而不是W1.grad 0.2所以每一轮训练通常是1. forward 计算预测 2. loss 计算损失 3. backward 计算梯度把梯度存到 .grad 4. update 用 .grad 更新参数 5. zero grad 清空 .grad准备下一轮这里的zero_()下划线表示原地操作直接把原来的梯度 tensor 改成 0。以后使用优化器时常见写法是optimizer.zero_grad()loss.backward()optimizer.step()其中optimizer.zero_grad()做的就是清空上一轮梯度。十、完整训练代码importtorch Xtorch.tensor([[0.0,0.0],[0.0,1.0],[1.0,0.0],[1.0,1.0],])ytorch.tensor([[0.0],[1.0],[1.0],[0.0],])torch.manual_seed(42)W1(torch.randn(2,4)*0.1).requires_grad_()b1torch.zeros(1,4,requires_gradTrue)W2(torch.randn(4,1)*0.1).requires_grad_()b2torch.zeros(1,1,requires_gradTrue)lr0.1forstepinrange(10001):# forwardz1X W1b1 a1torch.tanh(z1)z2a1 W2b2 a2torch.sigmoid(z2)loss-(y*torch.log(a21e-8)(1-y)*torch.log(1-a21e-8)).mean()# backwardloss.backward()# updatewithtorch.no_grad():W1-lr*W1.grad b1-lr*b1.grad W2-lr*W2.grad b2-lr*b2.grad# zero gradW1.grad.zero_()b1.grad.zero_()W2.grad.zero_()b2.grad.zero_()ifstep%10000:pred(a20.5).int()print(fstep{step:05d}, floss{loss.item():.6f}, fa2{a2.detach().view(-1).numpy().round(3)}, fpred{pred.view(-1).numpy()})最终输出类似step05000, loss0.011665, a2[0.014 0.992 0.99 0.014], pred[0 1 1 0] step06000, loss0.007604, a2[0.009 0.995 0.994 0.009], pred[0 1 1 0] step07000, loss0.005605, a2[0.007 0.996 0.995 0.007], pred[0 1 1 0] step08000, loss0.004423, a2[0.006 0.997 0.996 0.005], pred[0 1 1 0] step09000, loss0.003645, a2[0.005 0.998 0.997 0.004], pred[0 1 1 0] step10000, loss0.003094, a2[0.004 0.998 0.997 0.004], pred[0 1 1 0]可以看到模型已经成功学会 XOR[0, 0] - 0 [0, 1] - 1 [1, 0] - 1 [1, 1] - 0十一、今日总结今天的核心内容可以压缩成 6 点PyTorch 的Tensor可以记录计算过程并支持自动求导。requires_gradTrue表示这个参数需要计算梯度。前向传播得到的a2和loss都带有grad_fn说明它们属于计算图。loss.backward()会沿计算图反向传播自动把梯度存到参数的.grad中。PyTorch 默认只把梯度保存在 leaf tensor 的.grad里。每轮更新后必须清空梯度因为 PyTorch 的梯度默认会累加。最终要记住这句话NumPy 让我们理解反向传播的细节PyTorch 让我们把反向传播交给计算图和自动求导系统。十二、课后自测requires_gradTrue的作用是什么为什么a2会显示grad_fnSigmoidBackward0loss.backward()到底做了什么为什么W1.grad.shape和W1.shape一样为什么torch.randn(..., requires_gradTrue) * 0.1得到的W1.grad可能是None什么是 leaf tensor为什么参数更新要放在torch.no_grad()里面为什么每轮训练后都要调用zero_()