查看全课大纲(第 3 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
张量、计算图与自动微分
张量、计算图与自动微分:深度学习如何自动求导?
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
在上一节,我们了解了深度学习的基本概念和发展脉络。现在,我们将直面其核心的工程实现机制:深度学习模型是如何通过代码进行前向计算,并自动计算出用于更新参数的梯度的? 本节将深入剖析张量、计算图和自动微分这三个基石,它们是理解后续所有复杂模型(如卷积神经网络、Transformer)如何被训练的前提。学完本节,你将能回答:一个简单的线性回归模型在PyTorch中是如何一步步完成前向传播和反向传播的?计算图在其中扮演了什么角色?自动微分(autograd)又是如何工作的?
💡 核心导读
- 张量(Tensor):深度学习中的核心数据结构,是多维数组的泛化,是存储数据和参数的基本单位。
- 计算图(Computation Graph):一个动态构建的有向无环图,记录了从输入张量到输出张量的所有计算操作(节点)和数据流(边)。
- 自动微分(Automatic Differentiation, autograd):系统通过反向遍历计算图,利用链式法则自动计算所有可学习参数相对于损失函数的梯度。
- 训练循环:结合前向传播、损失计算、反向传播和参数更新,构成模型训练的基本迭代单元。
🧭 前置知识
- 矩阵乘法与形状:理解如
(batch, in_features)与(in_features, out_features)矩阵相乘得到(batch, out_features)的规则。 - 偏导数与链式法则:知道多元函数求偏导的概念,以及复合函数求导的链式法则(不展开推导,见站内数学基础课)。
- 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 节点开始,反向遍历这个图:
- 计算
loss对sq的梯度。 - 计算
sq对diff的梯度。 - 计算
diff对y_pred的梯度。 - 计算
y_pred对mm和b的梯度。 - 计算
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)。请:
- 确定
w1,b1,w2,b2应有的形状。 - 用
torch.randn初始化这些参数,并设置requires_grad=True。 - 随机生成
x和真实标签y_true(形状(2,1))。 - 完成前向传播,计算均方误差损失,并执行反向传播。
- 检查所有参数的
.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 outputs | backward() 默认只能从标量开始反向传播。 | 对损失进行求和或求平均,使其变为标量,如 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),明确指定数据类型。 |
本章小结
- 张量是深度学习的基础数据结构,其形状定义了数据的组织方式。
- 计算图在前向传播过程中动态构建,记录了所有运算和数据的依赖关系,是反向传播的路线图。
- 自动微分(autograd) 通过调用
loss.backward()触发,系统自动沿计算图反向计算所有可学习参数的梯度,并存储在参数的.grad属性中。 - 一个基本的训练迭代包括:前向传播计算损失、反向传播计算梯度、使用优化器更新参数、清零梯度。
requires_grad=True是告诉PyTorch需要对该张量计算梯度的开关。- 使用
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 为准。
- PyTorch 官方文档
torch.Tensor与 Autograd 机制 (PyTorch 2.14.1) - https://pytorch.org/docs/stable/tensors.html - PyTorch 官方文档
torch.nn.Linear(PyTorch 2.14.1) - https://pytorch.org/docs/stable/generated/torch.nn.Linear.html
—— 小象教研组