Skip to content

第 11 章 训练、评估与保存

学习目标

  • 理解训练集与验证集划分的意义
  • 掌握 model.train()model.eval() 的区别
  • 掌握 torch.no_grad() 下的评估与准确率计算
  • 掌握 torch.save / load_state_dict 保存和恢复模型
  • 掌握设备管理:device.to(device)

11.1 为什么要把数据分成训练集和验证集

前面的例子都用全部数据训练,再用同一批数据算准确率——这只能说明「模型记住了数据」,不能说明「模型学会了规律」。真实做法是划分数据集:

  • 训练集:训练时更新参数;
  • 验证集(validation set):训练中每轮检查模型对没见过数据的表现。

验证集准确率接近训练集,说明模型泛化良好;验证集远差于训练集,说明过拟合(背数据)。先把数据打乱,再按比例切开:

python
import torch

torch.manual_seed(42)
n = 600
x0 = torch.randn(n // 2, 2) + torch.tensor([-1.5, 0.0])
x1 = torch.randn(n // 2, 2) + torch.tensor([1.5, 0.0])
X = torch.cat([x0, x1])
y = torch.cat([torch.zeros(n // 2), torch.ones(n // 2)]).long()

perm = torch.randperm(n)          # 随机顺序
X, y = X[perm], y[perm]

n_train = 420
X_train, y_train = X[:n_train], y[:n_train]
X_val, y_val = X[n_train:], y[n_train:]
print("训练集:", X_train.shape, "验证集:", X_val.shape)

输出:

训练集: torch.Size([420, 2]) 验证集: torch.Size([180, 2])

torch.randperm(n) 生成 0~n-1 的随机排列,用它对数据洗牌,保证两类样本均匀混入两个集合。

11.2 model.train() 与 model.eval()

模型有两种模式:

  • model.train():训练模式。启用 Dropout、BatchNorm 等层的行为;
  • model.eval():评估模式。关掉随机行为,用稳定的参数推理。

没有 Dropout/BatchNorm 的模型(如本书的 MLP、CNN)两种模式计算结果相同,但养成习惯:训练循环开始前 model.train(),评估前 model.eval()。等以后用 Dropout 时,漏调 eval() 会让评估结果随机抖动。

11.3 no_grad 下的评估

评估(推理)不需要梯度:不用 backward,也不需要保存计算图。用 torch.no_grad() 包住,既省内存又更快(第 6 章)。

写一个可复用的评估函数:

python
import torch

def evaluate(model, X, y):
    model.eval()
    with torch.no_grad():
        logits = model(X)
        acc = (logits.argmax(1) == y).float().mean().item()
    return acc

logits.argmax(1) 取每个样本分数最高的类别,与真实标签比较,平均后就是准确率。

11.4 完整示例:训练 + 每轮评估

用 2 维合成数据(两个有重叠的高斯簇)训练一个 MLP,每轮同时打印训练集和验证集准确率:

python
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(42)
n = 600
x0 = torch.randn(n // 2, 2) * 1.0 + torch.tensor([-1.5, 0.0])
x1 = torch.randn(n // 2, 2) * 1.0 + torch.tensor([1.5, 0.0])
X = torch.cat([x0, x1])
y = torch.cat([torch.zeros(n // 2), torch.ones(n // 2)]).long()
perm = torch.randperm(n)
X, y = X[perm], y[perm]
X_train, y_train = X[:420], y[:420]
X_val, y_val = X[420:], y[420:]

train_loader = DataLoader(TensorDataset(X_train, y_train),
                          batch_size=32, shuffle=True)
model = nn.Sequential(
    nn.Linear(2, 16),
    nn.ReLU(),
    nn.Linear(16, 2),
)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

def evaluate(model, X, y):
    model.eval()
    with torch.no_grad():
        logits = model(X)
        acc = (logits.argmax(1) == y).float().mean().item()
    return acc

for epoch in range(20):
    model.train()
    for batch_x, batch_y in train_loader:
        logits = model(batch_x)
        loss = loss_fn(logits, batch_y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    if epoch % 4 == 0 or epoch == 19:
        train_acc = evaluate(model, X_train, y_train)
        val_acc = evaluate(model, X_val, y_val)
        print(f"epoch {epoch}: train_acc={train_acc:.3f} val_acc={val_acc:.3f}")

输出:

epoch 0: train_acc=0.924 val_acc=0.917
epoch 4: train_acc=0.943 val_acc=0.928
epoch 8: train_acc=0.943 val_acc=0.922
epoch 12: train_acc=0.943 val_acc=0.928
epoch 16: train_acc=0.943 val_acc=0.928
epoch 19: train_acc=0.945 val_acc=0.928

两个簇有重叠,理论上限约 0.93,所以验证集在 0.93 附近不再上涨是正常的——不是没训好,是数据本身可分性有限。

11.5 保存与加载模型

训练结束后,把模型参数存到磁盘,下次直接用:

python
torch.save(model.state_dict(), "model.pt")

state_dict() 是「参数名 → 参数张量」的字典,保存它(而不是整个模型对象)是标准做法。加载时先建一个结构完全相同的模型,再把参数填进去:

python
model2 = nn.Sequential(
    nn.Linear(2, 16),
    nn.ReLU(),
    nn.Linear(16, 2),
)
model2.load_state_dict(torch.load("model.pt", weights_only=True))
print("加载后验证集准确率:", evaluate(model2, X_val, y_val))

输出:

加载后验证集准确率: 0.9277777671813965

加载后的模型与训练完的模型表现一致。weights_only=True 是安全选项:只允许加载张量,防止恶意文件在加载时执行任意代码,加载不可信来源的模型文件时务必带上。

注意:保存的文件不包含模型结构。结构不同就加载会报错(见常见错误),所以要么保存 state_dict 时顺手把结构写在代码里,要么保存整个模型对象 torch.save(model, "model.pt")(不推荐,兼容性差)。

11.6 设备管理:CPU 与 GPU

第 1 章提过 device。通用写法是「有 CUDA 用 CUDA,没有用 CPU」:

python
import torch

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)

在本机输出:

cuda

无 GPU 的机器输出 cpu。把模型和张量搬到设备上:

python
model.to(device)
x = torch.ones(2).to(device)
print(x.device)

在本机输出:

cuda:0

规则:参与同一运算的张量和模型必须在同一设备。CPU 张量与 GPU 模型相乘会报 RuntimeError: Expected all tensors to be on the same device。训练脚本的标准做法:定义 device 后,对模型和数据都调用 .to(device)

动手实践

  1. 把 11.4 的完整示例复制运行,记录每轮的 train_acc 与 val_acc,说明两者为什么接近。
  2. 在 11.4 里故意删掉 model.eval()torch.no_grad(),观察运行是否仍然正确,解释这样做的隐患。
  3. 训练后保存 state_dict,新建相同结构模型加载,打印验证集准确率确认一致。
  4. 故意把加载用的模型结构改成 nn.Linear(2, 8)(层维度不同),运行 load_state_dict,记录报错并解释。
  5. 在代码开头加上 device 判断,把模型和一批数据搬到 GPU(或 CPU)上训练,验证 x.device 与模型参数设备一致。

常见错误

错误写法现象原因
评估时忘了 model.eval()有 Dropout 时结果随机抖动评估必须切到 eval 模式
评估时忘了 torch.no_grad()内存上涨、速度慢推理不需要计算图
load_state_dict 但结构不同RuntimeError: Error(s) in loading state_dict ... size mismatch加载模型的层必须与保存时完全一致
保存整个模型对象再用反序列化慢、升级后不兼容state_dict 保存参数
CPU 张量喂给 GPU 模型RuntimeError: Expected all tensors to be on the same device数据与模型都要 .to(device)
标签没转 long交叉熵报 dtype 错误分类标签用 .long()

章末练习

基础

  1. torch.randperm 把 100 个样本打乱,取前 70 个做训练集、后 30 个做验证集,打印两个集合的形状。
  2. 写一个 evaluate 函数(接收 model、X、y,返回准确率),并说明为什么要包 torch.no_grad()
  3. 训练一个简单模型,保存 state_dictmodel.pt,再加载并输出验证准确率。

提高

  1. 解释 model.train()model.eval() 的区别,并说明为什么评估时必须用 eval()
  2. 把 11.4 的训练轮数改成 200,观察 train_acc 与 val_acc 的差距是否拉大;结合「过拟合」概念解释你看到的现象。

挑战

  1. 在 11.4 里加一个「最佳验证准确率」跟踪:每轮评估后,如果比历史最好还高,就保存 state_dict;训练结束后加载最优模型再评估,并与最后一个 epoch 的模型对比。
  2. 写一个脚本:在 CPU 上训练 5 轮计时,再把模型和数据搬到 GPU 上训练 5 轮计时(本机有 CUDA 时),比较耗时并解释差异。

章末自测

每题选择一个最佳答案。本书不附答案:完成后交由老师或 AI 老师批改讲解。

  1. 验证集的作用是?
    • A. 参与训练更新参数
    • B. 检查模型对没见过数据的表现
    • C. 让模型记住更多数据
    • D. 替代训练集
  2. 评估模型前应该调用?
    • A. model.train()
    • B. model.eval()
    • C. model.backward()
    • D. model.step()
  3. 推理时包 torch.no_grad() 的目的是?
    • A. 让结果更准
    • B. 不建计算图,省内存提速
    • C. 必须包,否则报错
    • D. 让模型不可训练
  4. 计算分类准确率时,logits.argmax(1) 返回的是?
    • A. 每个样本分数最大的类别
    • B. 每个样本的分数
    • C. 损失值
    • D. 概率分布
  5. 保存模型参数的标准做法是?
    • A. torch.save(model, "model.pt")
    • B. torch.save(model.state_dict(), "model.pt")
    • C. model.save("model.pt")
    • D. torch.save(model.parameters(), "model.pt")
  6. 加载 state_dict 前,必须先?
    • A. 什么都不用做
    • B. 创建结构完全相同的模型
    • C. 把模型训练一轮
    • D. 调用 model.eval()
  7. 加载结构不同的模型会?
    • A. 自动适配
    • B. 报 RuntimeError: size mismatch
    • C. 静默丢弃多余参数
    • D. 训练后自动修复
  8. torch.load("model.pt", weights_only=True)weights_only=True 的作用是?
    • A. 只加载参数,防止恶意代码执行
    • B. 加载更快
    • C. 只加载结构
    • D. 压缩文件
  9. CPU 张量与 GPU 模型运算会?
    • A. 自动迁移
    • B. 报 RuntimeError: Expected all tensors to be on the same device
    • C. 静默出错
    • D. 结果正确
  10. 通用设备判断写法是?
    • A. device = torch.device("gpu")
    • B. device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    • C. device = torch.cuda.device()
    • D. device = "gpu"