第4课:PyTorch|自动微分Autograd机制深度解析【深度学习的“发动机”】

文章目录


📖 课前导读

为什么自动微分如此重要?

想象一下,你要训练一个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自动完成两件事:

  1. 记录:在前向传播时,自动记录所有操作,构建计算图。
  2. 求导 :调用.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=Trueloss.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突然变为infnan,参数值变得巨大,梯度值超过1e3。
  • 消失:loss长时间不下降,靠近输入层的参数梯度接近0。

诊断方法:在每层后打印梯度范数(可以使用钩子,如上面示例)。


五、课后总结

核心知识点回顾

概念 解释
计算图 有向无环图,记录前向传播的所有操作
叶子张量 用户创建的requires_grad=True的张量,梯度最终存储于此
.backward() 触发反向传播,计算所有叶子张量的梯度
x.grad 存储梯度的属性,会累加
梯度清零 每次迭代前必须执行 x.grad = Noneoptimizer.zero_grad()
torch.no_grad() 上下文管理器,禁用梯度记录(推理时必备)
.detach() 断开计算图,返回新张量
register_hook 自定义梯度处理(调试、修改)
Inplace操作 易破坏计算图,谨慎使用
梯度裁剪 防止梯度爆炸

思考题

  1. 为什么PyTorch不自动清零梯度,而要用户手动操作?
  2. 如果我想对同一个loss调用两次backward(),应该怎么做?
  3. 什么时候该用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 ** 3loss2 = 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 ** 2z.backward(),观察是否影响x.grad


七、下一课预告

第5课我们将学习PyTorch数据流与数据预处理,包括:

  • DatasetDataLoader的用法
  • 自定义数据集
  • 批量读取、打乱、多线程加载
  • 数据归一化与基础增强

学完第5课,你就能处理自己的数据了,为训练真实模型做好准备。


🔗《精讲25课|PyTorch 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!

💡 如果本文对您有所启发欢迎:

👍 点赞📌 收藏 📤 分享给更多需要的伙伴。

🗣️ 期待在评论区看到您的想法, 共同进步。

🔔 关注我,持续获取更多干货内容~

🤗 我们下篇文章见~

相关推荐
aneasystone本尊1 小时前
学习大模型推理的批处理:Continuous Batching
人工智能
IT_陈寒1 小时前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端
和裕1 小时前
平口开槽箱 vs 飞机盒 vs 扣底盒:自动化、展示效果与成本核心区别
大数据·运维·网络·人工智能·算法·自动化
维基框架1 小时前
坊间传闻 OpenAI 将要发布GPT-6 Sol 模型
人工智能·pytorch·python
AgentMaster1 小时前
从售前到售后全链路覆盖:智能客服在企业 5 大场景的落地实践与工具选型
大数据·人工智能·算法
ai小陈1 小时前
GPU服务器租用安全配置实战:SSH密钥与端口最小化开放
服务器·人工智能·深度学习·安全·ai·gpu算力
万物智能信息科技1 小时前
硬件看门狗MAX6369设置—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·华为·开源·harmonyos·鸿蒙
三掌柜6661 小时前
22秒攻击窗口下的防御重构:AI Threat Defense 与 Agent 安全护栏实践
人工智能·安全
七牛云行业应用1 小时前
WorkBuddy自定义模型失败怎么办?从接口鉴权到协议兼容的完整排查
人工智能·agent·ai编程