数据集怎么切成 train、val、test 三份,看着是 PyTorch 项目里最没技术含量的一步,实际上手之后你会发现,翻车的项目十有八九栽在这里。我见过训练集准确率一路飙到 99.7%、测试集却卡在 62% 纹丝不动的模型,最后一查,是同一批样本的增强副本被分到了训练集和验证集两边;也见过验证集指标漂亮得离谱,复现的时候换个随机种子直接掉五个点,原因是划分代码写在__main__里没固定种子。这篇就围绕 PyTorch 场景下的 train/val/test 划分,把选型逻辑、索引级划分的实现、预处理顺序、数据泄漏排查、DataLoader 配置和一堆踩坑经验讲透。适合刚把模型跑通、正准备认真调参的朋友,也适合带项目、需要给团队定一套可复现划分规范的人。
1. 数据集划分的整体思路与选型
1.1 三份数据到底各自在干什么活
很多人把 val 和 test 当成同义词,训练的时候拿验证集挑模型,挑完再用验证集报指标,最后写报告说"测试集准确率 93%"。这套流程在工程上是不成立的。训练集负责拟合参数,验证集负责拟合"人的决策"——学习率、层数、正则强度、早停轮次,凡是你在看指标之后做出的选择,本质上都是拿验证集在训练。而测试集是唯一一次性的、不带任何反馈的评估,它的价值在于告诉你"这套流程拿到没见过的新数据上大概什么水平"。一旦你根据测试集结果回头改代码,测试集就被污染成了第二个验证集,报出来的数字就失去了意义。
我一般的做法是:验证集可以天天看,看多少次都行;测试集写成一个独立的评估脚本,只在模型冻结、配置冻结、准备交付或写结论的时候跑一次。为了让这个纪律能执行下去,测试集的索引文件甚至可以不放在日常开发的分支里,物理上隔离。
划分比例没有标准答案,但有经验区间。数据量在万级以下时,6:2:2 或者 7:1.5:1.5 比较常见;十万级往上,验证集和测试集各留 1%~2% 就足够稳定,剩下的全喂给训练;千万级的时候,验证集留个几千条就够了,再多只是浪费时间。这里有个反直觉的点:验证集的绝对数量比比例更重要。指标要稳定,验证集至少得覆盖几百到上千个样本,否则准确率的波动区间能到正负三四个点,你根据这种噪声调超参,和在骰子上做决策区别不大。
1.2 随机划分、分层划分、按时间划分、按组划分怎么选
划分方式的选择,取决于你的样本之间是不是独立同分布。下面这张表是我自己的决策顺序,从上往下判断,命中哪一条就停。
| 划分方式 | 适用场景 | 关键实现 | 主要风险 |
|---|---|---|---|
| 按组划分 | 同一实体产生多条样本(同一用户多张图、同一设备多段信号、同一文档多个切片) | GroupShuffleSplit或按 group id 手写 | 组内泄漏,指标虚高最严重 |
| 按时间划分 | 时序预测、线上日志、金融、传感器流 | 按时间戳排序后切三段 | 用未来数据预测过去 |
| 分层划分 | 类别不平衡、类别数多但单类样本少 | train_test_split(stratify=...)、StratifiedKFold | 少数类在验证集里抽不到 |
| 纯随机划分 | 样本独立、类别均衡、量足够大 | random_split或randperm | 小样本时类别比例漂移 |
判断顺序很重要。先问"样本之间有没有组结构",再问"有没有时间顺序",最后才考虑分层。我见过团队在用户行为数据上直接上分层划分,类别比例是漂亮了,但同一个用户的历史行为同时出现在训练集和测试集,模型实际上是在做记忆而不是泛化,线下 AUC 0.92,上线一周掉到 0.71。这种坑靠调参救不回来,只能回去重做划分。
1.3 划分比例背后的取舍与几个常见误区
比例这件事,我更愿意把它当成"验证集噪声预算"来算。假设二分类任务真实准确率 0.9,验证集 N 条样本,准确率的标准差大致是 sqrt(0.9×0.1/N)。N=500 时标准差约 1.3%,95% 置信区间宽度约 ±2.6 个点;N=2000 时标准差约 0.67%,区间宽度约 ±1.3 个点。这意味着验证集只有几百条时,两个模型差 1 个点你根本分不出优劣,必须差到 3 个点以上才有讨论价值。把这个数算出来,你调参的时候就不会对着小数点后两位纠结。
另一个误区是把测试集当"备用验证集"用。数据实在不够的时候,正确的做法是上交叉验证,而不是把测试集拆开用。K 折交叉验证在 PyTorch 里完全可行:用StratifiedKFold生成 K 组索引,外层循环换折,内层正常训练,最后用各折验证指标的平均值做模型选择。代价是训练成本乘以 K,收益是小数据下指标方差显著下降。我在样本量几千条的表格类任务上,5 折交叉验证的结论比单次 8:1:1 划分稳定得多,尤其是几个模型差距在 1~2 个点时。
还有一个容易被忽略的点:划分应该在"样本级"还是"切片级"。序列任务里,一条长序列切成多个窗口之后,相邻窗口共享大量上下文,如果按窗口随机划分,训练集的窗口和测试集的窗口几乎重叠,指标必然虚高。正确做法是先按原始序列(或按原始实体)划分,切窗口的动作放在划分之后,每个划分内部独立切。
2. 核心实现细节:从 Dataset 到 Subset 的正确姿势
2.1 为什么我坚持"先定索引,再建 Dataset"
新手最常见的写法是先Dataset(...),再random_split(dataset, ...),看起来简洁,但隐藏了两个问题。第一,可复现性差:random_split的随机性来自传入的 generator,很多代码里压根没传,每次运行结果都不同,你以为在复现别人的实验,其实连自己的实验都复现不了。第二,划分结果不可见:索引藏在Subset.indices里,一旦需要核对"某张图到底在哪个集合",得现写代码去翻。
我现在的习惯是把划分做成一个独立步骤:先算出一份索引字典{"train": [...], "val": [...], "test": [...]},存成 json 或者 npy,训练脚本只负责读这份索引。这样做的好处很实在——划分和训练解耦,同一份划分可以被不同模型、不同人、不同机器复用;出问题的时候直接 diff 两份索引文件就能定位;想换划分策略,只改划分脚本,训练代码一行不动。
import json import numpy as np from pathlib import Path def save_split(indices: dict, path: str, meta: dict | None = None): payload = {"indices": {k: [int(i) for i in v] for k, v in indices.items()}, "meta": meta or {}} Path(path).write_text(json.dumps(payload, ensure_ascii=False)) print({k: len(v) for k, v in indices.items()}) def load_split(path: str) -> dict: payload = json.loads(Path(path).read_text()) return {k: np.array(v, dtype=np.int64) for k, v in payload["indices"].items()}把总数、比例、随机种子、划分时间一起写进meta,半年后回头看,你会感谢当时的自己。
2.2 random_split 的正确用法与它的边界
random_split不是不能用,它适合"样本独立、类别均衡、只想要个快速基线"的场景。用的时候有三件事必须做对:显式传 generator、长度必须严格加和、记住它返回的是Subset而不是新数据集。
import torch from torch.utils.data import random_split g = torch.Generator().manual_seed(20240501) n = len(base_ds) n_val = int(n * 0.15) n_test = int(n * 0.15) n_train = n - n_val - n_test train_ds, val_ds, test_ds = random_split( base_ds, [n_train, n_val, n_test], generator=g )[n_train, n_val, n_test]的和少一个都会抛ValueError,这点比某些静默截断的库友好。但它的边界也很明显:不做分层,类别不平衡时验证集里可能一个少数类都没有;不支持按组划分;每次调用消耗同一个 generator 的状态,如果你在同一个脚本里对两份数据连续调用random_split且复用同一个 generator,第二份的结果会受第一份影响。我一般每份数据单独建 generator,种子从主种子派生,比如main_seed + 1、main_seed + 2,避免隐式耦合。
还有个细节:random_split内部先做randperm,再按长度切,所以顺序是随机的,train_ds.indices不是排好序的。如果你的下游逻辑假设索引递增(比如按索引去读连续文件、做缓存预取),记得自己sorted()一下。
2.3 分层划分:让每个集合都见到少数类
类别不平衡的时候,分层几乎是必须的。我一般用 sklearn 的train_test_split做两段式切分,先切出 train 和"临时集",再从临时集里对半切成 val 和 test,两次都带stratify。
import numpy as np from sklearn.model_selection import train_test_split labels = np.asarray(base_ds.targets) # 假设 Dataset 暴露了 targets idx_all = np.arange(len(base_ds)) idx_train, idx_rest = train_test_split( idx_all, test_size=0.30, stratify=labels, random_state=42 ) idx_val, idx_test = train_test_split( idx_rest, test_size=0.50, stratify=labels[idx_rest], random_state=42 ) split = {"train": idx_train, "val": idx_val, "test": idx_test}两段式切分要注意第二次stratify必须用labels[idx_rest],不是原始的labels,否则会报维度不匹配。另外分层只管比例,不管绝对数量:如果某个类总共只有 3 条样本,按 7:1.5:1.5 切,验证集和测试集大概率一条都分不到。这种情况我的处理是把稀有类整体划到训练集,评估指标改用宏平均或者直接看混淆矩阵,不去追求"每个集合都有全部类别"这个表面上的完美。
分层划分还有个变体是按"类别组合"分层,多标签任务里特别有用。比如一条样本同时打了三个标签,直接对主标签分层会破坏其他标签的分布,这时可以把标签组合拼成一个字符串当分层键,或者用迭代分层(sklearn.model_selection.IterativeStratification)的思路自己实现。我在多标签场景踩过一次,对主标签分层之后,次要标签在验证集里的正例率从 12% 掉到 3%,验证指标完全失真,后来改成组合键才恢复正常。
2.4 用 Subset 包装:让 transform 跟着划分走
训练集要随机增强,验证集和测试集只能做确定性的预处理,这个差异必须体现在 Dataset 层面。麻烦的是Subset只是索引视图,底层Dataset的transform是共享的,你没法给三个子集配三套 transform。
我常用的解法是写一个轻量的包装类,把 transform 挂在外层,底层 Dataset 只负责读原始数据、不做任何随机变换。
import torch class TransformSubset(torch.utils.data.Dataset): def __init__(self, base, indices, transform=None, target_transform=None): self.base = base self.indices = list(indices) self.transform = transform self.target_transform = target_transform def __len__(self): return len(self.indices) def __getitem__(self, i): x, y = self.base[self.indices[i]] if self.transform is not None: x = self.transform(x) if self.target_transform is not None: y = self.target_transform(y) return x, ytrain_ds = TransformSubset(base, split["train"], transform=train_tf) val_ds = TransformSubset(base, split["val"], transform=eval_tf) test_ds = TransformSubset(base, split["test"], transform=eval_tf)这里有个硬性约束:base自身不能再带随机增强,否则 eval 集的"确定性"就是假的。我见过项目里base的构造函数默认塞了一个RandomHorizontalFlip,验证集每次评估结果都不一样,团队查了两周以为是模型不稳定。排查方法很简单,把验证集跑三遍同一个模型,指标不完全一致就说明有随机性漏进来了。
TransformSubset相比Subset多了一层 Python 调用,对绝大多数任务开销可以忽略。如果确实在追求极致的吞吐,可以在 dataset 里维护三个独立的transform属性,用Subset的子类覆盖__getitem__时按索引反查所属划分,但代码可读性会明显下降,我不推荐。
3. 数据泄漏与预处理顺序问题
3.1 归一化统计量只能在训练集上算
这是最常见、也最容易被忽视的泄漏。很多教程直接写transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),这是从某个大规模数据集上统计出来的固定值,本身没问题——它是别人的训练集统计量,不是你的。但如果你要自己在数据集上算统计量,只能遍历训练集。
import torch from torch.utils.data import DataLoader stat_loader = DataLoader(train_ds_untransformed, batch_size=256, shuffle=False, num_workers=4) n_pixels = 0 s = torch.zeros(3, dtype=torch.float64) ss = torch.zeros(3, dtype=torch.float64) with torch.no_grad(): for x, _ in stat_loader: x = x.double() s += x.sum(dim=[0, 2, 3]) ss += (x ** 2).sum(dim=[0, 2, 3]) n_pixels += x.size(0) * x.size(2) * x.size(3) mean = s / n_pixels var = ss / n_pixels - mean ** 2 std = var.clamp_min(1e-12).sqrt()用全量数据算统计量,理论上让你的预处理"提前看到了"验证集和测试集的分布,属于轻度泄漏。数据量大、分布稳定的时候影响很小,但在小样本、分布偏移明显的场景下,差距能有一两个点。既然成本只是多跑一遍前向,没有理由不做对。
顺带说一下数值精度:用float64累加,别用float32,几十万张图的平方和很容易在 float32 下丢精度,算出来的 std 偏小。
3.2 数据增强只作用于训练集,但"在哪一层加"有讲究
增强只给训练集,这个原则人人都知道,实现上还是要小心两层陷阱。第一层是增强的随机性没有正确隔离,上面说过。第二层是"划分前增强",也就是先把数据增强成一堆副本存下来,再划分。这样做等价于同一张图的不同版本被分到不同集合,验证集指标必然虚高,而且虚高得很隐蔽——因为每个副本的像素看起来确实不一样。
正确的顺序永远是:原始数据 → 划分索引 → 各自构造 transform。别为了省时间搞"预增强落盘再随机划分",除非你能保证同一张原图的所有增强副本同进同出同一个集合,而这件事的实现成本比重跑一遍高得多。
还有一类隐式增强需要留意:RandomResizedCrop、RandomErasing、MixUp/CutMix 在训练时是增强,在评估时必须关掉。MixUp 尤其危险,因为它在 batch 级别混合样本,验证集如果误开了,算出来的 loss 曲线会异常平滑、看起来特别好看,实际上评估的是"插值后的样本",不是真实分布。
3.3 重复样本、近重复样本与组内泄漏
随机划分最怕的是数据集里本来就存在重复或者近重复样本。同一张图被不同人标注两次、同一条商品被不同源抓取三次、同一条新闻被转载几十次,随机划分之后,一个副本在训练集、一个在测试集,模型实际上是在做最近邻检索,而不是泛化。这类泄漏的特点是:线下指标极高,线上崩得莫名其妙,而且崩的时候你找不到任何代码 bug。
排查手段是做一次去重或者近重复检测,至少对测试集做一遍。图像可以用感知哈希,文本可以用 MinHash 或者简单的 embed 相似度,表格数据可以用关键字段组合去重。我做过一个项目,去重前测试集准确率 0.94,去重后 0.81,差了十三个点,全来自重复样本。这十三个点如果带到线上,就是一次事故。
组内泄漏的处理前面提过,实现上给每条样本带上 group id,划分时以 group 为单位。GroupShuffleSplit一次只切一次,要切三段就先用它切出 train 和 rest,再对 rest 切一次。
import numpy as np from sklearn.model_selection import GroupShuffleSplit groups = np.asarray(base_ds.group_ids) idx_all = np.arange(len(base_ds)) gss1 = GroupShuffleSplit(n_splits=1, test_size=0.30, random_state=42) idx_train, idx_rest = next(gss1.split(idx_all, groups=groups)) gss2 = GroupShuffleSplit(n_splits=1, test_size=0.50, random_state=42) sub_idx, sub_rest = next(gss2.split(idx_rest, groups=groups[idx_rest])) idx_val, idx_test = idx_rest[sub_idx], idx_rest[sub_rest]注意idx_rest[sub_idx]这一步,GroupShuffleSplit返回的是相对于输入数组的位置,不是全局索引,忘了映射就会拿到错位的索引,而且不一定报错,只会在训练时静默地训练到错误的样本。这类映射错误我见过不止一次,通常要等到指标异常才被发现。
3.4 时间顺序与"未来信息"
时序任务里唯一正确的划分是按时间切,不是按比例随机切。做法是把样本按时间戳排序,前 70% 训练、接着 15% 验证、最后 15% 测试,边界上还可以留一段 gap,避免相邻时间点的强相关。
order = np.argsort(timestamps) n = len(order) n_train = int(n * 0.70) n_val = int(n * 0.15) gap = 24 # 留出一段缓冲,避免边界相邻样本跨集合 idx_train = order[:n_train - gap] idx_val = order[n_train:n_train + n_val - gap] idx_test = order[n_train + n_val:]gap 要不要留,取决于你的数据采样频率。高频采样的传感器数据里,相邻两条样本几乎一样,不留 gap 就等于泄漏。低频的日级数据,gap 可以设为 0 或者 1。这个参数最好在看验证集之前就定下来,别拿验证集去调 gap,那就变成了用未来信息选未来信息。
4. 完整训练/验证/测试流程的落地
4.1 DataLoader 的配置:参数不是随便填的
划分对了,DataLoader 配错照样出问题。下面是我常用的一套配置,以及每个参数为什么这么设。
| 参数 | 训练集 | 验证/测试集 | 说明 |
|---|---|---|---|
shuffle | True | False | 验证集乱序会让指标随批次顺序微变,不利于复现 |
drop_last | True(小数据或 BN 敏感时) | False | 最后一两个样本构成的 batch 会扰乱 BN 统计量 |
batch_size | 按显存和 LR 调 | 可以放大 2~4 倍 | 无梯度不需要存激活 |
num_workers | 4~8 | 4~8 | 受 CPU 核数限制,过大会抢主进程 |
pin_memory | True | True | 有 GPU 时才开 |
persistent_workers | True | True | 需num_workers > 0,省去每个 epoch 重建进程 |
prefetch_factor | 2~4 | 2~4 | 只在num_workers > 0时生效 |
from torch.utils.data import DataLoader train_loader = DataLoader( train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, drop_last=True, persistent_workers=True, ) val_loader = DataLoader( val_ds, batch_size=128, shuffle=False, num_workers=4, pin_memory=True, persistent_workers=True, ) test_loader = DataLoader( test_ds, batch_size=128, shuffle=False, num_workers=4, pin_memory=True, persistent_workers=True, )drop_last=True这条值得单独说。最后一个 batch 如果只有 1 个样本,BatchNorm 在训练模式下会因为方差为 0 抛错,或者更糟——静默产生一个极端的统计量,污染 running mean。就算没有 BN,只有一个样本的 batch 对梯度也是纯噪声。所以训练集要么drop_last=True,要么在 dataset 层保证长度足够。
反过来,验证集不要drop_last。验证集少几条样本不影响结论,但漏掉的那几条如果正好是困难样本,指标会系统性偏高,而且别人复现你的数字永远对不上。
4.2 评估函数的写法与调用时机
验证和测试的评估逻辑几乎一样,写成一个函数,差别只在调用时机。关键是model.eval()和torch.no_grad()一个都不能少。
@torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total, correct, loss_sum = 0, 0, 0.0 for x, y in loader: x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True) logits = model(x) loss_sum += criterion(logits, y).item() * y.size(0) correct += (logits.argmax(dim=1) == y).sum().item() total += y.size(0) model.train() return loss_sum / total, correct / total注意 loss 的累加方式:乘y.size(0)再除总数,这才是样本级平均。如果直接对 batch loss 求平均,最后一个小 batch 的权重会被高估,batch size 不一致的时候偏差更明显。分类任务里如果用了reduction="mean"的 CrossEntropy,这个偏差在小 batch 上能有 0.1~0.3 个点的差距,虽然不大,但没必要留。
model.train()放在函数末尾是个小技巧,避免调用方忘了切回训练模式。当然更稳妥的是在训练循环里显式切换,两处都写也不冲突。
训练循环的结构建议是这样:每个 epoch 训练完跑一次验证,记录验证 loss,验证 loss 连续 N 个 epoch 不下降就早停,同时保存验证指标最好的那份权重。测试集在整个训练流程结束后、用最优权重跑一次。
best_val, patience_cnt, best_state = float("inf"), 0, None for epoch in range(max_epochs): train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) if val_loss < best_val - 1e-4: best_val, patience_cnt = val_loss, 0 best_state = {k: v.detach().clone() for k, v in model.state_dict().items()} else: patience_cnt += 1 if patience_cnt >= patience: break model.load_state_dict(best_state) test_loss, test_acc = evaluate(model, test_loader, criterion, device)用验证 loss 而不是验证准确率做早停判断,准确率是离散的,平台期长,容易误停;loss 连续,对微小改进更敏感。这个选择在类别不平衡的任务上尤其重要。
4.3 模型选择、早停与测试集只开一次
早停用验证集,模型选择用验证集,超参搜索也用验证集,这三件事叠加起来,验证集实际上被"训练"了很多次,它对真实泛化能力的估计会逐渐乐观。这不算错,但要知道这个偏差存在。数据量小的时候,验证集的乐观偏差可以到两三个点。
对抗这种偏差的手段有两个。一是控制搜索次数,别在验证集上做几千次随机搜索,粗调加少量精调就够了。二是做嵌套划分:外层留出测试集,内层再切训练和验证,超参在内层选,外层只做最终评估。实现上就是两层索引,代码复杂度上升不多,但要求测试集在整个流程里完全不参与任何决策。我在需要交付严肃结论的项目上会这么做,快速迭代阶段就用简单的三段划分加严格的纪律。
测试集"只开一次"这件事,实际操作中要配合工程手段。把测试评估脚本和训练脚本分开,测试脚本读取一个固定的 checkpoint 路径,不接受命令行参数去改模型结构,输出结果写到单独的日志文件。这样即使手滑想"再调一版看看测试集",也会因为流程麻烦而放弃。听起来有点夸张,但我确实见过因为测试集看得太频繁,最后交付模型在真实数据上比报告低六个点的情况。
5. 常见问题与排查速查表
5.1 典型现象与对应根因
| 现象 | 高概率根因 | 排查动作 |
|---|---|---|
| 验证准确率异常高,测试集断崖下跌 | 重复/近重复样本跨集合 | 对测试集做去重和近重复检测 |
| 每次运行验证指标不一致 | 随机种子没固定,或 eval transform 带随机性 | 固定种子;验证集连跑三次比对 |
| 验证集某类样本为 0 | 未分层,或少数类样本量太少 | 打印各类别计数,改用分层或合并稀有类 |
| 训练 loss 正常但验证 loss 一直涨 | 划分里有标签泄漏特征 | 逐特征做单变量 AUC,找异常强特征 |
NoneType或索引越界报错 | group 划分后索引未映射回全局 | 检查idx_rest[sub_idx]这类映射 |
| 训练最后一个 batch 报 BN 相关错误 | 最后 batch 只有 1 条样本 | 训练集drop_last=True |
| 多进程下数据顺序或增强结果不可复现 | worker 的 numpy/random 未设种子 | 加worker_init_fn |
| 验证集指标每次跳动幅度很大 | 验证集太小 | 扩验证集,或改用交叉验证 |
worker_init_fn这个坑值得展开。PyTorch 会给每个 worker 设置torch的 base seed,但 numpy 和 Python 内置 random 的种子在不同 worker 里可能是同一个值,结果就是所有 worker 产生相同的随机增强序列。表现是数据多样性下降,模型收敛变慢,而且很难联想到是划分和多进程的问题。
import random import numpy as np import torch def worker_init_fn(worker_id: int): seed = (torch.initial_seed() + worker_id) % (2 ** 32) np.random.seed(seed) random.seed(seed) train_loader = DataLoader( train_ds, batch_size=64, shuffle=True, num_workers=4, worker_init_fn=worker_init_fn, persistent_workers=True, )加了worker_init_fn之后,同一份索引加同一个主种子,多次运行的增强序列应当完全一致,可以拿这个做自检。
5.2 复现性清单:种子到底要固定哪几处
"固定了随机种子但还是复现不了",原因通常是漏了几处。我自己的清单是这样的:
- Python
random.seed、numpy.random.seed、torch.manual_seed三件套,一个不能少。 - 有 GPU 时加
torch.cuda.manual_seed_all。 DataLoader的 generator 单独传入,别依赖全局状态。random_split、train_test_split显式传种子。- 划分索引落盘,训练时读文件而不是重新计算。
worker_init_fn覆盖多进程场景。- 记录
torch.backends.cudnn.deterministic的设置,开启后性能会降,但卷积结果确定。
最后一条要权衡:完全确定性会让训练慢 10%~30%,我在调试阶段开,正式跑大规模实验时关掉,但保留种子设置。因为即使 cudnn 不确定,种子固定之后统计意义上的复现(指标波动小于 0.5 个点)是能做到的,这就够用了。
还有一个隐蔽的点:如果你在划分之后对数据做了任何"依赖全量"的操作,比如用全部样本的标签分布算类别权重、用全部样本算特征分位数做分箱,那这些统计量也只在训练集上算。这条我在表格类项目里踩过——用全量数据算的类别权重,让模型在验证集上表现略好,看起来是好事,实际上是把验证集的分布信息泄漏进了损失函数。
5.3 小样本与类别不平衡的划分策略
样本量在几百条这个量级时,单次划分的方差太大,任何结论都不可靠。我的处理顺序是:先看能不能补数据或者用预训练权重减少对数据量的依赖;不能的话,上分层 K 折交叉验证,报告均值和标准差,而不是单次结果。
import numpy as np from sklearn.model_selection import StratifiedKFold labels = np.asarray(base_ds.targets) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) fold_scores = [] for fold, (tr_idx, va_idx) in enumerate(skf.split(np.zeros(len(labels)), labels)): tr_ds = TransformSubset(base_ds, tr_idx, transform=train_tf) va_ds = TransformSubset(base_ds, va_idx, transform=eval_tf) score = run_one_fold(tr_ds, va_ds) fold_scores.append(score) print(f"fold {fold}: {score:.4f}") print(f"mean={np.mean(fold_scores):.4f} std={np.std(fold_scores):.4f}")交叉验证下"测试集"的角色由各折的验证集共同承担,最终模型用全部数据重训一次。这个流程在学术场景很标准,工程上要注意重训时的 epoch 数:交叉验证各折的最优 epoch 平均值可以作为一个参考,但重训时数据变多了,通常要适当增加。
类别极度不平衡(正例占比低于 1%)时,分层能保证比例,但保证不了验证集里有足够的正例做稳定估计。这时候我会额外做一个"平衡验证集":从负例里下采样,让验证集的正负比接近 1:4 或者 1:10,专门用来看召回率和 PR 曲线;同时保留一个自然分布的验证集用来看校准和整体指标。两个验证集各司其职,比在一个失衡的验证集上纠结阈值要靠谱得多。
关于阈值选择,还有个实操细节:分类阈值应该在验证集上选,选完固定,测试集直接用。我见过在测试集上扫一遍阈值取最优的做法,那等于把测试集当验证集用,报出来的 F1 必然偏乐观。如果业务上对召回更敏感,阈值的选择规则要提前写进实验记录,不能事后看着结果倒推。
5.4 划分脚本的自检清单
最后分享一套我每次新建项目都会跑的自检,几十行代码,能挡掉大部分低级错误。
def sanity_check(split, labels, groups=None): all_idx = np.concatenate([split["train"], split["val"], split["test"]]) assert len(all_idx) == len(np.unique(all_idx)), "索引有重复或遗漏" assert len(all_idx) == len(labels), "索引总数与数据集不一致" for name, idx in split.items(): cnt = np.bincount(labels[idx], minlength=labels.max() + 1) print(f"{name:5s} n={len(idx):6d} 类别分布={cnt}") if groups is not None: g_tr = set(groups[split["train"]]) g_va = set(groups[split["val"]]) g_te = set(groups[split["test"]]) assert not (g_tr & g_va), "训练集与验证集存在同组样本" assert not (g_tr & g_te), "训练集与测试集存在同组样本" assert not (g_va & g_te), "验证集与测试集存在同组样本"三个断言分别对应三类事故:索引重复(同一条样本出现在两个集合)、索引遗漏(有样本从没被用到,或者被静默丢弃)、组重叠(最严重的泄漏)。打印类别分布是为了肉眼看一眼比例是否接近预期,有些问题数字一看就不对,不用等训练。
我在实际项目里的体会是,划分这件事的投入产出比高得离谱。花两个小时把索引落盘、把自检脚本写好、把 transform 的层次理顺,后面几个月调参、复现、交接都能省下大量时间。反过来,划分上偷的懒,通常会在最不该出问题的时候——比如演示前一天、上线前一周——以最难排查的形式还回来。真要说有什么技巧,就是别把划分写在训练脚本里,把它当成一个独立的、有输出的、可 diff 的工程步骤来做,剩下的问题都好办。