资讯详情

资讯详情

DataLoader自定义数据集实战:从设计到多进程调优的避坑指南

做深度学习项目的人大概都有过这种体验模型结构调了半天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, transformNone): # 只做元信息准备不加载实际数据 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_workers4, worker_init_fnworker_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, dtypetorch.long) mask torch.zeros(len(sequences), max_len, dtypetorch.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_workers0反而最快因为省掉了进程间通信。判断标准很简单跑一个 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_workersTrue让 worker 在 epoch 之间保持存活省掉重复创建的开销。代价是 worker 占用的内存不会释放数据量大时要权衡。prefetch_factor控制每个 worker 提前准备多少个 batch。默认是 2意思是 worker 在当前 batch 被消费时已经在准备后面两个 batch 了。调大能缓解 GPU 等待但会占用更多内存。我一般先保持默认如果观察到 GPU 有明显空等再往上调同时盯着内存别爆。参数默认值调大的收益调大的代价num_workers0并行加载缓解 GPU 空等进程开销、内存复制prefetch_factor2提前准备 batch减少等待内存占用上升persistent_workersFalse省去 epoch 间重建 workerworker 内存不释放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_workersTrue时 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 的地基地基打稳了后面调模型、调超参才有意义。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →