我做过不少深度学习相关的项目,大部分时间都在跟图片、文本、音频这类文件型数据打交道。有一件事看起来特别不起眼,却反反复复影响我整个实验流程,就是训练集、测试集和验证集的划分方式。今天想把这个话题好好聊透,起因是一次让我印象深刻的复盘经历:模型训练完了,指标也还不错,但当我想回头分析bad case时,突然发现自己根本说不清楚"测试集里到底有哪些样本、它们来自哪里、划分时用的随机种子是多少"。代码里只写了random_state=42,但准确地说,那次是用train_test_split临时切分的,切完没有落盘。等到后来想复现、想审计、想调整某几个样本时,只能重新跑一遍脚本,结果发现新划分跟旧划分已经不完全一致了。
也是从那时候开始,我彻底转向了用 txt 文件来管理数据集划分的方式:把训练、测试、验证三个集合的文件清单分别写进三个文本文件,每行一条相对路径,然后所有训练脚本都从这些清单去加载数据。这套做法非常朴素,但在实际项目里解决了我绝大部分的痛点。这篇文章就围绕这套方案,把我踩过的坑、摸索出来的步骤、以及背后的设计逻辑全部记录下来,希望对同样被数据集划分困扰的朋友有帮助。无论你是刚入门想搞清楚怎么切分,还是在找一种更可靠、可复现的划分管理方式,这篇内容都值得你读完。
1. 为什么我坚持用 txt 清单管理划分,而不是只靠随机种子
很多人(包括早期的我)觉得,数据划分不就是一行train_test_split的事吗?要复现就固定种子,要换比例就改个参数,何必多此一举维护几个文本文件?但实际在真实项目里,尤其是以文件形式存在的数据集,这套做法有几个很严重的隐患。
1.1 我的翻车经历:一次无法回答的模型复盘
某个图像分类项目里,我需要经常回看模型在测试集上的错误预测,判断到底是标注错、类别混淆还是数据分布问题。当时的流程是:读入全部图片路径,在训练脚本里直接用train_test_split(shuffle=True, random_state=42)切出测试集。一开始还挺顺利,直到某天我发现一批错误样本中有一些看起来"非常相似"的图像,我意识到一个问题:这个模型在训练时会不会已经见过这些样本了?因为划分过程没有留下任何清单,我无法验证。当时我只能重新生成一遍划分,生成了也做不到精确对比,因为脚本版本已经改了好几轮,数据目录也增加了新图片,重新生成的划分必然和当时不同。
后来我做了对比测试,发现仅靠随机种子切分,在数据集更新、路径变化、多人协作时,几乎无法保证"本次实验用的测试集"和"下次实验用的测试集"是同一批样本。这对模型A/B对比来说非常致命——你很难断定效果差异是模型本身带来的,还是数据划分变了导致的。
1.2 txt 清单相当于给数据集做了一张"账本"
当我改用 txt 文件记录清单后,所有问题都有了抓手。训练、测试、验证各自包含哪些文件,一目了然;任何人都能打开文本看一眼,不需要跑代码、不依赖当前环境。我把 train.txt、val.txt、test.txt 和原始数据放在一起,再用 Git 管理这几个文本,这样任何历史时刻的划分都能精确还原。
我用一个简单的表格来说明为什么 txt 方案在真实项目里更可靠:
| 对比维度 | 只用随机种子切分 | 使用 txt 清单管理 |
|---|---|---|
| 可复现性 | 依赖同一份代码、同一种子、同一次数据读取顺序 | 只要清单文件在,任意环境都能复现 |
| 可追溯性 | 无法快速回答"某个样本属于哪个集合" | 直接查文本,或写几行脚本 grep 一下 |
| 可审核性 | 黑盒,业务同事无法校验样本分配是否合理 | 纯文本,可逐行审查 |
| 可变更性 | 需要修改代码、重跑逻辑 | 直接编辑 txt,增删一行即可 |
| 跨环境迁移 | 换机器、换平台后结果可能漂移 | 纯文本随处可读,路径相对化后很稳 |
当然,并不是所有场景都需要 txt 清单。如果是内存中的小数据集、一次性的快速实验,直接切分完全没问题。但只要你面对的是文件型数据、需要跑多轮实验、需要复盘或团队协作,txt 清单的成本几乎可以忽略,收益却非常大。
2. 划分清单的生成:目录约定与核心代码
这一节讲具体怎么落地。我先约定一个清晰的文件组织方式,然后给出一套可以直接抄走的 Python 实现。
2.1 先约定目录结构和清单格式
我的习惯是在数据根目录下建一个lists文件夹,专门存放划分清单:
data/ ├── images/ │ ├── cat_001.jpg │ ├── cat_002.jpg │ ├── dog_001.jpg │ └── ... ├── labels.csv └── lists/ ├── train.txt ├── val.txt └── test.txt清单文件里每一行是一条记录,推荐格式是"相对路径 + 可选标签(空格分隔)":
images/cat_001.jpg cat images/dog_001.jpg dog为什么用相对路径而不是绝对路径?因为绝对路径跟机器绑定,换一台机器、换一个数据目录就失效了。相对路径只要保持整体目录结构不变,在任何地方都能正常解析。这一点在服务器之间迁移、跟别人共享数据时非常关键。
另外,建议在 txt 里同时保留标签信息。这个信息后续非常有用:统计类别的分布、做分层采样、检查类别平衡度,都可以直接读 txt 完成,不必再回头关联原始表格。如果项目里没有标签,那就只写路径。
2.2 脚本一:无标签场景的纯随机划分
如果你只有一堆文件、不需要考虑类别均衡,最基础的做法是这样:
import random from pathlib import Path data_root = Path("data") image_dir = data_root / "images" lists_dir = data_root / "lists" lists_dir.mkdir(exist_ok=True) # 收集所有图片路径,相对路径格式统一用正斜杠 all_files = [str(p.relative_to(data_root)).replace("\\", "/") for p in image_dir.rglob("*") if p.suffix.lower() in {".jpg", ".jpeg", ".png"}] print(f"共计 {len(all_files)} 张图片") # 固定随机种子,保证可复现性 random.seed(42) random.shuffle(all_files) # 划分比例 train_ratio, val_ratio, test_ratio = 0.7, 0.15, 0.15 train_end = int(len(all_files) * train_ratio) val_end = int(len(all_files) * (train_ratio + val_ratio)) train_files = all_files[:train_end] val_files = all_files[train_end:val_end] test_files = all_files[val_end:] # 写清单 for name, file_list in zip( ["train.txt", "val.txt", "test.txt"], [train_files, val_files, test_files] ): with open(lists_dir / name, "w", encoding="utf-8") as f: for line in file_list: f.write(line + "\n") print(f"{name}: {len(file_list)} 条")有几个细节我要强调一下。
第一,rglob("*")会递归找到所有文件,再用后缀过滤,这样即使目录里混了Thumbs.db、DS_Store之类的杂项也不会混进数据集。第二,路径统一把反斜杠替换成正斜杠,这能避免 Windows 和 Linux 跨平台时踩坑,后面第 4 节会细说。第三,random.seed(42)必须放在shuffle之前,否则每次运行结果都不一样。但要注意:一旦数据集新增了图片,即使种子不变,shuffle 的结果也会整体变化,这是随机切分的老问题,第 4.4 节会讲怎么处理。
2.3 脚本二:带标签文件的分层划分
如果每张图片已经有标签(比如从 CSV 读入),直接用纯随机可能让某个小类在测试集里一只不剩,这时候就需要分层划分,保证每个集合里各类别比例与原始数据大致一致。
import pandas as pd from sklearn.model_selection import train_test_split from pathlib import Path df = pd.read_csv("data/labels.csv") # 先划分 train 和 temp(val + test) train_df, temp_df = train_test_split( df, test_size=0.3, # 总共 30% 留作 val+test stratify=df["label"], # 按标签分层 random_state=42, ) # 再从 temp 中均分 val 和 test val_df, test_df = train_test_split( temp_df, test_size=0.5, # temp 的一半即总体的 15% stratify=temp_df["label"], random_state=42, ) def write_list(df, out_path, data_root="data"): with open(out_path, "w", encoding="utf-8") as f: for _, row in df.iterrows(): rel_path = f"{row['path']}".replace("\\", "/") f.write(f"{rel_path} {row['label']}\n") write_list(train_df, "data/lists/train.txt") write_list(val_df, "data/lists/val.txt") write_list(test_df, "data/lists/test.txt")这里有个容易出错的地方:第二次切分时,random_state=42和第一次一样会导致 val、test 两份结果存在某种固定的配对关系。其实对于分层来说没问题,因为两个集合互不重叠,但如果想更"独立"一点,也可以把第二次的随机种子改成 2024 之类的不同值。两种做法都能用,我更推荐换一个不同的种子,至少从设计上减少额外关联。
2.4 为什么按"先整体 shuffle,再顺序切片"而不是"隔几条取一条"?
这是我在实操中见过不少新手误解的地方。有人会想:既然要分 70%、15%、15%,那我不如每 20 条取 14 条训练、3 条验证、3 条测试,这样"均匀分布"。听起来很合理,但对文件型数据来说完全没有必要,而且容易带来隐藏问题——如果原始文件恰好按照某种规律排列(比如按拍摄时间、按类别编号连续存放),隔 k 条取 1 条会让测试集在时间轴上均匀分布,反而可能在时间序列场景中造成信息泄露。
我遇到过最典型的例子:一组按文件名排序的日志图片,文件名带了时间戳,如果隔 N 取一,测试集里全是"未来时间点"之间的插值样本,训练集也混着更晚的样本,模型在离线指标上表现很好,上线后立刻崩。用"先 shuffle 再切片"的方式,从源头上避免了这种排序带来的偏差。当然,如果是真正的时间序列数据,连 shuffle 都是错的——那要单独用第 3 节的按时间划分策略。
3. 划分策略怎么选:随机、分层、按时间各有各的适用场景
不同数据、不同业务目标,划分策略不是统一的。我总结下来主要就三种,选择的核心依据是:你希望模型在测试集上考察哪种泛化能力。
3.1 随机划分:最通用,但不是"随便划分"
随机划分适用于所有样本近似独立同分布、各类别比例本身比较均衡的场景。比如通用图像分类、新闻文本分类这类数据,随机划分完全够用。这里的"随机"必须配合固定的随机种子,严格来说应该叫"可复现的伪随机"。我在 2.2 节的脚本就是这么做的。
随机划分最大的好处是简单、高效、不容易出错。老手也一样常用,真正常用的工具往往不是花哨的那个。
3.2 分层划分:类别不平衡时的必修课
如果某个类别的样本很少,比如缺陷检测场景中正样本只占 5%,那么纯随机划分在极端情况下可能出现测试集里一条正样本都没有。也就是说,模型就算把所有图片都预测为"正常",准确率也有 95%,你根本无法通过测试集判断模型的缺陷召回能力。
分层划分能很好地解决这个问题。它保证每个集合里各类别比例和原始数据一致。实现方式已经写在了 2.3 节,核心就是train_test_split里的stratify=df["label"]参数。
划分完成后,一定要验证一下各类别在三个集合里的占比,我经常用这段代码快速检查:
for name in ["train", "val", "test"]: df_tmp = pd.read_csv(f"data/lists/{name}.txt", sep=" ", header=None, names=["path", "label"]) print(name) print(df_tmp["label"].value_counts(normalize=True))如果某个小类别在 val 或 test 里数量少于 1,或者比例与原始差距很大,说明分层策略可能没生效,或者数据量实在太小。在后一种情况下,可以考虑把少量稀有样本同时放进 train 和 test(注意要在清单位置标注清楚),或者用交叉验证代替固定划分。
3.3 按时间划分:时序数据和增量数据不能随机切
在预测类、推荐类、时间序列类项目中,数据天然有先后顺序,随机划分就是灾难。假设你在用连续 90 天的交易数据预测第 91 天,测试集却混在训练集中间,模型实际上"见过未来",离线指标会虚高,上线后立刻打回原形。我在 2.4 节提到的日志图片就是这个场景。
正确的做法是:按时间戳排好序,前 70% 训练,中间 15% 验证,最后 15% 测试。注意验证集也要在时间上先于测试集,不能反过来。道理很简单,验证集的作用是模拟部署后的环境,而部署时模型只能用到"当前时刻之前"的数据来调参。
如果你用 pandas 处理,大致思路如下:
df = df.sort_values("timestamp") n = len(df) train_end = int(n * 0.7) val_end = int(n * 0.85) train_df = df.iloc[:train_end] val_df = df.iloc[train_end:val_end] test_df = df.iloc[val_end:]这种做法牺牲了一部分"数据量",但换来的是真实可靠的评估指标,这个取舍绝对值得。
3.4 三种策略的选择速查表
| 划分策略 | 适用场景 | 实现要点 | 典型风险 |
|---|---|---|---|
| 随机划分 | 样本独立同分布、类别均衡 | 固定随机种子,先 shuffle 再切片 | 数据更新后无法精确复现旧划分 |
| 分层划分 | 类别不平衡、小样本类重要 | 按标签 stratify,逐层切分 | 极端不平衡时某些类可能仍缺失 |
| 按时间划分 | 时序数据、预测类任务 | 按时间排序后顺序切分 | 时间切点附近的信息泄漏 |
4. 实操中的四个经典坑,每一个我都真实踩过
能生成 txt 不代表万事大吉,这份清单在使用过程中有非常多容易被忽略的细节。下面这几个坑是我自己在真实项目里一个个排出来的。
4.1 编码问题:UTF-8 with BOM 会让第一行路径多出隐形字符
我第一次写清单,在 Windows 上用记事本另存为 txt,结果训练脚本一运行,第一张图死活找不到。排查了半天才发现,文件开头多了一个 BOM 标记(\ufeff),拼接出来的路径变成\ufeffimages/cat_001.jpg,自然读不到。
解决办法很简单:写文件时统一指定encoding="utf-8",不写 BOM;读文件时也用同样的编码。如果拿到别人发的 txt 不确定有没有 BOM,可以用codecs检查或直接转存一遍。另一个相关坑是换行符——Windows 记事本默认写\r\n,Linux 下解析时行尾会多一个\r。我在生成端就统一用newline="\n",在读取端用strip()或者rstrip("\n")再处理一次,基本能避开。
4.2 路径分隔符在 Windows 和 Linux 之间来回踩
如果你自己在单机上做实验,路径问题可能不会暴露。但一旦把代码和数据集同时拷贝到服务器上跑,Windows 风格的反斜杠路径在 Linux 下就全部失效了。最稳妥的做法是从写入清单那天起就统一存正斜杠/。pathlib模块中Path会自动适应当前系统的分隔符,但为了跨平台一致,我会在生成清单时做一次显式的字符串替换,把os.sep统一换成/。读取时则用Path(line)去解析,它自己会识别正斜杠。
4.3 三个集合出现交集或漏样本,必须得做一致性校验
一个经常发生的情况是:数据目录里新增了文件,或者有人在清洗数据时删掉了某个子文件夹,结果 txt 清单里有些路径指向了不存在的地方。更隐蔽的问题是三个集合之间出现交集——比如不同批次生成的清单中间逻辑改了,同一张图既进了训练集又进了测试集。这会让评估指标完全失真,甚至调包。
我每次生成完清单,都会立刻跑一个三行检查:
def load_paths(path): with open(path, encoding="utf-8") as f: return set(line.strip() for line in f if line.strip()) train, val, test = map(load_paths, [ "data/lists/train.txt", "data/lists/val.txt", "data/lists/test.txt", ]) assert not (train & val), "train 与 val 存在交集" assert not (train & test), "train 与 test 存在交集" assert not (val & test), "val 与 test 存在交集"如果连"并集等于全集"都要校验,可以把全量文件也读入做一次对比。这步虽然简单,但在项目里救过我很多次。
4.4 增量更新时的划分漂移问题
前面提过,数据集不是一成不变的。今天加了 1000 张新图片,明天有人补了一批标注,如果你每次都重新跑完整划分脚本,虽然种子固定,但 shuffle 的顺序会随总数据的增加而整体变化,结果是老样本也可能被重新分配到了不同集合里。这带来的连锁反应是:你没法拿新实验结果跟历史实验结果做精确对比,因为测试集本身变了。
我自己现在的做法是:一旦原始数据稳定、第一版清单生成后,后续新增数据只做增量分配。比如新数据来了,按同样比例分成三份,追加到三个清单文件的末尾;或者根据业务规则,把所有新数据先放入测试集,观察模型在新样本上的表现。无论哪种策略,核心原则是:旧样本的归属保持不动,只处理新增部分。这样才能保证历史实验的可比性,也能让后续复盘时清楚每一批数据的加入时间。
增量分配的简化示例:
def append_to_list(new_samples, list_path): with open(list_path, "a", encoding="utf-8") as f: for line in new_samples: f.write(line + "\n")注意这是在原始数据不会"删改"的前提下才安全。如果旧文件本身要被替换,那还是得做一次全量审计,把旧清单和新的数据目录重新对齐。
5. 划分完成之后,怎么验证这一批 txt 是合格的
很多人写完清单就急着去训练,结果是在训练过程中发现奇奇怪怪的问题。我强烈建议,训练脚本启动之前花两分钟做一次系统性的验证,下面三个维度基本够用。
5.1 数量与比例检查
首先统计每个集合的行数,和预期的总量×比例对比。例如总共有 10000 张图,按 7:1.5:1.5 划分,train 应该是 7000 上下,val 和 test 各 1500 上下。如果偏差超过 1%,优先检查是不是文件后缀过滤、重复文件、路径合法性出了问题。
5.2 集合关系验证
第 4.3 节那段交集校验代码可以直接放到一个独立的validate_lists.py脚本里。这个脚本我会在每次数据变更后都跑一下,也放进 CI 流程。毕竟手改 txt 文件时很容易笔误,多一个自动校验环节会比肉眼靠谱得多。
5.3 训练代码里的"最后一道防线"
光在生成时校验还不行,因为可能在写 DataLoader 时,路径拼接、过滤逻辑把数据搞错。我习惯在训练脚本的数据加载阶段也做一层断言,判断训练集路径和验证集路径的交集为空。也许听起来有点重复,但在多卡训练、多线程加载的复杂环境下,这个 assert 能帮你提前暴露很多隐蔽的工程问题。它不消耗多少算力,却能让你睡得安心。
6. 把 txt 清单当成数据集的"账本":几个进阶用法
聊完基础,最后讲几个让这套方案真正发挥长效价值的进阶思路。
6.1 用 Git 管理划分清单,让每次实验都可回溯
txt 文件是纯文本,天然适合放进 Git 仓库跟踪。每次实验开始前,我在日志里记录当前实验所对应的 commit hash。这样将来任何时候,只要切到那个 commit,就能看到当时这一轮实验用的训练、测试、验证清单到底是什么。这在需要审计模型为什么出现偏差、或者在合作项目中复核数据合规性时,价值非常大。
6.2 直接编辑 txt 做业务干预
自动划分永远无法完全替代业务判断。比如某个罕见类别的全部样本仅 20 条,自动分层后测试集里只分配到 3 条,而你希望重点评估模型在这个类别上的表现。这种业务诉求,用代码写很绕,但直接编辑 txt 就简单了:把测试清单里加上你需要的那几条,训练清单里对应减掉。因为是纯文本,改动过程一目了然,事后也好回滚。有一点必须提醒:任何手工干预都要在文件里留下注释(比如加一行# 手工调整:将 xyz 样本移入测试集),否则时间久了没人记得这些改动是出于什么原因。
6.3 同一份数据可以维护多套划分
同一个项目里,可能既需要随机划分来跑标准 baseline,又需要按时间划分做线上模拟,还需要一份分层划分供小样本分析。用 txt 方案的巨大优势就在这里:我可以在lists目录下建random、time、stratified三个子目录,分别存放对应的train.txt、val.txt、test.txt。训练脚本只接收一个--lists_dir参数,换一套划分只是换个路径的事,完全不用改数据处理代码。这种灵活性,是硬编码切分逻辑的方案完全不具备的。
把这套"划分清单即账本"的思路落地以后,我几乎再没有因为数据集划分问题抓过狂。回过头看,它没有任何炫技成分,反而是那种"笨办法"最可靠。如果你也经常被数据集划分、实验复现、数据审计这类问题困扰,我建议直接动手,把你当前项目的文件清单落到 txt 里,跑一次对比验证。坚持一两个项目之后,你大概也会像我一样,再也回不去不落盘的随机切分了。