第三章:完整训练与评估循环 (核心实战)

万事俱备,只欠东风。这“东风”就是一个规范、健壮的训练循环(Training Loop)。本章的目标,是让你能够独立编写出一个工业级的训练脚本,能处理从模型训练、性能验证到断点续训的全过程。我们将以第二章构建的 LeNet 模型和飞桨内置的 MNIST 数据集为例,贯穿本章所有实战环节。

3.1 标准训练循环 (The Training Loop)

训练循环的本质,就是让模型不断地“看”数据,并通过“犯错-反思-修正”的过程,逐步优化自身的参数。这个过程被组织成 EpochsSteps (或 Iterations):

  • Epoch (时代):代表模型完整地看过一遍所有训练数据。例如,训练集有 10000 张图片,batch_size 为 100,那么一个 Epoch 就包含 10000 / 100 = 100Step。进行多个 Epoch 的训练是深度学习的常态。
  • Step/Iteration (步/迭代):代表模型处理一个 batch 的数据。每一次 Step,模型的参数都会被更新一次。

3.1.1 实战:model.train() —— 开启“学习模式”

在开始训练循环之前,必须做的第一件事就是调用 model.train()。这行代码看似简单,却至关重要。它并非一个可有可无的标记,而是会切实地改变模型中某些层的行为模式。

模式 model.train() (训练模式) model.eval() (评估模式)
Dropout 层 激活。在前向传播时,会以一定概率随机地将部分神经元的输出置为 0,用于防止过拟合。 关闭。所有神经元都会参与计算,以确保模型输出的确定性和稳定性。
BatchNorm 层 使用当前批次数据的均值和方差来做归一化,同时会持续更新全局的均值和方差统计量。 使用在整个训练过程中学习到的全局均值和方差来进行归一化,且不会更新它们。

一句话总结model.train() 确保了 Dropout 和 BatchNorm 等层处于它们在训练时应有的状态。忘记调用它,可能会导致模型不收敛或性能远低于预期。

3.1.2 实战:训练的“五步法” (The Five-Step Method)

每一个 Step 的核心,都可以被精炼为一套标准化的“五步法”。掌握了这个流程,你就掌握了所有监督学习任务的训练核心。

  1. 前向传播 (Forward Pass):将一个 batch 的数据输入模型,计算得到预测结果。
    predictions = model(inputs)
  2. 计算损失 (Compute Loss):使用损失函数,比较模型的预测结果和真实标签,计算出损失值 loss
    loss = loss_fn(predictions, labels)
  3. 反向传播 (Backward Pass):从 loss 开始,自动计算出模型中每一个可学习参数相对于损失值的梯度(gradient)。
    loss.backward()
  4. 更新参数 (Update Parameters):优化器根据上一步计算出的梯度,来更新模型的参数。
    optimizer.step()
  5. 梯度清零 (Clear Gradients):清除上一步计算的梯度,为下一次迭代做准备。
    optimizer.clear_grad()

易错点对比:clear_grad() vs zero_grad()

  • PaddlePaddle: optimizer.clear_grad()
  • PyTorch: optimizer.zero_grad()

两者功能完全相同,但名称不同。这是从 PyTorch 迁移过来的开发者最容易犯的错误之一。忘记清零会导致梯度在多次迭代中累加,使得参数更新方向完全错误,模型无法收敛。

完整的 MNIST 训练循环示例:

现在,我们将所有概念整合到一个完整的、可运行的脚本中。

import paddle
import paddle.nn.functional as F
from paddle.vision.datasets import MNIST
from paddle.vision.transforms import ToTensor
from paddle.io import DataLoader

# --- 1. 准备工作 (复习第二章内容) ---

# 定义 LeNet 模型 (代码与第二章相同)
class LeNet(paddle.nn.Layer):
    def __init__(self, num_classes=10):
        super(LeNet, self).__init__()
        self.conv1 = paddle.nn.Conv2D(in_channels=1, out_channels=6, kernel_size=5)
        self.pool1 = paddle.nn.MaxPool2D(kernel_size=2, stride=2)
        self.conv2 = paddle.nn.Conv2D(in_channels=6, out_channels=16, kernel_size=5)
        self.pool2 = paddle.nn.MaxPool2D(kernel_size=2, stride=2)
        self.fc1 = paddle.nn.Linear(in_features=16 * 4 * 4, out_features=120)
        self.fc2 = paddle.nn.Linear(in_features=120, out_features=84)
        self.fc3 = paddle.nn.Linear(in_features=84, out_features=num_classes)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.pool1(x)
        x = F.relu(self.conv2(x))
        x = self.pool2(x)
        x = paddle.flatten(x, start_axis=1)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

# 设置设备
device = paddle.set_device('gpu' if paddle.is_compiled_with_cuda() else 'cpu')

# 准备数据
transform = ToTensor() # 将数据从 HWC 转为 CHW,并归一化到 [0, 1]
train_dataset = MNIST(mode='train', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)

# 实例化模型、损失函数、优化器
model = LeNet().to(device)
loss_fn = paddle.nn.CrossEntropyLoss()
optimizer = paddle.optimizer.Adam(learning_rate=0.001, parameters=model.parameters())

# --- 2. 核心:标准训练循环 ---

NUM_EPOCHS = 5
total_steps = len(train_loader)

for epoch in range(NUM_EPOCHS):
    # **** 关键步骤 1: 切换到训练模式 ****
    model.train()
    
    for step, (images, labels) in enumerate(train_loader):
        # 将数据移动到指定设备
        images = images.to(device)
        labels = labels.to(device)
        
        # **** 核心五步法 ****
        # 1. 前向传播
        outputs = model(images)
        
        # 2. 计算损失
        loss = loss_fn(outputs, labels)
        
        # 3. 反向传播
        loss.backward()
        
        # 4. 更新参数
        optimizer.step()
        
        # 5. 梯度清零
        optimizer.clear_grad()
        
        # 打印训练信息
        if (step + 1) % 100 == 0:
            print(f"Epoch [{epoch+1}/{NUM_EPOCHS}], Step [{step+1}/{total_steps}], Loss: {loss.item():.4f}")

print("训练完成!")

3.1.3 排错:显存 OOM (Out of Memory)

CUDA error: out of memory 是 GPU 编程中最令人头疼的错误之一。它意味着你的模型、数据或中间变量所需的显存,超过了你显卡的物理显存容量。

常见原因与解决方案:

  1. batch_size 过大 (最常见)

    • 原因: batch_size 直接决定了每个 step 中有多少数据被一次性加载到显存。更大的 batch_size 意味着更大的激活图(activations),消耗显存也随之线性增长。
    • 排查: 在终端运行 watch -n 1 nvidia-smi 实时监控显存占用。
    • 解决: 减小 batch_size 是最直接有效的办法。从一个较小的值(如 8 或 16)开始尝试,逐步增大,直到接近显存上限。如果 batch_size 减小后模型效果变差,可以考虑使用“梯度累积”技术来模拟大 batch_size 的效果。
  2. Tensor 未被及时释放

    • 原因: 在动态图模式下,Python 的垃圾回收机制通常能很好地处理不再需要的 Tensor。但如果你在循环中,无意间将 Tensor(尤其是那些还连接着计算图的 Tensor)存储在一个全局列表或字典中,它们占用的显存就无法被释放。
    • 解决: 检查代码中是否有类似 history.append(loss) 的操作。如果你只是想记录 loss 的数值,请使用 history.append(loss.item()).item() 会将其转换为一个普通的 Python 数字,从而切断与计算图的连接,使其可以被正常回收。
  3. 模型本身过大

    • 原因: 复杂的模型(如层数很深、通道数很宽)本身就包含大量的参数,这些参数本身就要占用大量显存。
    • 解决:
      • 换用显存更大的 GPU(成本高)。
      • 使用更轻量级的模型架构。
      • 使用混合精度训练(AMP, paddle.amp),可以用一半的显存存储参数和激活值,同时通常能加速训练。

3.2 评估与验证循环

只训练不评估,就像闭门造车。我们需要一个独立的验证集(或测试集)来客观地评价模型在“未见过”的数据上的表现,以此来判断模型是否过拟合,并作为调参的依据。

评估循环与训练循环的核心区别在于:只进行前向传播,不进行反向传播和参数更新。

3.2.1 实战:model.eval() —— 切换到“考试模式”

model.train() 对应,在开始评估前,我们必须调用 model.eval()。如前文表格所述,这将关闭 Dropout 并固定 BatchNorm 的行为,保证评估结果的确定性和可复现性。

3.2.2 难点:在 paddle.no_grad() 环境下进行评估

这是评估循环中的一个关键优化。paddle.no_grad() 是一个上下文管理器,它会告诉飞桨:在这个代码块里,不要构建计算图

这会带来两大好处:

  1. 节省显存: 因为不需要记录用于反向传播的中间变量(激活值),显存消耗会大幅降低。这常常允许我们在评估时使用比训练时大得多的 batch_size,从而加快评估速度。
  2. 提升速度: 省去了构建计算图的开销,前向传播的计算速度会更快。
# 准备验证数据
val_dataset = MNIST(mode='test', transform=transform)
val_loader = DataLoader(val_dataset, batch_size=128) # 评估时可用更大 batch_size

# --- 核心:标准评估循环 ---

# **** 关键步骤 1: 切换到评估模式 ****
model.eval()

correct = 0
total = 0

# **** 关键步骤 2: 使用 no_grad 上下文 ****
with paddle.no_grad():
    for images, labels in val_loader:
        images = images.to(device)
        labels = labels.to(device)
        
        # 1. 前向传播
        outputs = model(images)
        
        # 2. 计算评估指标 (例如: 准确率)
        # outputs.shape is [batch_size, num_classes]
        # 我们需要找到每个样本预测概率最高的那个类别的索引
        _, predicted = paddle.max(outputs, axis=1)
        
        total += labels.shape[0]
        correct += (predicted == labels).sum().item()

accuracy = 100 * correct / total
print(f"在 {total} 张测试图片上的准确率: {accuracy:.2f} %")

# 评估结束后,如果还需要继续训练,记得切回 model.train()
# model.train()

3.3 模型的保存与加载 (重点)

辛苦训练得到的模型,如果不保存下来,程序一关就前功尽弃了。模型的保存与加载是深度学习项目中必不可少的一环,主要用于:

  • 推理部署: 将训练好的模型用于实际预测。
  • 断点续训: 在训练意外中断(如断电、程序崩溃)后,能从上次的进度继续训练,避免从头再来。
  • 分享与复现: 将模型分享给他人。

飞桨提供了两种主要的保存方式。

3.3.1 实战:保存与加载“模型参数” (推荐方式)

这种方式只保存模型的可学习参数(权重和偏置),不保存模型结构。这是最常用、最灵活、最推荐的方式。

  • model.state_dict(): 返回一个 Python 字典,键是层的名字(如 conv1.weight),值是对应的参数 Tensor
  • paddle.save(): 将这个字典保存到磁盘,通常以 .pdparams 为后缀。
  • paddle.load(): 从磁盘加载参数字典。
  • model.set_state_dict(): 将加载的参数字典应用到模型实例上。

代码示例:

# --- 保存模型参数 ---
save_path = "lenet.pdparams"
paddle.save(model.state_dict(), save_path)
print(f"模型参数已保存到 {save_path}")


# --- 加载模型参数 ---

# 1. 必须先创建一个和保存时结构完全相同的模型实例
new_model = LeNet()

# 2. 加载参数字典
loaded_state_dict = paddle.load(save_path)

# 3. 将参数应用到新模型
new_model.set_state_dict(loaded_state_dict)

# 4. 同样,加载后要调用 .eval() 进行预测
new_model.eval()
# ... 接下来就可以用 new_model 进行预测了

优势: 这种方式解耦了模型代码和模型权重。只要你有模型的类定义,就可以加载权重。这使得代码的重构和模型的迁移变得非常容易。

3.3.2 实战:保存与加载“整个模型” (用于断点续训)

这种方式不仅保存参数,还使用 Python 的 pickle 技术将模型对象本身(包括其结构)序列化到磁盘。

代码示例:

# --- 保存整个模型 ---
# 注意:飞桨官方建议保存整个模型时,将模型切换到 eval 模式
model.eval() 
paddle.save(model, "lenet_full.pdmodel")
print("整个模型已保存")

# --- 加载整个模型 ---
loaded_model = paddle.load("lenet_full.pdmodel")

# 加载后可以直接使用
loaded_model.eval()
# ... 进行预测

适用场景:
这种方式的最大优点是便利,尤其适合快速地进行断点续训。但它的缺点也很明显:它将代码结构和权重紧紧地绑定在一起,如果你的项目文件结构发生改变,或者 LeNet 类的定义被移动到其他 .py 文件,加载时就可能失败。

最佳实践:创建 Checkpoint 进行断点续训

在长时间的训练任务中,一个专业的做法是保存一个检查点 (Checkpoint)。这个检查点是一个字典,不仅包含模型参数,还应包含优化器的状态、当前的训练轮数 (epoch)、以及目前最好的验证集分数等。

# 在训练循环的每个 epoch 结束或验证集性能提升时保存
best_accuracy = 0.0

# ... 在验证循环后 ...
# if accuracy > best_accuracy:
#     best_accuracy = accuracy
#     checkpoint = {
#         'epoch': epoch + 1,
#         'model_state_dict': model.state_dict(),
#         'optimizer_state_dict': optimizer.state_dict(),
#         'best_accuracy': best_accuracy,
#         'loss': loss.item(),
#     }
#     paddle.save(checkpoint, 'best_model_checkpoint.pdparams')
#     print(f"发现更优模型,已保存 Checkpoint at epoch {epoch+1}")


# --- 如何从 Checkpoint 恢复训练 ---
# resume_training = True
# if resume_training:
#     checkpoint = paddle.load('best_model_checkpoint.pdparams')
    
#     model.set_state_dict(checkpoint['model_state_dict'])
#     optimizer.set_state_dict(checkpoint['optimizer_state_dict'])
#     start_epoch = checkpoint['epoch']
#     best_accuracy = checkpoint['best_accuracy']
    
#     print(f"从 epoch {start_epoch} 恢复训练...")
#     # ... 接下来修改 for 循环,从 start_epoch 开始
#     # for epoch in range(start_epoch, NUM_EPOCHS):
#     #     ...

通过保存和加载 Checkpoint,你可以让训练任务变得无比健壮,即使面对数天乃至数周的长时间训练,也无需担心意外中断带来的损失。

至此,你已经完整掌握了使用 PaddlePaddle 进行模型训练、评估和管理的全套流程。你已经具备了独立完成一个端到-端深度学习项目的核心能力。下一章,我们将把目光投向更广阔的天地,探索飞桨强大的生态系统,学习如何利用预训练模型和开发套件,以十倍、百倍的效率解决真实世界的复杂问题。

PaddlePaddle 详细学习笔记 其它章节 链接

以下是整个系列的5章目录,点击章节标题即可跳转阅读(所有章节统一使用“DeepSpeed 详细学习笔记 第X章”格式;若链接暂未索引,可直接访问作者博客搜索相应标题获取最新链接)。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐