做深度学习项目的人大概都有过这种体验:模型结构调了半天,loss 曲线纹丝不动,回头一查,问题根本不在网络本身,而是数据喂进去的方式出了岔子。DataLoader这个组件看起来只是训练循环里一行代码的事,但它背后牵扯的自定义数据集制作,往往是整个 pipeline 里最容易埋雷、也最容易被低估的环节。我见过太多项目,模型代码写得漂漂亮亮,一到自定义数据集就卡住——要么__getitem__返回的格式对不上,要么多进程加载时莫名其妙报错,要么训练速度慢得离谱却找不到原因。
这篇内容想聊的就是DataLoader自定义数据集制作这件事。它不是什么高深的理论,但它是每个做训练的人都绕不开的基本功。不管你是刚接触深度学习、准备跑第一个自己的数据集,还是已经做过几个项目、想把手上的数据加载流程整理得更规范,下面这些从实际项目里攒下来的经验应该都能对上你的场景。我会从数据集类的设计逻辑讲起,把__init__、__len__、__getitem__这三个方法为什么这样设计、每个环节容易出什么问题、collate_fn什么时候必须自己写、多进程加载的坑怎么绕,一层层拆开说。核心关键词就一个:自定义数据集,围绕它把DataLoader的整条链路讲透。
1. 先想清楚数据集类到底在扮演什么角色
很多人上手写自定义数据集,是直接抄一个模板:继承Dataset,填三个方法,完事。能跑,但一旦数据格式变复杂、或者需要做数据增强、或者要处理变长序列,模板就不够用了。根本原因在于没想明白这个类在整条数据链路里到底承担什么职责。
1.1 数据集类是"索引到样本"的映射器
把Dataset抽象一下,它本质上就是一个映射函数:给它一个整数索引i,它返回第i个样本。__len__告诉外界一共有多少个样本,__getitem__负责根据索引取出对应的样本。DataLoader在这个基础上做了一层封装,负责批量组装、打乱顺序、多进程并行加载。
这个认知很关键,因为它决定了你写代码时的边界。数据集类不应该关心 batch 怎么组、不应该关心要不要 shuffle、也不应该关心用几个进程加载——这些是DataLoader的事。数据集类只该关心一件事:给定索引,怎么把这条样本从原始存储里读出来、做必要的预处理、返回一个干净的样本。
我见过有人在__getitem__里手动做 batch 拼接,或者在数据集类里维护一个全局的 shuffle 状态,这些都是职责越界。一旦越界,多进程加载时状态会分裂到各个 worker 里,行为完全不可预期。记住这条边界,后面很多坑都能提前避开。
1.2 三个方法各自的真实职责与常见误用
__init__的职责是准备元信息,不是加载全部数据。新手最容易犯的错是在__init__里把所有图片、所有文本一次性读进内存。数据量小的时候没事,一旦上到几十万条,内存直接爆掉。正确做法是在__init__里只建立索引——比如把所有文件路径收集成一个列表、把标注文件读成一个字典、把样本 ID 排好序。真正的数据读取放到__getitem__里按需进行。
__len__看似最简单,但它决定了DataLoader一个 epoch 会取多少次数据。这里有个细节:如果你用了sampler或者做了分布式训练,__len__的返回值会和实际每个进程拿到的样本数不一致,这是正常的,不要试图在__len__里做进程相关的逻辑。
__getitem__是重头戏,它返回的应该是单条样本,通常是一个张量、或者一个字典/元组。返回格式要和后续的collate_fn对齐,这一点后面单独讲。这里先强调一个原则:__getitem__要尽量做到无副作用、可重复调用。同一个索引调用两次,应该返回相同的结果(除非你故意做了随机增强)。如果里面有随机性,要确保随机源是每个 worker 独立的,否则多进程下所有 worker 会返回一模一样的"随机"结果——这是非常经典的坑,后面会详细说。
1.3 一个最小但完整的骨架长什么样
先给一个能直接用的骨架,把三个方法的职责摆清楚:
from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, file_list, label_map, transform=None): # 只做元信息准备,不加载实际数据 self.file_list = file_list # 路径列表 self.label_map = label_map # 标注字典 self.transform = transform # 预处理函数 def __len__(self): return len(self.file_list) def __getitem__(self, idx): path = self.file_list[idx] label = self.label_map[path] sample = self._load_sample(path) # 按需读取 if self.transform: sample = self.transform(sample) return sample, label def _load_sample(self, path): # 具体的读取逻辑,子类或外部可替换 ...这个骨架的价值不在于代码本身,而在于它把"元信息准备"和"样本读取"彻底分开了。_load_sample单独抽出来,是为了让读取逻辑可替换、可测试。实际项目里,读取逻辑往往是最容易出问题的地方——文件损坏、编码不一致、格式不统一,把它隔离出来,调试时能单独测。
2.__getitem__里那些决定成败的细节
__getitem__是整个数据集类里代码量最大、也最容易出问题的方法。它要处理读取、解码、预处理、格式转换一整套流程,每一步都有讲究。这一节把几个高频细节拆开讲。
2.1 读取失败不能直接抛异常
真实数据里总有那么几条是坏的:图片文件截断、标注缺失、编码乱码。如果__getitem__遇到坏样本直接抛异常,整个训练会中断。更麻烦的是,多进程加载时异常会在 worker 里抛出,堆栈信息经常被吞掉,你只能看到一个模糊的报错,定位起来非常痛苦。
我的做法是在__getitem__里对读取做一层保护,遇到坏样本时返回一个占位样本,同时记录日志。占位样本要保证格式和正常样本一致,这样collate_fn不会因为格式不一致而崩溃。比如图像任务可以返回一张全零的同尺寸图,文本任务可以返回一个特殊标记的短序列。训练时这些占位样本对梯度的贡献很小,但能保证流程不中断。
def __getitem__(self, idx): try: sample = self._load_sample(self.file_list[idx]) label = self.label_map[self.file_list[idx]] return sample, label except Exception as e: self._log_bad_sample(idx, e) return self._placeholder_sample(), self._placeholder_label()这里有个权衡:占位样本会让训练数据里混入噪声,如果坏样本比例很高,得先停下来清洗数据,而不是靠占位硬扛。占位机制是给"少量偶发坏样本"兜底的,不是用来掩盖数据质量问题的。
2.2 随机增强的随机源必须每个 worker 独立
这是多进程加载里最隐蔽的坑之一。假设你在__getitem__里用random.random()做随机裁剪,单进程跑的时候一切正常。一旦num_workers > 0,每个 worker 是独立的进程,它们 fork 自同一个父进程,随机数生成器的初始状态完全相同。结果就是:每个 worker 对不同的样本做出了完全相同的随机决策,数据增强形同虚设。
解决办法是给每个 worker 设置独立的随机种子。PyTorch 提供了一个worker_init_fn参数,在DataLoader初始化每个 worker 时调用:
import random import numpy as np def worker_init_fn(worker_id): seed = torch.initial_seed() % 2**32 random.seed(seed) np.random.seed(seed) loader = DataLoader(dataset, num_workers=4, worker_init_fn=worker_init_fn)torch.initial_seed()在每个 worker 里返回不同的值,用它来重置 Python 和 NumPy 的随机源,就能保证每个 worker 的随机增强真正独立。如果你用的是torch自带的随机函数,它内部已经处理了 worker 种子,但混用 Python 原生random或 NumPy 的随机函数时,这个worker_init_fn必须自己写。
2.3 返回格式要和下游严格对齐
__getitem__返回什么,collate_fn就得能处理什么。最常见的返回格式有两种:元组(sample, label)和字典{"input": sample, "label": label}。元组简单直接,适合结构固定的任务;字典可读性好、扩展方便,适合多输入的任务(比如同时有图像和文本)。
不管选哪种,关键是所有样本的返回结构必须一致。如果有的样本返回元组、有的返回字典,collate_fn在组装 batch 时会直接崩掉。我习惯在数据集类里定义一个sample_schema属性,把返回结构写清楚,collate_fn里也按这个 schema 来解析,两边对齐,改的时候不容易漏。
还有一个细节:返回的张量维度。图像任务里,单张图通常是(C, H, W),collate_fn默认会把它堆成(B, C, H, W)。如果你的__getitem__返回的是(H, W, C),堆出来就是(B, H, W, C),模型那边如果按(B, C, H, W)写的就会报维度错误。这种错误报得很直接,但排查时容易绕远路,因为报错位置在模型里,根因却在数据集。养成习惯:在数据集类里就把通道顺序统一好,别留到后面。
3.collate_fn什么时候必须自己动手写
默认的collate_fn能处理大部分规整数据:它把一批样本按位置堆叠成张量。但真实任务里,数据往往不规整——变长序列、不同尺寸的图像、多模态输入,这时候默认行为就不够用了,必须自己写collate_fn。
3.1 变长序列为什么默认 collate 会失败
文本任务里,每条样本的序列长度不同。默认collate_fn会尝试把一批(L_i,)的序列堆成(B, L)的张量,但L_i各不相同,堆叠直接失败。这时候必须自己写collate_fn,做 padding:找出这一批里的最大长度,把短序列补齐到最大长度,同时生成一个 mask 标记哪些位置是 padding。
def collate_fn(batch): sequences, labels = zip(*batch) max_len = max(len(s) for s in sequences) padded = torch.zeros(len(sequences), max_len, dtype=torch.long) mask = torch.zeros(len(sequences), max_len, dtype=torch.bool) for i, s in enumerate(sequences): padded[i, :len(s)] = s mask[i, :len(s)] = True return padded, mask, torch.tensor(labels)这里的关键是padding 值的选择。用 0 做 padding 时,要确保 0 不是有效 token 的 ID,否则模型会把 padding 当成真实输入。如果 0 已经被占用,就换一个专门的 padding token。mask 的作用是让模型在计算注意力或损失时忽略 padding 位置,这个 mask 要一路传到模型里,不能在中途丢掉。
3.2 图像尺寸不一致时的两种处理策略
图像任务里,如果所有图都预先 resize 到同一尺寸,默认collate_fn就够了。但有些任务不能统一 resize——比如目标检测、或者需要保留原始分辨率的场景。这时候一批图尺寸各异,默认堆叠失败,得自己处理。
策略一是在collate_fn里做 padding,把所有图补到这一批的最大尺寸。好处是保留原始信息,坏处是 padding 区域会引入无效计算,而且不同 batch 的最大尺寸不同,模型输入尺寸会波动。策略二是把 resize 放到__getitem__里,统一到固定尺寸,collate_fn用默认的就行。哪种更好取决于任务:分类任务通常选策略二,检测任务往往选策略一。
我实际项目里更常用策略二,因为固定输入尺寸对模型更友好,显存占用也稳定。如果确实需要保留原始尺寸,会在collate_fn里做 padding,同时把每张图的原始尺寸也返回,供后续还原坐标用。
3.3collate_fn里不要做重计算
collate_fn是在主进程里跑的(或者说,是在每个 worker 把样本送回主进程之后、组装 batch 时跑的),它做的事情会直接影响每个 batch 的准备时间。有人把归一化、甚至模型前处理都塞进collate_fn,结果数据准备成了瓶颈。
原则是:能在__getitem__里做的预处理,不要放到collate_fn里。__getitem__在多进程下并行执行,collate_fn相对串行。把重计算放在__getitem__里,能充分利用多核;collate_fn只做组装、padding、类型转换这类轻量操作。这个分工搞反了,num_workers开再多也提不上速度。
4. 多进程加载的坑与调优思路
num_workers这个参数看起来只是填个数字,但它背后牵扯到进程模型、内存拷贝、共享策略一整套机制。填错了轻则速度上不去,重则程序卡死或内存爆掉。
4.1num_workers不是越大越好
直觉上进程越多加载越快,但实际不是。每个 worker 都是独立进程,有自己的内存空间,主进程要把数据从 worker 拷贝过来,这个拷贝有开销。worker 数量超过 CPU 核心数之后,进程调度本身的开销会抵消并行收益,速度反而下降。
经验值:num_workers设成 CPU 物理核心数的 0.5 到 1 倍之间比较稳。比如 8 核机器,设 4 到 8 都合理。如果数据读取本身很轻(比如全在内存里),num_workers=0反而最快,因为省掉了进程间通信。判断标准很简单:跑一个 epoch,看 GPU 利用率。如果 GPU 利用率上不去、经常空等,说明数据加载是瓶颈,加 worker;如果 GPU 已经跑满,加 worker 没意义。
还有一个容易忽略的点:num_workers > 0时,数据集对象会被 pickle 到每个 worker 里。如果数据集对象里存了很大的数据结构(比如整个标注表),每个 worker 都会复制一份,内存占用成倍增长。解决办法是把大结构放到共享内存里,或者用mmap方式读取,避免复制。
4.2 Windows 和 Linux 下的行为差异
多进程加载在不同操作系统下行为不一样,这个坑跨平台开发时经常踩。Linux 下默认用fork启动 worker,子进程直接继承父进程的内存状态,启动快、开销小。Windows 下没有fork,用的是spawn,每个 worker 重新导入模块、重新执行初始化,启动慢,而且要求数据集类和相关代码必须能被 pickle。
这意味着在 Windows 下,数据集类里不能有 lambda 函数、不能有无法 pickle 的对象(比如打开的文件句柄、数据库连接)。如果你在__init__里打开了一个文件句柄存成属性,Linux 下可能侥幸能跑,Windows 下直接报 pickle 错误。稳妥做法是:数据集类里只存可 pickle 的简单数据(路径、列表、字典),所有资源在__getitem__里按需打开、用完即关。
4.3persistent_workers与prefetch_factor的取舍
每次 epoch 结束,DataLoader默认会销毁所有 worker,下个 epoch 重新创建。worker 创建有开销,如果 epoch 很多、每个 epoch 又很短,这个开销会累积。persistent_workers=True让 worker 在 epoch 之间保持存活,省掉重复创建的开销。代价是 worker 占用的内存不会释放,数据量大时要权衡。
prefetch_factor控制每个 worker 提前准备多少个 batch。默认是 2,意思是 worker 在当前 batch 被消费时,已经在准备后面两个 batch 了。调大能缓解 GPU 等待,但会占用更多内存。我一般先保持默认,如果观察到 GPU 有明显空等,再往上调,同时盯着内存别爆。
| 参数 | 默认值 | 调大的收益 | 调大的代价 |
|---|---|---|---|
num_workers | 0 | 并行加载,缓解 GPU 空等 | 进程开销、内存复制 |
prefetch_factor | 2 | 提前准备 batch,减少等待 | 内存占用上升 |
persistent_workers | False | 省去 epoch 间重建 worker | worker 内存不释放 |
5. 从踩坑到跑通:一条完整的排查链路
前面讲的都是"应该怎么做",但真实项目里更常见的情况是"已经跑不起来了,怎么定位"。这一节还原一条完整的排查链路,把常见故障的定位思路走一遍。
5.1 报错信息指向模型,根因却在数据集
最典型的情况:训练时报维度不匹配,报错位置在模型 forward 里,你盯着模型改了半天没结果。这时候要往回追:打印一个 batch 的实际形状。在训练循环里加一行print(batch[0].shape),看实际喂进去的是什么。十有八九是数据集返回的维度顺序和模型预期不一致,或者collate_fn堆叠的维度不对。
排查顺序建议是:先看单条样本的形状(在__getitem__里打印),再看一个 batch 的形状(在训练循环里打印),两者对比,问题出在哪一环一目了然。别一上来就改模型,数据形状对不上,模型改到天亮也没用。
5.2 loss 不降反升,先怀疑数据而不是模型
训练跑起来了,但 loss 震荡或者不收敛。很多人的第一反应是调学习率、换优化器,但数据加载环节的问题同样会导致这个现象。几个高频原因:随机增强的随机源没隔离,导致增强失效或异常;padding 值选错,模型把 padding 当有效输入;标签和样本错位,__getitem__里索引对错了。
定位方法:关掉所有随机增强,用固定的小批量数据过拟合。如果模型能把这批数据拟合到接近零 loss,说明模型没问题,问题在数据加载的随机性或对齐上;如果连固定小批量都拟合不了,再回头查模型。这个"先过拟合小批量"的技巧是排查训练问题的万能第一步,能快速把问题范围从"模型+数据"缩小到其中一边。
5.3 训练越跑越慢,查内存和 worker 状态
训练初期速度正常,跑着跑着越来越慢,最后卡死。这种渐进式变慢通常是内存泄漏。常见来源:__getitem__里打开了文件或连接却没关闭;worker 里累积了全局列表;persistent_workers=True时 worker 内存持续增长。
排查手段:用系统监控看内存曲线,如果是阶梯式上升,基本可以确定是泄漏。然后在__getitem__里检查所有资源打开的地方,确保用完即关。用with语句管理文件句柄是最省心的做法。另外,num_workers设得过大、每个 worker 又持有大数据结构时,内存会成倍增长,这种情况降低 worker 数量或者改用共享内存能缓解。
6. 几个让数据集更耐用的工程习惯
代码能跑通只是及格线,真正省心的是那些一开始就做对的工程习惯。这些习惯不增加多少工作量,但能在后续迭代里省掉大量调试时间。
6.1 把预处理逻辑做成可配置的
预处理(归一化参数、resize 尺寸、增强强度)经常要调。如果这些值硬编码在__getitem__里,每次调整都要改代码、重启训练。更好的做法是把它们抽成配置,通过__init__传进来。这样调参时只改配置不改逻辑,也方便做对比实验——同一份数据、不同配置,跑两组结果直接比。
我习惯把预处理相关的参数打包成一个字典或者配置对象,数据集类只负责按配置执行。这样数据集类本身保持稳定,变化的只是外部配置,测试和复用都方便。
6.2 给数据集写一个自检脚本
数据集写完,别急着扔进训练循环。先写个小脚本,随机抽若干条样本,检查:形状对不对、标签范围合不合理、有没有坏样本、预处理后的数值范围是否正常。这个脚本跑一遍只要几秒钟,但能提前发现大部分低级错误。
自检脚本里我通常会检查这几项:样本总数和预期是否一致、随机抽 100 条看有没有异常、统计标签分布看是否严重不均衡、打印几条样本的数值范围(比如图像像素是否在 0-1 或 0-255)。这些检查做完,再进训练,心里有底。
6.3 版本化你的数据划分
训练集、验证集、测试集的划分一旦确定,要固定下来并存档。我见过因为重新随机划分导致验证集泄漏进训练集的情况,结果指标虚高,上线后原形毕露。做法很简单:划分时固定随机种子,把划分结果(哪些 ID 属于哪个集合)存成文件,后续所有实验都读这个文件,不再重新划分。
这个习惯在多人协作时尤其重要。没有固定的划分文件,每个人本地跑出来的结果都没法对比。存一份划分文件,大家用同一份,结果才有可比性。
6.4 日志里记录数据加载的关键指标
训练日志里除了 loss 和指标,建议加上数据加载相关的信息:每个 epoch 的加载耗时、坏样本数量、实际参与训练的样本数。这些信息平时不起眼,但出问题时是定位的第一手线索。比如坏样本数量突然上升,说明数据源出了问题;加载耗时突然变长,可能是磁盘 IO 或者 worker 状态异常。
我在实际项目里的体会是,DataLoader自定义数据集这件事,难点从来不在 API 本身——三个方法、几个参数,看文档半小时就能写出来。真正花时间的是那些边界情况:坏数据怎么兜底、多进程下随机性怎么保证、变长数据怎么组装、内存怎么不泄漏。这些问题文档里不会写,只有实际跑过、踩过、调过,才会形成肌肉记忆。上面这些经验,本质上就是把那些"踩过一次就不想再踩"的坑提前摆出来,让你在写第一版的时候就能绕开。数据集是整个训练 pipeline 的地基,地基打稳了,后面调模型、调超参才有意义。