2.数据集划分与指标管理
第 2 章 — 训练/验证/测试三段划分与指标管理
第 1 章我们用最少的代码跑通了 CIFAR-10 的训练流程,但留下两个隐患:
所谓"验证集"其实用的是官方 测试集 ,每个 epoch 都对它算指标,等于高考前先把真题答案看了几遍;
指标打印完就丢,无法复盘"模型到底怎么一步步变好的"。
本章就解决这两件事:把数据切成 训练 / 验证 / 测试 三段,各司其职;再用一个简单的
defaultdict把每个 epoch 的指标记录下来,为后面画曲线、做可视化、调参打基础。
一、本章要解决的问题
- 之前:第 1 章的"验证集"名不副实——它装载的是官方测试集,训练过程中反复碰它,测出的分数有水分;指标只打印在终端上,看完就没了,无法分析训练过程。
- 现在:我们把官方训练集 50000 张再随机切出 5000 张作为 验证集 (45000 训练 / 5000 验证),验证集只用于训练过程中的模型选择;官方测试集 10000 张彻底冻结,只在训练结束后用一次做最终评估;同时把每个 epoch 的训练/验证指标存进
metrics字典,随时可以复盘。 - 判断标准:终端能看到
Train/Val/Test三段指标;代码里能查询到每个 epoch 的历史指标列表;训练过程中模型选择只依据验证集,不再偷看测试集。
二、核心概念速览
下面 7 个概念是本章代码的全部"生词"。老概念(张量、卷积、损失、优化器等)已在第 1 章讲过,这里只讲新东西。先花 5 分钟读完,再看代码会轻松很多。
1. 训练集 / 验证集 / 测试集
把整个学习过程想象成准备高考:训练集 = 平时做练习题,模型在这里反复刷题、学知识(更新参数);验证集 = 模拟考,考完对答案,看看自己哪里薄弱,然后调整"复习策略"(调学习率、改网络结构、决定什么时候停);测试集 = 最终高考,只考一次,考前绝对不能碰。三者的关键铁律是数据不能重叠——如果验证集里有训练时见过的题,就等于"泄题",成绩虚高。
2. 泛化能力
模型在 没见过的数据 上依然表现好的能力,简称"举一反三"。一个学生如果只会背练习册的答案,换道新题就懵,那叫死记硬背;泛化好的学生是真正掌握了规律。深度学习的目标从来不是记住训练集,而是让模型面对新样本也能预测得准。训练集准确率高但新数据准确率低,就是泛化能力差。
3. 过拟合(overfitting)
直观理解:模型把训练集 背下来 了,而不是 学会 了。具体表现是训练集准确率一路走高甚至接近 100%,但验证/测试准确率明显偏低,两者差距越拉越大。类比:考前把练习册从头到尾背得滚瓜烂熟,结果考试换了个题型就露馅。过拟合是深度学习的头号敌人,后面几章会反复围绕它做文章。
4. random_split 随机切分
PyTorch 自带的"切数据"工具:把一个大数据集按指定大小随机切成几份,返回切好的子数据集。用法是 train_dataset, val_dataset = random_split(data, [45000, 5000])。注意它是 随机 切分,每次运行结果可能不同;想要可复现需要设置随机种子(见本章"知识补充")。类比:把一沓练习册随机分成"平时练的"和"模拟考的"两摞,保证两份内容不重复。
5. accuracy 与 loss 的区别
- loss:一个连续数值,衡量"模型错得有多惨"。错一点点和错得离谱,loss 能精细区分;它是"可导的",所以梯度下降靠它来更新参数。
- accuracy:一个粗略的比例,"100 张里对了 87 张"。直观、好理解,但它不可导,没法用来更新参数。 类比:考完试先看总分(loss),再看及格率(accuracy)。总分是过程指标(驱动学习),及格率是结果指标(展示水平)。训练时两者都要看:loss 决定参数怎么调,accuracy 告诉我们模型实际有多准。
6. defaultdict 字典
collections 里字典的一个"温柔版":访问不存在的键时不会报 KeyError,而是自动创建这个键并赋予默认值。本章用法:metrics = defaultdict(list),于是 metrics["train_loss"] 第一次访问就是一个空列表,直接 append 即可。类比:一个会自动给新抽屉贴好标签、放好空本子的文件柜,随手一拿就是能用的本子。如果用普通字典 {},访问不存在的键会直接报错(动手练习 4 让你亲自体会)。
7. 为什么测试集只能最后用一次
因为每次碰测试集都等于偷看高考答案。只要你根据测试集的结果调整了任何东西(改学习率、换网络、多训几个 epoch),这个"答案"就不再是未知的了,测试集分数会被你"调"高,失去公正性。所以测试集必须全程冻结、只在最终评估时用一次。这正是本章引入验证集的根本原因:把"模拟考"和"高考"分开,用验证集做日常调参,把测试集留给最后那一次"定胜负"。
三、解决思路
- 三段划分:官方训练集 50000 张,用
random_split切出 5000 张当验证集,剩下 45000 张当训练集;官方测试集 10000 张原样保留。三个DataLoader各司其职。 - 两个函数:
train_one_epoch(更新参数)和validate(只算指标)与第 1 章相同,validate现在同时服务验证集和测试集——验证集每次 epoch 后跑,测试集只在最后跑一次。 - 指标记录:用
defaultdict(list)按名字存下每个 epoch 的train_loss / train_acc / val_loss / val_acc四个列表,这是最朴素的"实验记录本"。 - 不做什么:不引入 argparse、logging、matplotlib、早停——这些是第 3 章及以后的事,本章先把"数据该分成几段、指标往哪记"这件地基事做对。
trade-off:验证集从训练集里挤占了 5000 张,模型"少练了 10%"的题,但换来的是每次 epoch 之后有一个 干净、公正 的模型选择依据。这个代价几乎总是值得的。
四、代码变更
相对第 1 章(train.py),核心改动是数据切分、loader 新增、指标记录、测试集收尾。具体如下:
# 1) import:新增 random_split 和 defaultdict
- from torch.utils.data import DataLoader
+ from torch.utils.data import DataLoader, random_split
+ from collections import defaultdict
# 2) 数据:transform 由"共用一个变量"改为各自内联(逻辑等价,细节略)
- train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
- val_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)
+ train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=...)
+ test_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=...)
# 3) 关键改动:从官方训练集里切出验证集(第 1 章的 val_dataset 其实是测试集)
+ train_dataset, val_dataset = random_split(train_dataset, [45000, len(train_dataset) - 45000])
# 4) loader:新增 val_loader(验证集)和 test_loader(官方测试集)
+ val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
+ test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
# 5) 训练循环:把每个 epoch 的四个指标追加进 metrics(原代码只 print)
+ metrics = defaultdict(list)
+ metrics["train_loss"].append(train_loss)
+ metrics["train_acc"].append(train_acc)
+ metrics["val_loss"].append(val_loss)
+ metrics["val_acc"].append(val_acc)
# 6) 训练结束:测试集只评估这一次,并打印历史指标
+ test_loss, test_acc = validate(model, test_loader, criterion, device)
+ print(f"\n[Test] loss {test_loss:.4f} acc {test_acc:.4f}")
+ print("\n指标历史(前 5 个 epoch):")
+ for name in ["train_loss", "val_loss", "train_acc", "val_acc"]:
+ print(f" {name:10s}: {[round(v, 4) for v in metrics[name][:5]]}")模型的 SimpleCNN、损失、优化器、训练/验证函数本体一字未动——本章改的是"数据怎么分、指标怎么记",不是网络本身。
五、完整代码
创建(或覆盖)train.py:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split # 新增:random_split 用于随机切分数据集
from torchvision import datasets, transforms
from collections import defaultdict # 新增:defaultdict 用于记录指标历史
# ----------------------------------------------------------------------------
# 0. 设备:有 GPU 用 GPU,没有就用 CPU(与第 1 章相同)
# ----------------------------------------------------------------------------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# 后续所有"数据/模型/计算"都要 .to(device) 放到同一个设备上,否则报 device 不匹配
# ----------------------------------------------------------------------------
# 1. 模型:SimpleCNN —— 与第 1 章完全相同(3 个卷积块 + 全局平均池化 + 分类头)
# ----------------------------------------------------------------------------
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# 块1:3 -> 32 通道。CIFAR-10 是 32x32 输入,padding=1 保持分辨率不变
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32), # 稳定数值分布,加速收敛
nn.ReLU(inplace=True), # inplace=True 节省内存(原地修改)
nn.MaxPool2d(2), # 32x32 -> 16x16
# 块2:32 -> 64 通道(输入通道必须=上一层输出通道 32)
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16x16 -> 8x8
# 块3:64 -> 128 通道
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 8x8 -> 4x4
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 任意输入尺寸 -> 1x1,省去手算展平维数
nn.Flatten(), # (B,128,1,1) -> (B,128)
nn.Linear(128, num_classes), # 128 维特征 -> 10 类 logits
)
def forward(self, x):
# 前向传播:特征提取 -> 分类。x 形状: (batch, 3, 32, 32)
return self.classifier(self.features(x))
# ----------------------------------------------------------------------------
# 2. 数据:训练 / 验证 / 测试 三段划分(本章的核心改动)
# ----------------------------------------------------------------------------
# 数据:从训练集再切 5000 张作为验证集(45000 训练 / 5000 验证)
# 验证集用于训练过程中的模型选择;测试集只用于最终评估,全程不参与决策
# 注意:CIFAR-10 官方把数据分为 train(50000) / test(10000) 两段
train_dataset = datasets.CIFAR10(root="./data", train=True, download=True,
transform=transforms.Compose([
transforms.ToTensor(), # PIL(0~255 int) -> tensor(0~1 float)
transforms.Normalize((0.4914, 0.4822, 0.4465),
(0.2470, 0.2435, 0.2616)),
# 减去均值、除以方差,把像素分布中心化到 0 附近,加速收敛
]))
test_dataset = datasets.CIFAR10(root="./data", train=False, download=True,
transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465),
(0.2470, 0.2435, 0.2616)),
]))
# random_split:把官方训练集随机切成 [45000, 5000] 两份,5000 张留给验证
# 注意:切分是随机的,每次运行结果可能不同;想固定下来需设随机种子(见"知识补充")
train_dataset, val_dataset = random_split(train_dataset, [45000, len(train_dataset) - 45000])
BATCH_SIZE = 64
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
# shuffle=False:验证/测试不依赖顺序,保证每次评估结果稳定可比
# ----------------------------------------------------------------------------
# 3. 损失函数 + 优化器(与第 1 章相同)
# ----------------------------------------------------------------------------
model = SimpleCNN().to(device) # 把模型搬到 GPU/CPU
criterion = nn.CrossEntropyLoss() # 分类任务的默认损失:softmax + 交叉熵
optimizer = optim.Adam(model.parameters(), lr=1e-3) # 优化器管理模型的全部可学习参数
# ----------------------------------------------------------------------------
# 4. 训练与验证函数(与第 1 章相同,validate 同时服务验证集和测试集)
# ----------------------------------------------------------------------------
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train() # 训练模式:启用 BN 的 batch 统计
total_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device) # 数据搬上设备
optimizer.zero_grad() # 清零梯度(梯度会累加,不清会叠加)
outputs = model(images) # 前向传播 -> (batch, 10) 的 logits
loss = criterion(outputs, labels) # 计算这批的损失
loss.backward() # 反向传播:算出每个参数的梯度
optimizer.step() # 按梯度更新一次参数
total_loss += loss.item() * images.size(0) # 累加损失(乘样本数,后面加权平均)
correct += (outputs.argmax(dim=1) == labels).sum().item() # 预测类别=得分最高的类
total += images.size(0)
return total_loss / total, correct / total # 返回 平均loss, 准确率
def validate(model, loader, criterion, device):
model.eval() # 评估模式:BN 用全局统计量
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # 验证不计算梯度,省显存、更快
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += images.size(0)
return total_loss / total, correct / total
# ----------------------------------------------------------------------------
# 5. 主训练循环 + 指标记录(相对第 1 章的核心改动)
# ----------------------------------------------------------------------------
EPOCHS = 10
# name -> 每个 epoch 的值列表:这就是最朴素的"实验记录"
metrics = defaultdict(list)
for epoch in range(1, EPOCHS + 1):
train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc = validate(model, val_loader, criterion, device)
# 把本 epoch 的四个指标追加进历史记录(defaultdict 第一次访问会自动建空列表)
metrics["train_loss"].append(train_loss)
metrics["train_acc"].append(train_acc)
metrics["val_loss"].append(val_loss)
metrics["val_acc"].append(val_acc)
print(f"Epoch {epoch:02d}/{EPOCHS} | "
f"Train loss {train_loss:.4f} acc {train_acc:.4f} | "
f"Val loss {val_loss:.4f} acc {val_acc:.4f}")
# ----------------------------------------------------------------------------
# 6. 最终评估:测试集只在训练结束后用这一次,全程不参与任何决策
# ----------------------------------------------------------------------------
# 训练结束,用测试集做最终评估(只跑这一次)
test_loss, test_acc = validate(model, test_loader, criterion, device)
print(f"\n[Test] loss {test_loss:.4f} acc {test_acc:.4f}")
# 简单展示记录到的历史指标
print("\n指标历史(前 5 个 epoch):")
for name in ["train_loss", "val_loss", "train_acc", "val_acc"]:
print(f" {name:10s}: {[round(v, 4) for v in metrics[name][:5]]}")六、知识补充:如何让切分结果可复现(随机种子)
你可能已经注意到:random_split 是随机切分的,每次运行切出来的"哪些图进验证集"都不同,模型的训练结果也会跟着轻微波动。这 不是 bug,是随机性 。想让实验结果稳定可复现,只需在 random_split 之前固定随机种子:
import random
import numpy as np
torch.manual_seed(42) # 固定 PyTorch 的随机数(模型初始化、数据顺序、random_split 都在内)
random.seed(42) # 固定 Python 标准库的随机数
np.random.seed(42) # 固定 NumPy 的随机数加上这几行后,同一台机器上每次运行得到的切分和训练结果就完全一致了。科研和正式实验里这是"必备动作",否则你没法判断"模型变好了"到底是你的改动起作用,还是运气好抽到了更好分的验证集。
另外两点常识:验证集一般取总量的 10%~20%(这里 5000/50000 = 10%);如果数据很少,三段划分会浪费样本,此时可以用交叉验证——那是后话。
七、本章小结
- 学到了什么
- 三段划分的职责铁律: 训练集负责学、验证集负责选、测试集负责验 ,三者数据不能重叠。
random_split从官方训练集里切出验证集,validate一个函数同时服务验证集与测试集。defaultdict(list)让"记录指标"变成三行代码,metrics字典就是最朴素的实验记录本。- 测试集全程冻结、只评估一次——这是验证集存在的根本理由。
- 常见坑
- 忘了固定随机种子:每次跑结果不一样,别当成 bug 瞎找。
- 反复用测试集调参:每用一次就"泄一次题",最终分数不可信。
- 切分比例写错:
random_split的长度之和必须等于原数据集长度,[45000, len(train_dataset) - 45000]就是防止手抖求和写错。 - 验证/测试 loader 忘了
shuffle=False:顺序打乱不影响平均指标,但会浪费时间去随机化,且让结果难以逐条对比。
- 下一章预告:本章把指标存进了
metrics,第 3 章我们就把它画成训练曲线,用 matplotlib 直观看到 loss 下降和过拟合的出现,并引入 Early Stopping(早停)来及时止损。
八、动手练习
- 改切分比例:把
random_split改成[40000, 10000],观察验证集变大后 val 指标是否更稳定;再改成[48000, 2000],观察验证集过小带来的抖动。 - 亲眼看到过拟合:把
EPOCHS从 10 改成 40,观察train_acc继续爬升而val_acc停滞甚至回落、两者差距越拉越大——这就是过拟合的直观画像。 - 画曲线:训练结束后用 matplotlib 把
metrics["train_loss"]和metrics["val_loss"]画在同一张图里(提示:import matplotlib.pyplot as plt; plt.plot(metrics["train_loss"])),看两条曲线的走势。 - 体会 defaultdict:把
metrics = defaultdict(list)改成metrics = {},直接跑,观察KeyError: 'train_loss'的报错;再用metrics.setdefault("train_loss", []).append(train_loss)修复,说说两者区别。 - 偷看一下"泄题"的后果:临时把
val_loader换成test_loader(即回到第 1 章的写法),连续跑几个 epoch 后,对比最终测试分数,体会"反复碰测试集 = 分数虚高"有多隐蔽。
