
文章目录
-
- [📖 课前导读](#📖 课前导读)
- 一、知识原理:反向传播与自动微分
-
- [1.1 为什么需要反向传播?](#1.1 为什么需要反向传播?)
- [1.2 计算图:自动微分的基石](#1.2 计算图:自动微分的基石)
- [1.3 链式法则示例](#1.3 链式法则示例)
- 二、环境搭建(承接上节课)
- 三、代码实战:从零掌握Autograd
-
- [3.1 `requires_grad`:标记需要梯度的张量](#3.1
requires_grad:标记需要梯度的张量) - [3.2 计算梯度:`.backward()`](#3.2 计算梯度:
.backward()) - [3.3 梯度的累积特性](#3.3 梯度的累积特性)
- [3.4 禁用梯度:`torch.no_grad()`](#3.4 禁用梯度:
torch.no_grad()) - [3.5 分离子图:`.detach()`](#3.5 分离子图:
.detach()) - [3.6 梯度的截断与限制](#3.6 梯度的截断与限制)
- [3.7 自定义梯度运算:`register_hook`](#3.7 自定义梯度运算:
register_hook) - [3.8 梯度爆炸与消失的简单演示](#3.8 梯度爆炸与消失的简单演示)
- [3.9 Inplace操作的风险](#3.9 Inplace操作的风险)
- [3.10 多层网络的梯度传递实战](#3.10 多层网络的梯度传递实战)
- [3.11 获取梯度并手动更新参数(模拟优化器)](#3.11 获取梯度并手动更新参数(模拟优化器))
- [3.1 `requires_grad`:标记需要梯度的张量](#3.1
- 四、难点解析:常见问题与陷阱
-
- [4.1 `RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn`](#4.1
RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn) - [4.2 `RuntimeError: Trying to backward through the graph a second time`](#4.2
RuntimeError: Trying to backward through the graph a second time) - [4.3 梯度为`None`](#4.3 梯度为
None) - [4.4 Inplace操作破坏了计算图](#4.4 Inplace操作破坏了计算图)
- [4.5 梯度累加导致训练不稳定](#4.5 梯度累加导致训练不稳定)
- [4.6 梯度爆炸/消失的征兆](#4.6 梯度爆炸/消失的征兆)
- [4.1 `RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn`](#4.1
- 五、课后总结
- 六、课后作业
- 七、下一课预告
- [🔗《精讲25课|PyTorch 从入门到精通》系列课程导航](#🔗《精讲25课|PyTorch 从入门到精通》系列课程导航)
📖 课前导读
为什么自动微分如此重要?
想象一下,你要训练一个100层的神经网络。如果手动计算每一层参数的梯度,不仅极其繁琐,而且极易出错。反向传播算法的数学公式虽然漂亮,但手工实现时,链式法则的层层嵌套会让代码变得难以维护。
PyTorch的Autograd(自动梯度)引擎 就是来解决这个问题的。你只需要定义前向传播(输入→输出),Autograd会自动构建计算图,并在你调用.backward()时,自动计算出所有参数的梯度。
💡 类比:Autograd就像汽车的自动变速箱。你不需要关心齿轮如何啮合、离合器如何工作,只需要踩油门(前向传播)和刹车(反向传播),变速箱自动完成换挡(梯度计算)。
学完这一课,你将能够:
- ✅ 理解PyTorch计算图的构建过程
- ✅ 使用
requires_grad控制哪些张量需要梯度 - ✅ 调用
.backward()自动计算梯度 - ✅ 掌握梯度清零、梯度截断、禁用梯度的场景
- ✅ 自定义梯度运算(梯度钩子)
- ✅ 识别inplace操作的风险并规避
- ✅ 诊断梯度爆炸/消失的征兆并采取基础应对措施
一、知识原理:反向传播与自动微分
1.1 为什么需要反向传播?
在训练神经网络时,我们需要找到一组参数(权重和偏置),使得损失函数最小。梯度下降法的核心步骤是:
参数_new = 参数_old - 学习率 × 梯度
梯度告诉我们:如果稍微增大某个参数,损失会如何变化(增加还是减少,变化多少)。为了计算梯度,我们需要从损失函数出发,沿着网络反向传播误差,逐层计算每个参数对损失的偏导数。
这就是**反向传播(Backpropagation)**算法的本质:利用链式法则,从输出层向输入层逐层计算梯度。
1.2 计算图:自动微分的基石
PyTorch在背后构建了一个有向无环图(DAG) ,称为计算图。每个张量操作都是图中的一个节点,边表示数据依赖关系。
例如,表达式 z = (a * b) + c 的计算图如下:
a b
\ /
* c
\ /
+
|
z
前向传播 :从输入节点开始,按依赖关系计算每个节点的值。
反向传播:从输出节点(通常是损失)开始,沿相反方向计算梯度(利用链式法则)。
PyTorch的Autograd自动完成两件事:
- 记录:在前向传播时,自动记录所有操作,构建计算图。
- 求导 :调用
.backward()时,自动沿着计算图反向传播,计算所有叶子张量(requires_grad=True且非由其他张量操作得来)的梯度。
1.3 链式法则示例
假设 y = f(g(x)),那么 dy/dx = (dy/dg) * (dg/dx)。神经网络就是无数个这样的复合函数。
Autograd自动应用链式法则,你不需要显式写任何求导公式。
二、环境搭建(承接上节课)
本课基于第2课、第3课搭建的环境。确保已安装PyTorch,并导入必要的库:
python
import torch
import torch.nn as nn
import torch.nn.functional as F
# 设置随机种子以便结果可复现
torch.manual_seed(42)
print(f"PyTorch版本: {torch.__version__}")
print(f"CUDA可用: {torch.cuda.is_available()}")
三、代码实战:从零掌握Autograd
3.1 requires_grad:标记需要梯度的张量
默认情况下,我们创建的张量不需要梯度。
python
x = torch.tensor([2.0])
y = x ** 2
print(y.requires_grad) # False,因为没有标记
如果想对某个张量求导,必须设置requires_grad=True:
python
# 方法1:创建时指定
x = torch.tensor([2.0], requires_grad=True)
print(x.requires_grad) # True
# 方法2:创建后再修改(警告:某些操作可能不支持)
x = torch.tensor([2.0])
x.requires_grad_(True) # 原地修改,返回自身
print(x.requires_grad) # True
💡 叶子张量 :由用户直接创建的张量(而非通过运算得到),且
requires_grad=True,才会被记录梯度。
3.2 计算梯度:.backward()
python
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2 # y = x^2
y.backward() # 计算梯度 dy/dx
print(x.grad) # tensor([4.]) 因为导数 2x = 4
逐行解释:
y.backward():触发反向传播,自动计算所有requires_grad=True的张量的梯度。x.grad:存储了dy/dx的值,即梯度。
⚠️ 注意 :
.backward()只能对标量(0维张量)调用。如果y是向量,需要传入一个与y形状相同的gradient参数,或者对y求和后再调用(常见做法)。
对向量的反向传播
python
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2 # y = [1, 4, 9]
# 如果直接 y.backward() 会报错,因为 y 不是标量
# 正确做法:先对 y 求和,再 backward
loss = y.sum() # loss = 1+4+9=14
loss.backward()
print(x.grad) # tensor([2., 4., 6.]),导数 2*x
为什么能这样?因为 sum(y) 对每个 x_i 的偏导数就是 dy_i/dx_i = 2*x_i,梯度恰好是 2*x_i。
实际训练中,损失函数loss通常是一个标量(如交叉熵损失、MSE损失),所以可以直接调用.backward()。
3.3 梯度的累积特性
重要特性 :.backward() 会累加 梯度到 .grad 属性中,而不是替换。
python
x = torch.tensor([2.0], requires_grad=True)
y1 = x ** 2
y1.backward() # 第一次计算梯度
print(x.grad) # tensor([4.])
y2 = x ** 3
y2.backward() # 再次调用 backward,梯度会累加
print(x.grad) # tensor([4.+12.=16.]),因为导数3*x^2=12
为什么这样设计? 在一些场景中,我们可能需要对多个损失求和后再反向传播,或者在一个大批次中累积梯度(模拟更大batch size)。但大多数情况下,我们希望在每次迭代前清零梯度,否则梯度会不断叠加,导致训练崩溃。
梯度清零的三种方法
python
# 方法1:将grad置为None(推荐,性能最好)
x.grad = None
# 方法2:原地归零
x.grad.zero_() # 注意:如果grad是None会报错,需先检查
# 方法3:最常用 - 优化器的zero_grad()(后面课程详讲)
# optimizer.zero_grad()
标准训练循环中的梯度清零模式:
python
x = torch.tensor([2.0], requires_grad=True)
for step in range(3):
y = x ** 2
y.backward()
print(f"Step {step+1}: grad = {x.grad.item()}")
# 清零梯度,避免累加
x.grad = None
输出:
Step 1: grad = 4.0
Step 2: grad = 4.0
Step 3: grad = 4.0
3.4 禁用梯度:torch.no_grad()
在模型推理、验证、或不需要梯度的时候,禁用梯度可以大幅节省内存和计算。
python
x = torch.tensor([2.0], requires_grad=True)
with torch.no_grad():
y = x ** 2
print(y.requires_grad) # False,因为禁用了梯度记录
# 或者使用装饰器
@torch.no_grad()
def inference(x):
return x ** 2
y = inference(x)
print(y.requires_grad) # False
💡 典型场景 :模型评估(验证集/测试集)时,我们不需要计算梯度,只需要前向传播得到预测结果。使用
torch.no_grad()可以极大减少显存占用,并加速推理。
3.5 分离子图:.detach()
.detach() 会创建一个新的张量 ,与原始张量共享数据,但断开计算图,即新张量不再需要梯度。
python
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
z = y.detach() # z 与 y 数值相同,但 requires_grad=False
print(z.requires_grad) # False
# 后续操作不会影响原始计算图
w = z ** 3
w.backward() # 这不会影响 x 的梯度,因为 z 已被detach
print(x.grad) # None
典型用途:
- 强化学习中,将目标网络的参数从主网络分离。
- 实现某些不需要梯度的中间变量。
3.6 梯度的截断与限制
在训练深层网络时,梯度可能变得极大(梯度爆炸)或极小(梯度消失)。可以通过**梯度裁剪(Gradient Clipping)**来限制梯度的范数。
python
# 模拟一个参数
param = torch.tensor([100.0], requires_grad=True)
loss = param ** 2
loss.backward()
print(f"裁剪前梯度: {param.grad}") # tensor([200.])
# 梯度裁剪:限制最大范数为 50
torch.nn.utils.clip_grad_norm_(param, max_norm=50)
print(f"裁剪后梯度: {param.grad}") # tensor([50.]),被缩放了
# 或按值裁剪
param.grad = torch.clamp(param.grad, -10, 10)
梯度裁剪通常在反向传播后、优化器更新参数前执行。
3.7 自定义梯度运算:register_hook
你可以为张量注册一个"钩子"(hook),在梯度计算完成后自动执行自定义操作(如打印梯度、修改梯度)。
python
x = torch.tensor([2.0], requires_grad=True)
# 注册钩子
def print_grad(grad):
print(f"梯度值: {grad}")
return grad # 可以返回修改后的梯度
hook = x.register_hook(print_grad)
y = x ** 2
y.backward() # 会触发钩子,输出 "梯度值: tensor([4.])"
# 移除钩子
hook.remove()
典型应用:
- 调试梯度消失/爆炸:打印每一层的梯度统计。
- 梯度伪装:如梯度反转层(Domain Adaptation中的GRL)。
3.8 梯度爆炸与消失的简单演示
梯度爆炸示例
使用一个深度网络,激活函数为Sigmoid(容易产生饱和),且权重初始化过大。
python
torch.manual_seed(0)
# 模拟一个20层的线性网络,每层权重初始为10
x = torch.tensor([1.0], requires_grad=True)
for i in range(20):
# 模拟一个线性层 y = w * x,w=10
w = torch.tensor([10.0], requires_grad=True)
x = w * x
# 为了保留计算图,不重用w(实际应用中会复用参数)
# x 是最终的输出,假设 loss = x
loss = x
loss.backward()
# 梯度爆炸了!第一层的梯度巨大
# 实际输出会很大,甚至inf
print(f"最终输出: {loss.item()}") # 10^20 量级
应对策略:使用梯度裁剪、合适的初始化(如Xavier/Kaiming)、使用BatchNorm、更换激活函数(ReLU)。
梯度消失示例
使用Sigmoid激活函数,权重初始过小。
python
torch.manual_seed(0)
x = torch.tensor([1.0], requires_grad=True)
sigmoid = torch.nn.Sigmoid()
for i in range(10):
w = torch.tensor([0.1], requires_grad=True)
x = sigmoid(w * x) # sigmoid函数将输出压缩到(0,1)
loss = x
loss.backward()
print(f"梯度: {x.grad}") # 可能极小
应对策略:使用ReLU族激活函数、残差连接、BatchNorm。
3.9 Inplace操作的风险
Inplace操作(如 tensor.add_(1))会直接修改原张量,而不创建新对象。这可能导致计算图的正确性被破坏,因为Autograd需要保留原始值来计算梯度。
python
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
# y.add_(1) # 尝试对中间结果进行inplace修改会报错
# RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.
# 正确的做法:使用非inplace版本
z = y + 1
z.backward()
print(x.grad) # 正常工作
规则:
- 不要对需要梯度的叶子张量进行inplace操作。
- 不要对参与反向传播的中间变量进行inplace操作。
- 安全做法:总是使用非inplace版本(如
y = y + 1而不是y.add_(1))。
3.10 多层网络的梯度传递实战
我们手动搭建一个简单三层网络,观察梯度如何从输出层传递到输入层。
python
import torch
import torch.nn as nn
# 定义一个三层的全连接网络
class ThreeLayerNet(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, output_dim)
self.relu = nn.ReLU()
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
x = self.fc3(x)
return x
# 创建网络实例
net = ThreeLayerNet(10, 20, 1)
print(net)
# 为每个参数注册钩子,打印梯度范数
for name, param in net.named_parameters():
if param.requires_grad:
param.register_hook(lambda grad, name=name: print(f"{name} 梯度范数: {grad.norm().item():.6f}"))
# 随机输入和目标
x = torch.randn(4, 10) # batch_size=4, input_dim=10
y_true = torch.randn(4, 1)
# 前向传播
output = net(x)
loss = nn.MSELoss()(output, y_true)
# 反向传播
loss.backward()
print("梯度传递完成!")
输出示例(数值会变化):
fc3.weight 梯度范数: 0.234567
fc3.bias 梯度范数: 0.123456
fc2.weight 梯度范数: 0.112233
...
你可以看到,越靠近输出层的参数,梯度范数通常越大(因为链式法则中梯度随层数累积)。这就是梯度消失/爆炸的根源。
3.11 获取梯度并手动更新参数(模拟优化器)
python
x = torch.tensor([2.0], requires_grad=True)
learning_rate = 0.1
for step in range(10):
y = (x - 3) ** 2 # 最小化点 x=3
y.backward()
# 手动更新参数(梯度下降)
with torch.no_grad():
x -= learning_rate * x.grad
print(f"Step {step+1}: x = {x.item():.4f}, grad = {x.grad.item():.4f}")
# 清零梯度
x.grad = None
输出会看到x逐渐接近3。
四、难点解析:常见问题与陷阱
4.1 RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn
原因 :对一个不需要梯度的张量调用了.backward(),或者计算图中没有requires_grad=True的叶子变量。
解决 :确保从输入到损失的路径上有至少一个requires_grad=True的张量。
4.2 RuntimeError: Trying to backward through the graph a second time
原因 :默认情况下,计算图在.backward()后被释放以节省内存。第二次调用需要保留计算图。
解决:
- 如果确实需要多次反向传播,在第一次调用时传入
retain_graph=True:loss.backward(retain_graph=True) - 检查是否不小心调用了两次
.backward()(如训练循环中忘记清零梯度且重复调用)
4.3 梯度为None
可能的原因:
- 张量的
requires_grad=False - 使用了
.detach()或torch.no_grad()包裹了操作 - 计算图中断(例如使用了不支持梯度的操作,或对梯度进行了numpy转换)
4.4 Inplace操作破坏了计算图
错误示例:
python
x = torch.tensor([2.0], requires_grad=True)
x.add_(1) # inplace 修改
y = x ** 2
y.backward() # 可能报错或梯度不正确
4.5 梯度累加导致训练不稳定
忘记清零梯度是最常见的新手错误。检查训练循环中是否有optimizer.zero_grad()或手动置零。
4.6 梯度爆炸/消失的征兆
- 爆炸 :loss突然变为
inf或nan,参数值变得巨大,梯度值超过1e3。 - 消失:loss长时间不下降,靠近输入层的参数梯度接近0。
诊断方法:在每层后打印梯度范数(可以使用钩子,如上面示例)。
五、课后总结
核心知识点回顾
| 概念 | 解释 |
|---|---|
| 计算图 | 有向无环图,记录前向传播的所有操作 |
| 叶子张量 | 用户创建的requires_grad=True的张量,梯度最终存储于此 |
.backward() |
触发反向传播,计算所有叶子张量的梯度 |
x.grad |
存储梯度的属性,会累加 |
| 梯度清零 | 每次迭代前必须执行 x.grad = None 或 optimizer.zero_grad() |
torch.no_grad() |
上下文管理器,禁用梯度记录(推理时必备) |
.detach() |
断开计算图,返回新张量 |
register_hook |
自定义梯度处理(调试、修改) |
| Inplace操作 | 易破坏计算图,谨慎使用 |
| 梯度裁剪 | 防止梯度爆炸 |
思考题
- 为什么PyTorch不自动清零梯度,而要用户手动操作?
- 如果我想对同一个loss调用两次
backward(),应该怎么做? - 什么时候该用
torch.no_grad(),什么时候用.detach()?
六、课后作业
作业1:标量梯度计算
已知 y = 2*x^3 + 5*x,使用PyTorch自动微分计算x=3时的梯度,并手动验证结果(导数公式:6*x^2 + 5,在x=3时应为59)。
作业2:向量梯度与累加
创建向量x = [1, 2, 3],requires_grad=True。定义 y = x * x(逐元素平方),然后loss = y.sum()。调用loss.backward(),打印x.grad。再定义z = x ** 3,loss2 = z.sum(),调用loss2.backward(),再次打印x.grad,观察累加效果。然后清零梯度,重新计算一次,验证结果。
作业3:梯度裁剪实战
创建param = torch.tensor([100.0], requires_grad=True),定义损失loss = param ** 2,反向传播后,分别用clip_grad_norm_ 限制最大范数为1、10、50,观察梯度变化。
作业4:自定义梯度钩子
实现一个钩子,在反向传播后打印梯度的均值和标准差(针对一个形状为(3,3)的权重张量)。提示:grad.mean().item()。
作业5:调试梯度消失
编写一个10层线性网络(每层nn.Linear(10,10),激活函数使用nn.Sigmoid()),输入随机向量,计算输出对输入层的梯度范数。然后更换激活函数为nn.ReLU(),重新计算,对比两者差异。
作业6:Inplace操作实验
尝试对叶子张量执行x.add_(1),观察报错。然后创建一个x,先y = x * 2,再对y执行y.add_(1),再z = y ** 2,z.backward(),观察是否影响x.grad。
七、下一课预告
第5课我们将学习PyTorch数据流与数据预处理,包括:
Dataset和DataLoader的用法- 自定义数据集
- 批量读取、打乱、多线程加载
- 数据归一化与基础增强
学完第5课,你就能处理自己的数据了,为训练真实模型做好准备。
🔗《精讲25课|PyTorch 从入门到精通》系列课程导航
🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~