资讯动态

PyTorch自定义数据集加载实战:从Dataset到DataLoader的完整指南

发布时间:2026/10/18 7:59:04 来源:尧图企业网站定制
1. 从文件夹到Tensor自定义数据集加载的完整链路很多人第一次用PyTorch训练模型跑通了官方MNIST或CIFAR-10的示例之后信心满满地准备上自己的数据结果卡在第一步——数据根本喂不进去。官方示例里torchvision.datasets.MNIST一行代码就把数据下载、解压、转Tensor全搞定了但换成自己那堆散落在文件夹里的图片、CSV或者numpy数组就不知道从哪下手了。这个问题的本质是PyTorch的数据加载机制其实只有两个核心抽象——Dataset和DataLoader。Dataset负责定义“一条数据长什么样、怎么取”DataLoader负责“怎么批量取、要不要打乱、用几个进程取”。官方数据集之所以方便是因为有人替你写好了Dataset子类。你要做的就是为自己的数据写一个同样结构的东西。这篇文章面向的是已经能跑通PyTorch基础训练流程、但还没独立处理过自定义数据的开发者。我会从最基础的Dataset继承讲起覆盖图片分类、CSV表格、numpy数组三种最常见的数据形态然后深入到DataLoader的并行加载、内存优化、以及那些官方文档不会告诉你的踩坑细节。读完你至少能做到不管数据长什么样都能把它变成DataLoader能吃的格式并且知道每一步为什么这么写。2. 三种数据形态的Dataset写法拆解2.1 图片文件夹ImageFolder能解决80%的场景如果你的数据结构是每个类别一个文件夹比如train/cat/xxx.jpg、train/dog/xxx.jpg那torchvision.datasets.ImageFolder直接就能用不需要自己写Dataset。这是最容易被忽略的一点——很多人上来就手写Dataset其实标准场景用现成工具更稳。from torchvision import datasets, transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder( root./data/train, transformtrain_transform )ImageFolder会自动扫描root下的子文件夹把文件夹名作为类别标签按字母序编号。这里有个容易踩的坑类别编号是按文件夹名字母序来的不是按你想要的顺序。比如cat是0、dog是1但如果文件夹叫0_cat、1_dog编号就跟着数字走。训练完之后做推理时类别映射搞反了预测结果就全乱了。我的习惯是训练完立刻把dataset.classes和dataset.class_to_idx存成json推理时直接读不靠记忆。transform里的Normalize参数用的是ImageNet的均值和标准差这是迁移学习场景下的惯例。如果你是从头训练且数据分布差异大可以自己算一下数据集的均值和方差但实测下来用ImageNet的参数在大多数场景下也不会差太多。2.2 自定义Dataset__getitem__才是核心当数据结构不是标准文件夹时就得自己写Dataset子类。核心就三个方法__init__做初始化读文件列表、标签等__len__返回样本总数__getitem__根据索引返回一条数据。from torch.utils.data import Dataset from PIL import Image import os class MyImageDataset(Dataset): def __init__(self, img_dir, label_file, transformNone): self.img_dir img_dir self.transform transform self.samples [] with open(label_file, r) as f: for line in f: name, label line.strip().split(,) self.samples.append((name, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): name, label self.samples[idx] img_path os.path.join(self.img_dir, name) image Image.open(img_path).convert(RGB) if self.transform: image self.transform(image) return image, label这段代码看起来简单但有几个关键决策点值得说清楚。为什么在__init__里读标签文件而不是在__getitem__里读因为__getitem__会被调用成千上万次每次调用都去读文件、解析文本I/O开销会拖垮整个训练流程。把所有元数据在初始化时一次性加载到内存列表里__getitem__只做索引查找和图片读取这是标准做法。为什么用Image.open而不是cv2.imread两者都能读图片但Image.open是惰性加载只有调用convert或transform时才真正解码像素。在DataLoader多进程模式下惰性加载能减少每个worker的内存占用。另外cv2读进来是BGR通道跟PyTorch预期的RGB不一致容易出颜色错乱的问题。convert(RGB)不能省。如果你的数据集里混有灰度图、RGBA图不统一转RGB的话后面ToTensor出来的通道数不一致collate的时候直接报错。这个坑我在处理混合来源的图片数据时踩过排查了半天才发现是某几张图是四通道的PNG。2.3 CSV和numpy非图像数据的处理套路表格数据和numpy数组的处理逻辑跟图片类似区别在于__getitem__里返回的是tensor而不是PIL Image。import pandas as pd import torch class TabularDataset(Dataset): def __init__(self, csv_path, target_col, transformNone): self.df pd.read_csv(csv_path) self.target_col target_col self.transform transform self.features self.df.drop(columns[target_col]).values.astype(float32) self.labels self.df[target_col].values.astype(float32) def __len__(self): return len(self.df) def __getitem__(self, idx): x torch.tensor(self.features[idx]) y torch.tensor(self.labels[idx]) if self.transform: x self.transform(x) return x, y这里有个性能细节self.features在初始化时已经转成了numpy数组__getitem__里只做torch.tensor转换。不要在__getitem__里做df.iloc[idx]pandas的行索引比numpy数组索引慢一个数量级。如果数据量特别大比如上百万行连numpy数组都可能占太多内存这时候可以考虑用内存映射文件或者HDF5格式按需读取。对于numpy数组直接作为数据源的情况TensorDataset是最省事的方案from torch.utils.data import TensorDataset X torch.randn(1000, 32) y torch.randint(0, 10, (1000,)) dataset TensorDataset(X, y)TensorDataset把多个tensor按第一维对齐打包__getitem__返回对应索引的切片。适合数据已经全部在内存里、不需要复杂预处理的场景。3. DataLoader的并行加载与那些反直觉的参数3.1 num_workers到底设多少DataLoader的num_workers控制用几个子进程加载数据。设成0表示在主进程里加载设成大于0会启动对应数量的worker进程。很多人以为这个值越大越快实际上不是。worker进程的工作是从Dataset取数据、做transform、拼成batch。如果num_workers太大进程间通信和内存复制的开销会超过并行带来的收益。经验值是设为CPU物理核心数比如8核机器设4到8。但更准确的做法是看GPU利用率如果训练时GPU利用率经常掉到50%以下说明数据加载是瓶颈可以适当增加worker如果GPU利用率稳定在90%以上说明数据供给跟得上不用再加。还有一个隐藏问题每个worker都会复制一份Dataset对象。如果你的Dataset在__init__里加载了很大的数据比如整个图片路径列表内存占用会乘以worker数量。我见过有人把几GB的numpy数组放在Dataset里num_workers8直接爆内存。解决办法是用torch.utils.data.Subset或者把大数据放到共享内存里。3.2 collate_fn当默认拼接不够用时DataLoader默认的collate_fn会把一个batch里的样本堆叠成tensor。对于图像分类这种每条数据形状固定的任务默认的够用。但如果你的数据每条长度不一样——比如文本序列、变长音频、不同尺寸的图片——默认的collate_fn会在torch.stack那一步报错。这时候需要自定义collate_fnfrom torch.nn.utils.rnn import pad_sequence def collate_fn(batch): sequences, labels zip(*batch) lengths torch.tensor([len(s) for s in sequences]) padded pad_sequence(sequences, batch_firstTrue, padding_value0) return padded, lengths, torch.tensor(labels)pad_sequence把变长序列用0填充到batch内最大长度。注意padding_value的选择如果是文本ID序列0通常对应padding token如果是特征值0可能跟真实值混淆这时候要用一个不会出现的值比如-1或者配合attention mask使用。自定义collate_fn时还有一个容易忽略的点它是在worker进程里执行的不是主进程。所以collate_fn里不要做依赖主进程状态的操作也不要往全局变量里写东西。3.3 pin_memory和drop_last的取舍pin_memoryTrue会把数据放到锁页内存里加速CPU到GPU的传输。这个参数在GPU训练时几乎总是应该设为True代价是占用一些不可交换的内存。只有在内存极度紧张或者纯CPU训练时才关掉。drop_lastTrue会丢弃最后一个不满的batch。什么时候需要当你的模型里有BatchNorm层而最后一个batch只有一个样本时BatchNorm会报错因为无法计算方差。另外如果训练时用了分布式训练每个进程的batch大小不一致会导致同步问题。但drop_last也有代价丢弃的样本相当于没参与训练如果数据集本身很小丢一个batch可能就丢了5%的数据。我的习惯是数据集大于1万条时开drop_last小于1万条时关掉同时确保最后一个batch至少有2个样本。4. 数据增强管道里的顺序陷阱与性能账4.1 transform的顺序不是随便排的transforms.Compose里的操作按列表顺序执行顺序错了轻则效果打折重则直接报错。几个基本原则Resize和RandomCrop要在ToTensor之前。因为ToTensor会把PIL Image或numpy数组转成tensor同时把数值范围从0-255缩放到0-1。Resize和RandomCrop这些几何变换在PIL Image上操作更快而且ToTensor之后再做几何变换需要处理通道维度容易出错。Normalize必须在ToTensor之后。因为Normalize的输入需要是tensor而且它按通道做减均值除标准差PIL Image没有通道维度的概念。RandomHorizontalFlip、ColorJitter这些增强操作放在ToTensor之前或之后都可以但放在之前更快因为PIL的操作是C实现的比tensor操作省CPU。一个典型的正确顺序transforms.Compose([ transforms.Resize(256), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])4.2 数据增强不是越多越好我见过有人在训练集上堆了十几种增强结果模型收敛极慢验证集准确率还不如不加。原因是过度的增强让训练分布和真实分布偏离太远模型学到的特征跟测试时看到的不匹配。一个实用的判断方法增强后的图片你自己还能认出类别吗如果一张猫的图片经过增强后你都要犹豫一下那模型也很难学。RandomRotation超过30度、RandomResizedCrop的scale范围太宽、ColorJitter的饱和度变化太大都容易把图片变得“面目全非”。另一个经验是增强的强度应该跟数据量成反比。数据量少的时候比如每类几百张适度增强能防止过拟合数据量大的时候每类几万张增强的收益递减反而增加训练时间。我通常先用弱增强跑一版baseline看过拟合程度再决定要不要加码。4.3 用torchvision v2还是自己写torchvision.transforms.v2是较新版本引入的API支持对tensor和PIL统一处理而且可以同时变换图像和标注比如分割mask、检测框。如果你的任务涉及目标检测或分割v2的Transform能保证图像和标注用同样的随机参数变换避免自己写同步逻辑。但v2的生态还在完善中有些第三方库还没适配。如果是纯分类任务v1的transforms足够用而且资料多、踩坑少。我的建议是分类任务用v1检测/分割任务用v2。5. 那些让我debug到凌晨的报错与修复5.1 报错“RuntimeError: DataLoader worker exited unexpectedly”这个报错信息极其模糊它只告诉你worker挂了但不告诉你为什么挂。常见原因有三个第一Dataset的__getitem__里抛了异常。比如图片路径不存在、图片文件损坏、标签越界。因为异常发生在子进程里主进程只看到worker退出看不到具体异常。排查方法是在__getitem__里加try-except把异常信息写日志def __getitem__(self, idx): try: # 正常逻辑 return image, label except Exception as e: print(fError at index {idx}: {e}) raise第二内存不足。每个worker都复制了一份数据如果数据太大或者worker太多系统OOM killer会杀掉worker进程。用dmesg或系统日志能看到OOM记录。解决办法是减少num_workers或者用更省内存的数据结构。第三在Dataset里用了不可序列化的对象。num_workers0时Dataset会被pickle到子进程。如果__init__里存了数据库连接、文件句柄、lambda函数pickle会失败。解决办法是把这些放到__getitem__里按需创建或者用__getstate__/__setstate__自定义序列化。5.2 训练loss不下降但数据明明没问题这种情况我遇到过好几次最后发现是标签和数据没对齐。比如图片按文件名排序标签按另一个顺序排列__getitem__返回的image和label不是同一条数据的。排查方法很简单取前几个batch把图片和标签打印出来人工核对。另一个隐蔽的原因是Normalize的参数用错了。如果训练时用了ImageNet的均值方差但推理时忘了加Normalize或者用了不同的参数模型看到的输入分布不一致预测结果会完全乱掉。我的做法是把transform定义成函数训练和推理共用同一套预处理逻辑只把增强部分区分开。5.3 验证集loss比训练集低很多这听起来是好事但通常意味着验证集的transform跟训练集不一致。比如训练集有RandomHorizontalFlip和RandomCrop验证集只有Resize和CenterCrop。如果验证集的预处理过于简单模型在验证集上看到的图片更“干净”loss自然更低。这不是模型泛化好而是评估条件不一致。正确的做法是验证集不做随机增强但几何变换Resize、Crop要和训练集保持一致的目标尺寸。Normalize参数必须完全相同。6. 大数据集下的加载优化实战6.1 用WebDataset格式减少小文件I/O当数据集有几十万张小图片时文件系统的元数据操作会成为瓶颈。每读一张图都要open、read、close即使有worker并行磁盘IOPS也可能跟不上。这时候可以把图片打包成tar格式用WebDataset库按流读取。import webdataset as wds dataset wds.WebDataset(data/shards/{00000..00099}.tar) dataset dataset.decode(pil).to_tuple(jpg, cls)WebDataset把多个样本打包成一个shard顺序读取tar文件比随机读取小文件快得多。而且它天然支持分布式训练每个进程读不同的shard不需要额外的采样器。6.2 预取与缓存策略DataLoader本身有预取机制当num_workers0时worker会在后台提前加载下一个batch的数据。但如果你的__getitem__特别慢比如要做复杂的在线增强预取可能不够。可以在Dataset层面加一层缓存把处理好的tensor存到内存或磁盘下次直接读缓存。内存缓存适合数据集能全部放进RAM的场景用一个字典或LRU cache实现。磁盘缓存适合大数据集把处理后的tensor存成.pt文件用torch.load读取。但缓存要注意失效问题如果transform参数变了缓存必须重建。6.3 分布式训练时的数据划分用DistributedSampler时每个进程只看到数据集的一部分。DataLoader的sampler参数要设成DistributedSampler(dataset)并且每个epoch开始前调用sampler.set_epoch(epoch)保证每个epoch的划分不同。一个常见的错误是忘了设shuffleFalse。DistributedSampler自己会做shuffle如果DataLoader的shuffle也设成True两者冲突会导致每个进程看到重复数据。正确写法sampler DistributedSampler(dataset, shuffleTrue) loader DataLoader(dataset, batch_size32, samplersampler, shuffleFalse)7. 我个人的几条实战心得关于数据加载我踩过的坑比模型本身还多。分享几条用血泪换来的经验。第一永远先用小批量数据跑通全流程。拿10张图片、2个类别把训练、验证、推理的代码全部跑一遍确认没有shape错误、标签对齐、transform顺序问题再上全量数据。直接上全量数据debug每次报错都要等几分钟才能复现效率极低。第二把数据集的统计信息打印出来。类别分布是否均衡、图片尺寸范围、通道数、标签范围这些信息在写Dataset的时候就应该确认。我习惯在__init__结束后打印一行摘要比如“Dataset: 5000 samples, 10 classes, image size range [120, 800]”。这行日志在后续排查问题时能省很多时间。第三transform的参数要跟模型输入匹配。比如模型第一层是Conv2d(3, 64, kernel_size7, stride2)那输入图片至少要是224x224否则经过几次下采样后特征图就变成1x1了。这个错误在换模型时特别容易犯因为transform是之前写好的忘了跟着改。第四验证集的transform要固定。不要用随机增强不要用RandomCrop用Resize加CenterCrop或者直接Resize到目标尺寸。验证集的目的是评估模型在真实分布上的表现引入随机性会让评估结果不可复现。第五保存一份数据集的元信息。类别映射、归一化参数、图片尺寸这些信息在推理阶段必须和训练时完全一致。我通常存一个preprocess_config.json训练和推理都从这个文件读参数避免手动同步出错。最后说一个关于性能的观察在大多数中小规模数据集上几万到几十万样本数据加载的瓶颈往往不在num_workers的数量而在__getitem__里的操作是否高效。把PIL的convert、resize这些操作放在__getitem__里是必要的但不要在__getitem__里做os.listdir、pd.read_csv、网络请求这些慢操作。把这些挪到__init__里训练速度会有肉眼可见的提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑