查看全课大纲(第 4 / 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)训练流程的先修知识,后续章节将在此基础上引入更复杂的模型组件。

💡 核心导读

  1. 数据:是学习的源泉,通常组织为 (batch, ...) 形状的张量,包含特征 X 和(监督学习下的)标签 y。
  2. 模型:是一个可学习的函数 f,将输入数据映射为预测输出(如 logits)。其核心是包含需要优化参数(如权重 W 和偏置 b)的层。
  3. 目标函数(损失函数):量化模型预测 ŷ 与真实标签 y 之间的差距,如交叉熵损失。训练的目标就是最小化这个损失。
  4. 优化器:根据损失函数计算出的梯度,按照特定规则(如 SGD、Adam)更新模型参数,驱动模型向损失更小的方向“学习”。
  5. 训练循环:将数据输入模型、计算损失、反向传播梯度、优化器更新参数,这四个步骤的反复迭代。

🧭 前置知识

  1. 张量与形状:理解如 (batch_size, feature_dim) 的张量表示。
  2. 矩阵乘法:知道 (m, n) 矩阵与 (n, p) 矩阵相乘得到 (m, p) 矩阵。
  3. 链式法则:理解复合函数求导的基本思想(具体求导计算由 PyTorch 的 autograd 自动完成)。
  4. softmax 与交叉熵:知道 softmax 将 logits 转换为概率分布,交叉熵衡量两个概率分布间的差异。

数据:学习的燃料

在深度学习中,数据通常被表示为张量。对于监督学习任务,每一份数据样本都包含两部分:

  • 特征(Feature):模型的输入,例如一张图片的像素值、一段文本的词向量。我们用 X 表示。
  • 标签(Label):我们期望模型预测的目标值,例如图片的类别、文本的情感。我们用 y 表示。

为了高效利用计算资源,我们通常将多个样本打包成一个批次(Batch) 进行并行处理。因此,特征张量 X 的形状通常是 (batch_size, ...),其中 ... 代表样本本身的维度。标签 y 的形状通常是 (batch_size,) 或 (batch_size, num_classes),具体取决于任务类型。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

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

# 模拟一个简单的分类任务数据
# 假设每个样本有10个特征,共有4个样本(batch_size=4)
X = torch.randn(4, 10)  # 形状:(batch_size=4, feature_dim=10)
print("特征数据 X.shape =", X.shape)

# 假设这是一个3分类任务,标签是类别索引(0, 1, 2)
y = torch.tensor([0, 2, 1, 0])  # 形状:(batch_size=4,)
print("标签数据 y =", y.tolist())
特征数据 X.shape = torch.Size([4, 10])
标签数据 y = [0, 2, 1, 0]

模型:可学习的函数

模型 f 是一个将输入特征 X 映射到预测输出(如 logits)的函数。它由一系列层(Layer) 组成,每层包含可学习的参数(Parameter),主要是权重(Weight)和偏置(Bias)。最简单的模型之一是多层感知机(MLP)。

一个两层的 MLP 前向传播过程如下:

  1. 线性变换1:h = X @ W1^T + b1 (@ 表示矩阵乘法)
  2. 非线性激活:a = ReLU(h) (引入非线性)
  3. 线性变换2:logits = a @ W2^T + b2

这里的 W1, b1, W2, b2 就是模型需要学习的参数。logits 是模型对每个样本在各个类别上的原始打分,尚未归一化成概率。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

torch.manual_seed(0)
X = torch.randn(4, 10)          # 本块内重建同一份小批量数据(固定种子)
y = torch.tensor([0, 2, 1, 0])

torch.manual_seed(0)

class SimpleMLP(nn.Module):
    def __init__(self, input_dim=10, hidden_dim=5, num_classes=3):
        super().__init__()
        # 第一层全连接层:参数 W1 (hidden_dim, input_dim), b1 (hidden_dim,)
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        # 第二层全连接层(输出层):参数 W2 (num_classes, hidden_dim), b2 (num_classes,)
        self.fc2 = nn.Linear(hidden_dim, num_classes)
        # 激活函数(非线性变换)
        self.relu = nn.ReLU()

    def forward(self, x):
        h = self.fc1(x)   # 线性变换1
        a = self.relu(h)  # 非线性激活
        logits = self.fc2(a) # 线性变换2,输出 logits
        return logits

# 实例化模型
model = SimpleMLP()
print("模型结构:", model)
print("\n模型参数:")
for name, param in model.named_parameters():
    print(f"  {name}: shape={tuple(param.shape)}")

# 前向传播:将数据 X 输入模型,得到预测 logits
logits = model(X)
print(f"\n模型预测 logits.shape = {logits.shape}")
print("第一个样本的 logits =", [round(v, 4) for v in logits[0].tolist()])
模型结构: SimpleMLP(
  (fc1): Linear(in_features=10, out_features=5, bias=True)
  (fc2): Linear(in_features=5, out_features=3, bias=True)
  (relu): ReLU()
)

模型参数:
  fc1.weight: shape=(5, 10)
  fc1.bias: shape=(5,)
  fc2.weight: shape=(3, 5)
  fc2.bias: shape=(3,)

模型预测 logits.shape = torch.Size([4, 3])
第一个样本的 logits = [-0.3796, -0.1649, 0.1955]

目标函数与优化:指引学习的方向

得到预测 logits 后,我们需要一个标准来衡量它的好坏,这就是目标函数或损失函数(Loss Function)。对于分类任务,最常用的是交叉熵损失(Cross-Entropy Loss)。PyTorch 的 nn.CrossEntropyLoss 将 softmax 归一化和交叉熵计算合并在一起,直接接受 logits 和类别索引标签 y。

损失值是一个标量,值越小表示模型预测得越好。我们的目标就是找到一组模型参数,使得在训练数据上的平均损失最小。

如何找到这组参数?我们使用梯度下降的思想。通过反向传播(Backpropagation) 算法(由 PyTorch 的 autograd 自动完成),可以计算出损失相对于每个模型参数的梯度 ∂loss/∂W。优化器(Optimizer) 则根据这些梯度来更新参数。

最基础的优化器是随机梯度下降(SGD),其更新规则为:W = W - lr * g,其中 lr 是学习率,g 是梯度。PyTorch 的 optim.SGD 实现了该算法(根据官方文档,其更新公式为 v_{t+1} = μ * v_t + g_{t+1}; p_{t+1} = p_t - lr * v_{t+1},其中 μ 是动量,p 是参数)。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

class SimpleMLP(nn.Module):
    """两层全连接 + ReLU;本块内定义,保证块自包含。"""
    def __init__(self, input_dim=10, hidden_dim=5, num_classes=3):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, num_classes)
        self.relu = nn.ReLU()

    def forward(self, x):
        return self.fc2(self.relu(self.fc1(x)))


torch.manual_seed(0)
X = torch.randn(4, 10)          # 本块内重建同一份小批量数据(固定种子)
y = torch.tensor([0, 2, 1, 0])

torch.manual_seed(0)

# 重新初始化模型、优化器和损失函数,确保起点一致
model = SimpleMLP()
optimizer = optim.SGD(model.parameters(), lr=0.1)  # 使用随机梯度下降,学习率0.1
loss_fn = nn.CrossEntropyLoss()

# 模拟一个极简的训练步骤(1个batch,更新1次)
print("=== 训练前 ===")
logits_before = model(X)
loss_before = loss_fn(logits_before, y)
print(f"损失 loss_before = {loss_before.item():.4f}")

# 训练步骤开始
optimizer.zero_grad()  # 重要!清空上一轮计算的梯度
loss_before.backward() # 反向传播,计算梯度
optimizer.step()       # 优化器根据梯度更新参数

print("\n=== 执行 optimizer.step() 后 ===")
logits_after = model(X)
loss_after = loss_fn(logits_after, y)
print(f"损失 loss_after = {loss_after.item():.4f}")
print(f"损失变化: {loss_before.item():.4f} -> {loss_after.item():.4f} (期望下降)")
=== 训练前 ===
损失 loss_before = 1.2862

=== 执行 optimizer.step() 后 ===
损失 loss_after = 1.2295
损失变化: 1.2862 -> 1.2295 (期望下降)

可以看到,执行一次 optimizer.step() 后,损失有所下降,说明参数更新朝着减少损失的方向进行。

完整的训练循环

将以上步骤组合起来,就构成了深度学习中最核心的训练循环。我们通常会在多个轮次(Epoch) 上遍历整个训练数据集,每个批次(Batch)执行一次参数更新。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

class SimpleMLP(nn.Module):
    """两层全连接 + ReLU;本块内定义,保证块自包含。"""
    def __init__(self, input_dim=10, hidden_dim=5, num_classes=3):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, num_classes)
        self.relu = nn.ReLU()

    def forward(self, x):
        return self.fc2(self.relu(self.fc1(x)))


torch.manual_seed(0)
X = torch.randn(4, 10)          # 本块内重建同一份小批量数据(固定种子)
y = torch.tensor([0, 2, 1, 0])

torch.manual_seed(0)

# 1. 准备组件
model = SimpleMLP()
optimizer = optim.SGD(model.parameters(), lr=0.1)
loss_fn = nn.CrossEntropyLoss()

# 2. 模拟一个极简的训练循环(2个epoch,每个epoch 1个batch)
print("开始训练循环...")
for epoch in range(2):
    # 假设每个epoch我们都用同样的数据X, y(实际中会打乱和分批)
    optimizer.zero_grad()
    logits = model(X)
    loss = loss_fn(logits, y)
    
    loss.backward()
    optimizer.step()
    
    print(f"Epoch {epoch+1}: loss = {loss.item():.4f}")

# 3. 训练后评估(同样用X,实际应用应在未见过的数据上评估)
model.eval()  # 将模型设置为评估模式(影响Dropout、BatchNorm等层的行为)
with torch.no_grad():  # 评估时不计算梯度,节省内存
    final_logits = model(X)
    final_loss = loss_fn(final_logits, y)
    predictions = final_logits.argmax(dim=-1)  # 取概率最大的类别作为预测
    accuracy = (predictions == y).float().mean()
    
print(f"\n训练后评估:")
print(f"  最终损失 = {final_loss.item():.4f}")
print(f"  预测类别 = {predictions.tolist()}")
print(f"  真实类别 = {y.tolist()}")
print(f"  准确率 = {accuracy.item():.4f}")
开始训练循环...
Epoch 1: loss = 1.2862
Epoch 2: loss = 1.2295

训练后评估:
  最终损失 = 1.1918
  预测类别 = [2, 2, 2, 2]
  真实类别 = [0, 2, 1, 0]
  准确率 = 0.2500

在这个极简的例子中,由于数据随机、模型简单且训练步数极少,准确率不高。但这完整演示了数据、模型、目标函数和优化器如何在一个循环中协同工作。

📝 动手练一练

问题:将上面训练循环中的优化器从 SGD 改为 Adam,并保持学习率 lr=0.01,运行 2 个 epoch。观察损失下降的速度和最终准确率与 SGD 有何不同?(提示:Adam 是一种自适应学习率的优化器,通常收敛更快。)

👉 点击查看参考答案
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

class SimpleMLP(nn.Module):
    """两层全连接 + ReLU;本块内定义,保证块自包含。"""
    def __init__(self, input_dim=10, hidden_dim=5, num_classes=3):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, num_classes)
        self.relu = nn.ReLU()

    def forward(self, x):
        return self.fc2(self.relu(self.fc1(x)))


torch.manual_seed(0)

# 数据
X = torch.randn(4, 10)
y = torch.tensor([0, 2, 1, 0])

# 使用 Adam 优化器(重置种子,确保与 SGD 示例从同一初始参数出发)
torch.manual_seed(0)
model = SimpleMLP()
optimizer = optim.Adam(model.parameters(), lr=0.01)  # 改用 Adam,学习率通常设得更小
loss_fn = nn.CrossEntropyLoss()

print("使用 Adam 优化器训练...")
for epoch in range(2):
    optimizer.zero_grad()
    logits = model(X)
    loss = loss_fn(logits, y)
    loss.backward()
    optimizer.step()
    print(f"Epoch {epoch+1}: loss = {loss.item():.4f}")

# 评估
model.eval()
with torch.no_grad():
    final_logits = model(X)
    final_loss = loss_fn(final_logits, y)
    predictions = final_logits.argmax(dim=-1)
    accuracy = (predictions == y).float().mean()
    
print(f"\nAdam 训练后评估:")
print(f"  最终损失 = {final_loss.item():.4f}")
print(f"  预测类别 = {predictions.tolist()}")
print(f"  准确率 = {accuracy.item():.4f}")
使用 Adam 优化器训练...
Epoch 1: loss = 1.2862
Epoch 2: loss = 1.2405

Adam 训练后评估:
  最终损失 = 1.2045
  预测类别 = [2, 2, 2, 2]
  准确率 = 0.2500

思路:把优化器换成 Adam、学习率降到 0.01,其余保持不变(数据与模型都用 torch.manual_seed(0) 重建,两种优化器从同一组初始参数出发)。对照两个印刷块:第 1 个 epoch 的损失完全相同(1.2862),第 2 个 epoch 时 Adam 为 1.2405、SGD 为 1.2295;训练后评估的最终损失 Adam 为 1.2045、SGD 为 1.1918。

也就是说,在这个只更新两次参数的极小例子里,Adam 反而略慢。原因不是 Adam 更差,而是它的自适应步长要靠累积梯度的一阶矩、二阶矩估计才能发挥作用,2 次更新远不足以体现优势;「Adam 通常收敛更快」是统计规律,并非每一步都更快。想观察到差异,需要把 epoch 增加到 20 以上、把 hidden_dim 或样本量调大——步数一多,Adam 对学习率尺度不敏感的优势才会显现。

常见错误

错误理解或写法现象或报错原因正确做法
忘记 optimizer.zero_grad()损失不下降或波动异常;梯度值异常大。PyTorch 会累积梯度。如果不清零,本次计算的梯度会与历史梯度相加,导致更新方向错误。在每个训练步骤(step())前,务必调用 optimizer.zero_grad()。
将 nn.CrossEntropyLoss 的输入误以为是概率损失计算错误或出现数值不稳定(如 NaN)。nn.CrossEntropyLoss 设计为直接接收未归一化的 logits,它在内部高效地结合了 softmax 和交叉熵计算。直接将模型输出的 logits 传给 nn.CrossEntropyLoss,不要手动做 softmax。
在训练循环内使用 torch.no_grad()模型参数无法更新,损失不变。torch.no_grad() 上下文管理器会禁用梯度计算,导致 loss.backward() 无法生成梯度。torch.no_grad() 只应用于不需要计算梯度的环节,如模型评估、推理。训练时必须在其外部。
混淆 model.train() 和 model.eval()在评估时得到与训练时不一致的随机结果(如使用 Dropout 时)。某些层(如 nn.Dropout, nn.BatchNorm)在训练和评估时的行为不同。model.train() 和 model.eval() 用于切换这些模式。训练前调用 model.train(),评估前调用 model.eval()。

本章小结

  1. 深度学习训练围绕数据(X, y)、模型(f)、目标函数(loss) 三要素展开。
  2. 模型是一个包含可学习参数(权重、偏置)的函数,它将输入数据映射为预测值(如 logits)。
  3. 目标函数(如交叉熵损失)量化预测与真实的差距,训练的目标是最小化它。
  4. 优化器(如 SGD、Adam)利用损失函数反向传播得到的梯度,按照特定算法更新模型参数。
  5. 标准的训练循环包括:前向传播计算损失、反向传播计算梯度、优化器更新参数,并循环多个轮次。
  6. 注意常见陷阱,如梯度清零、正确使用损失函数和模型模式切换。

📋 行动清单

  • 运行本节第一个代码块,将 batch_size 从 4 改为 2,观察 X 和 y 形状的变化。
  • 在“完整的训练循环”代码块中,将 epoch 循环次数增加到 5,观察损失和准确率的变化趋势。
  • 尝试修改 SimpleMLP 类的 hidden_dim(例如改为 10 或 20),重新运行训练循环,感受模型容量对学习的影响。

延伸阅读

  1. PyTorch optim.SGD 官方文档(2024,对应 PyTorch 2.x) – https://pytorch.org/docs/stable/generated/torch.optim.SGD.html
  2. PyTorch nn.CrossEntropyLoss 官方文档(2024) – https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

—— 小象教研组