查看全课大纲(第 3 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

张量、计算图与自动微分

张量、计算图与自动微分:深度学习如何自动求导?

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

在上一节,我们了解了深度学习的基本概念和发展脉络。现在,我们将直面其核心的工程实现机制:深度学习模型是如何通过代码进行前向计算,并自动计算出用于更新参数的梯度的? 本节将深入剖析张量、计算图和自动微分这三个基石,它们是理解后续所有复杂模型(如卷积神经网络、Transformer)如何被训练的前提。学完本节,你将能回答:一个简单的线性回归模型在PyTorch中是如何一步步完成前向传播和反向传播的?计算图在其中扮演了什么角色?自动微分(autograd)又是如何工作的?

💡 核心导读

  1. 张量(Tensor):深度学习中的核心数据结构,是多维数组的泛化,是存储数据和参数的基本单位。
  2. 计算图(Computation Graph):一个动态构建的有向无环图,记录了从输入张量到输出张量的所有计算操作(节点)和数据流(边)。
  3. 自动微分(Automatic Differentiation, autograd):系统通过反向遍历计算图,利用链式法则自动计算所有可学习参数相对于损失函数的梯度。
  4. 训练循环:结合前向传播、损失计算、反向传播和参数更新,构成模型训练的基本迭代单元。

🧭 前置知识

  1. 矩阵乘法与形状:理解如 (batch, in_features) 与 (in_features, out_features) 矩阵相乘得到 (batch, out_features) 的规则。
  2. 偏导数与链式法则:知道多元函数求偏导的概念,以及复合函数求导的链式法则(不展开推导,见站内数学基础课)。
  3. Python基础与NumPy/PyTorch张量操作:了解如何创建和操作多维数组。

张量:数据的容器

在深度学习中,一切数据——无论是图像像素、文本词向量还是模型参数——都被表示为张量。你可以将标量(0维)、向量(1维)、矩阵(2维)视为张量的特例。PyTorch中的 torch.Tensor 是核心对象。

一个张量的关键属性是其形状(shape),它描述了每个维度的长度。例如,一个形状为 (2, 3, 4) 的张量,可以理解为2个样本,每个样本是一个3行4列的矩阵。

import torch
torch.manual_seed(0)  # 固定随机种子,确保结果可复现

# 创建不同形状的张量
scalar = torch.tensor(3.14)                     # 标量,shape = ()
vector = torch.tensor([1.0, 2.0, 3.0])          # 向量,shape = (3,)
matrix = torch.randn(2, 3)                      # 矩阵,shape = (2, 3)
batch_data = torch.randn(4, 3, 32, 32)          # 一个批次图像,shape = (4, 3, 32, 32) 表示4张RGB 32x32图片(缩小尺寸仅为示意)

print("标量 shape:", scalar.shape)
print("向量 shape:", vector.shape)
print("矩阵 shape:", matrix.shape)
print("批次图像数据 shape:", batch_data.shape)
标量 shape: torch.Size([])
向量 shape: torch.Size([3])
矩阵 shape: torch.Size([2, 3])
批次图像数据 shape: torch.Size([4, 3, 32, 32])

计算图与自动微分

当我们对张量进行运算(如加法、乘法、矩阵乘、激活函数)时,PyTorch会动态地构建一个计算图。这个图记录了数据(张量)和运算(函数)之间的依赖关系。只有设置了 requires_grad=True 的张量(通常是模型参数)才会被该图跟踪,以便后续计算梯度。

前向传播就是沿着计算图从输入到输出执行计算。反向传播则是从输出(通常是损失值,一个标量)开始,沿着计算图反向传播,利用链式法则计算每个可学习参数相对于损失的梯度。

PyTorch的 autograd 引擎自动完成了这一切。我们只需在计算损失后调用 loss.backward(),梯度就会累积到各个参数的 .grad 属性中。

让我们通过一个最简单的线性回归例子 y = w * x + b 来揭示整个过程。

import torch
torch.manual_seed(0)

# 1. 准备数据 (监督学习)
x_data = torch.tensor([[1.0], [2.0], [3.0]])  # 输入,shape (3, 1)
y_true = torch.tensor([[2.0], [4.0], [6.0]])  # 真实标签,shape (3, 1)

# 2. 初始化模型参数,并告诉PyTorch需要计算它们的梯度
w = torch.tensor([[0.0]], requires_grad=True)  # 权重,shape (1, 1)
b = torch.tensor([[0.0]], requires_grad=True)  # 偏置,shape (1, 1)

# 3. 定义学习率
learning_rate = 0.01

print("初始参数: w =", w.tolist(), "b =", b.tolist())
print()

# 进行2轮训练
for epoch in range(2):
    # 4. 前向传播:构建计算图并计算预测值和损失
    y_pred = x_data @ w + b  # @ 表示矩阵乘法,等价于 torch.mm(x_data, w)
    loss = ((y_pred - y_true) ** 2).mean()  # 均方误差损失

    # 5. 反向传播:自动计算梯度
    loss.backward()

    # 6. 查看梯度(在更新参数前)
    print(f"Epoch {epoch+1}:")
    print("  预测值 y_pred =", [[round(v, 3) for v in row] for row in y_pred.tolist()])
    print("  损失 loss =", round(loss.item(), 4))
    print("  权重梯度 w.grad =", [[round(v, 4) for v in row] for row in w.grad.tolist()])
    print("  偏置梯度 b.grad =", [[round(v, 4) for v in row] for row in b.grad.tolist()])

    # 7. 手动更新参数(梯度下降)。注意:torch.no_grad() 上下文内操作不会被计算图跟踪
    with torch.no_grad():
        w -= learning_rate * w.grad
        b -= learning_rate * b.grad
        # 重要!清空梯度,否则下次 .backward() 时梯度会累积
        w.grad.zero_()
        b.grad.zero_()

    print("  更新后权重 w =", [[round(v, 4) for v in row] for row in w.tolist()])
    print("  更新后偏置 b =", [[round(v, 4) for v in row] for row in b.tolist()])
    print()
初始参数: w = [[0.0]] b = [[0.0]]

Epoch 1:
  预测值 y_pred = [[0.0], [0.0], [0.0]]
  损失 loss = 18.6667
  权重梯度 w.grad = [[-18.6667]]
  偏置梯度 b.grad = [[-8.0]]
  更新后权重 w = [[0.1867]]
  更新后偏置 b = [[0.08]]

Epoch 2:
  预测值 y_pred = [[0.267], [0.453], [0.64]]
  损失 loss = 14.771
  权重梯度 w.grad = [[-16.6044]]
  偏置梯度 b.grad = [[-7.0933]]
  更新后权重 w = [[0.3527]]
  更新后偏置 b = [[0.1509]]

理解计算图的构建与梯度流

上面的代码中,y_pred = x_data @ w + b 这一行触发了计算图的构建。我们可以将其分解为几个操作节点:

输入节点: x_data, w, b
操作节点1: mm = x_data @ w   (矩阵乘法)
操作节点2: y_pred = mm + b   (加法)
操作节点3: diff = y_pred - y_true
操作节点4: sq = diff ** 2
操作节点5: loss = sq.mean()

当调用 loss.backward() 时,autograd 引擎从 loss 节点开始,反向遍历这个图:

  1. 计算 loss 对 sq 的梯度。
  2. 计算 sq 对 diff 的梯度。
  3. 计算 diff 对 y_pred 的梯度。
  4. 计算 y_pred 对 mm 和 b 的梯度。
  5. 计算 mm 对 w 的梯度。 最终,梯度 ∂loss/∂w 和 ∂loss/∂b 被分别存入 w.grad 和 b.grad。

梯度累积的坑:注意代码中 w.grad.zero_() 这一步。如果忘记清零,下一次 loss.backward() 计算出的梯度会与 .grad 中已有的值相加,导致更新方向错误。这是初学者常犯的错误。

import torch
torch.manual_seed(0)

w = torch.tensor([[1.0]], requires_grad=True)
x = torch.tensor([[2.0]])

# 第一次计算
y1 = x @ w
loss1 = y1.sum()
loss1.backward()
print("初始 w.grad =", [[round(v, 4) for v in row] for row in w.grad.tolist()])

# 不清零梯度,直接进行第二次计算
y2 = x @ w
loss2 = y2.sum()
loss2.backward()  # 梯度会累积!
print("\nEpoch 1 结束后:")
print("  loss1 =", loss1.item())
print("  w.grad =", [[round(v, 4) for v in row] for row in w.grad.tolist()])
print("  w =", [[round(v, 4) for v in row] for row in w.tolist()])

# 模拟一次更新(不清零)
with torch.no_grad():
    w -= 0.1 * w.grad
    # 注意,这里故意没有 w.grad.zero_()

# 第三次计算(梯度继续累积)
y3 = x @ w
loss3 = y3.sum()
loss3.backward()
print("\nEpoch 2 结束后:")
print("  loss2 =", loss2.item())
print("  w.grad =", [[round(v, 4) for v in row] for row in w.grad.tolist()])  # 这个梯度是错的,因为它包含了前两次的梯度信息
print("  w =", [[round(v, 4) for v in row] for row in w.tolist()])
初始 w.grad = [[2.0]]

Epoch 1 结束后:
  loss1 = 2.0
  w.grad = [[4.0]]
  w = [[1.0]]

Epoch 2 结束后:
  loss2 = 2.0
  w.grad = [[6.0]]
  w = [[0.6]]

与 PyTorch 模块(nn.Module)的结合

在实际中,我们很少直接操作 w 和 b。PyTorch 提供了 torch.nn.Linear 等模块来封装线性变换 y = xA^T + b(注意公式是 A^T,这是为了编程便利性,使得 weight 的形状是 (out_features, in_features))。

早期课程材料可能直接使用 w 和 b,现代 PyTorch 实践则统一使用 nn.Module 的子类。nn.Linear 内部已经包含了需要梯度的 weight 和 bias 参数。

import torch
import torch.nn as nn
torch.manual_seed(0)

# 使用 nn.Linear 定义同样的线性层
linear_layer = nn.Linear(in_features=1, out_features=1, bias=True)
# 初始化参数为0以便与前面例子对比
with torch.no_grad():
    linear_layer.weight.fill_(0.0)
    linear_layer.bias.fill_(0.0)

print("nn.Linear 初始权重:", linear_layer.weight.tolist())
print("nn.Linear 初始偏置:", linear_layer.bias.tolist())

x_data = torch.tensor([[1.0], [2.0], [3.0]])
y_true = torch.tensor([[2.0], [4.0], [6.0]])

# 前向传播
y_pred = linear_layer(x_data)  # 等价于 x_data @ linear_layer.weight.T + linear_layer.bias
loss = ((y_pred - y_true) ** 2).mean()

# 反向传播
loss.backward()

print("\n一次反向传播后:")
print("权重梯度 weight.grad =", [[round(v, 4) for v in row] for row in linear_layer.weight.grad.tolist()])
print("偏置梯度 bias.grad =", [round(v, 4) for v in linear_layer.bias.grad.tolist()])
# 可以看到,grad 值与之前手动计算 w, b 时一致
nn.Linear 初始权重: [[0.0]]
nn.Linear 初始偏置: [0.0]

一次反向传播后:
权重梯度 weight.grad = [[-18.6667]]
偏置梯度 bias.grad = [-8.0]

📝 动手练一练

题目:假设我们有一个简单的网络:output = torch.relu(x @ w1 + b1) @ w2 + b2。其中 x 形状为 (2, 3),w1 形状需匹配使得第一层输出为 (2, 4),w2 形状需匹配使得最终输出为 (2, 1)。请:

  1. 确定 w1, b1, w2, b2 应有的形状。
  2. 用 torch.randn 初始化这些参数,并设置 requires_grad=True。
  3. 随机生成 x 和真实标签 y_true(形状(2,1))。
  4. 完成前向传播,计算均方误差损失,并执行反向传播。
  5. 检查所有参数的 .grad 属性是否不为 None,并打印它们的形状。
👉 点击查看参考答案

思路:根据矩阵乘法形状规则推断参数形状。前向传播按顺序计算。反向传播后,所有 requires_grad=True 的参数都应拥有梯度。

import torch
torch.manual_seed(0)

# 1. 确定输入输出
batch_size, in_dim = 2, 3
hidden_dim = 4
out_dim = 1

x = torch.randn(batch_size, in_dim)
y_true = torch.randn(batch_size, out_dim)

# 2. 初始化参数,并确定形状
# 第一层: x (2,3) @ w1 (3,4) -> (2,4), 再加 b1 (4,) 或 (1,4) 通过广播
w1 = torch.randn(in_dim, hidden_dim, requires_grad=True)
b1 = torch.randn(1, hidden_dim, requires_grad=True)  # 形状(1,4)便于广播
# 第二层: (2,4) @ w2 (4,1) -> (2,1), 再加 b2 (1,1)
w2 = torch.randn(hidden_dim, out_dim, requires_grad=True)
b2 = torch.randn(1, out_dim, requires_grad=True)

print("参数形状检查:")
print(f"  w1: {tuple(w1.shape)} (应与 (in_dim, hidden_dim) 一致)")
print(f"  b1: {tuple(b1.shape)}")
print(f"  w2: {tuple(w2.shape)} (应与 (hidden_dim, out_dim) 一致)")
print(f"  b2: {tuple(b2.shape)}")

# 3. 前向传播
hidden = torch.relu(x @ w1 + b1)  # (2,4)
y_pred = hidden @ w2 + b2         # (2,1)

# 4. 计算损失与反向传播
loss = ((y_pred - y_true) ** 2).mean()
loss.backward()

print("\n反向传播后梯度检查 (应不为None):")
print(f"  w1.grad is None: {w1.grad is None}, shape: {w1.grad.shape if w1.grad is not None else 'N/A'}")
print(f"  b1.grad is None: {b1.grad is None}, shape: {b1.grad.shape if b1.grad is not None else 'N/A'}")
print(f"  w2.grad is None: {w2.grad is None}, shape: {w2.grad.shape if w2.grad is not None else 'N/A'}")
print(f"  b2.grad is None: {b2.grad is None}, shape: {b2.grad.shape if b2.grad is not None else 'N/A'}")
参数形状检查:
  w1: (3, 4) (应与 (in_dim, hidden_dim) 一致)
  b1: (1, 4)
  w2: (4, 1) (应与 (hidden_dim, out_dim) 一致)
  b2: (1, 1)

反向传播后梯度检查 (应不为None):
  w1.grad is None: False, shape: torch.Size([3, 4])
  b1.grad is None: False, shape: torch.Size([1, 4])
  w2.grad is None: False, shape: torch.Size([4, 1])
  b2.grad is None: False, shape: torch.Size([1, 1])

常见错误

错误理解或写法现象或报错原因正确做法
忘记在训练循环中清零梯度 (grad.zero_())模型不收敛或收敛异常;梯度值异常增大。每次 backward() 梯度会累积到 .grad 属性中,而不是替换。多次累积的梯度指向错误更新方向。在参数更新后,立即调用 optimizer.zero_grad() 或手动 param.grad.zero_()。
对不需要梯度的张量设置了 requires_grad=True内存占用增加,计算变慢。计算图会跟踪所有 requires_grad=True 的张量,保存中间结果用于反向传播。仅为模型参数设置 requires_grad=True。输入数据通常不需要。
在参数更新时未使用 torch.no_grad() 上下文计算图被意外扩展,可能导致内存泄漏或错误。在 with torch.no_grad(): 外进行 param -= lr * param.grad 这样的操作,会创建新的计算图节点。使用 with torch.no_grad(): 包裹参数更新步骤,或使用 optimizer.step()。
损失不是标量时调用 backward()RuntimeError: grad can be implicitly created only for scalar outputsbackward() 默认只能从标量开始反向传播。对损失进行求和或求平均,使其变为标量,如 loss.sum().backward()。
混淆 torch.tensor 与 torch.Tensor用 torch.Tensor([1,2]) 得到的是默认 dtype(float32)的浮点张量,整数被静默转成浮点,类型问题被掩盖。torch.Tensor 是类构造器(FloatTensor 的别名),总是使用全局默认 dtype,不按输入数据推断;torch.tensor 是工厂函数,会按输入数据推断 dtype。创建数据时使用 torch.tensor(data, dtype=torch.float32),明确指定数据类型。

本章小结

  1. 张量是深度学习的基础数据结构,其形状定义了数据的组织方式。
  2. 计算图在前向传播过程中动态构建,记录了所有运算和数据的依赖关系,是反向传播的路线图。
  3. 自动微分(autograd) 通过调用 loss.backward() 触发,系统自动沿计算图反向计算所有可学习参数的梯度,并存储在参数的 .grad 属性中。
  4. 一个基本的训练迭代包括:前向传播计算损失、反向传播计算梯度、使用优化器更新参数、清零梯度。
  5. requires_grad=True 是告诉PyTorch需要对该张量计算梯度的开关。
  6. 使用 torch.no_grad() 上下文管理器来包裹参数更新操作,可以防止不必要的计算图构建,提升效率。

📋 行动清单

  • 运行线性回归示例代码,尝试修改学习率(如改为0.1或0.001),观察损失下降的速度和稳定性有何不同。
  • 在“动手练一练”的代码中,尝试移除 torch.relu 激活函数,再次运行并观察梯度的变化。
  • 故意在训练循环中注释掉 w.grad.zero_() 和 b.grad.zero_(),运行2个epoch以上,打印每次的梯度值,直观感受梯度累积的效果。

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

  1. PyTorch 官方文档 torch.Tensor 与 Autograd 机制 (PyTorch 2.14.1) - https://pytorch.org/docs/stable/tensors.html
  2. PyTorch 官方文档 torch.nn.Linear (PyTorch 2.14.1) - https://pytorch.org/docs/stable/generated/torch.nn.Linear.html

—— 小象教研组