资讯详情

资讯详情

PyTorch自定义数据集全指南:从Dataset构建到DataLoader调优实战

做深度学习项目的人应该都经历过这种落差在公开数据集上模型表现得像个专家一换到自己的业务数据立刻垮掉。原因往往不在模型结构而在最容易被轻视的数据入口——你根本没有一套能稳定、高效、灵活地把原始图片/标签喂给网络的机制。这篇文章想聊的核心就是Pytorch下如何构建属于自己的训练数据集从目录怎么组织、Dataset类怎么写、DataLoader参数怎么调到数据增强怎么配、报错了怎么排查按一条完整链路走下来让自建数据集这件事不再靠网上零散代码拼凑。内容适合两类人一是刚接触Pytorch、只会用torchvision里现成数据集的新手想知道换成自己的图片后整套流程该怎么改二是已经在业务里跑了几个模型、但每次处理数据都手忙脚乱的开发者。我会把我实际踩过、修过的坑一起写进来尽量让你看完能直接照着动手。1. 数据集设计动手前先想清楚三件事1.1 数据形态决定组织方式构建数据集的第一个问题不是写代码而是想清楚你的数据长什么样。不同形态的数据组织方式和使用方式完全不同。最常见的场景是单标签图像分类。这种情况下数据集最合适的组织方式是文件夹即类别每个类别一个文件夹文件夹里放对应类别的图片。我推荐这个方案的原因很朴素第一文件夹名天然就是标签不需要额外维护一份映射表第二查看、增删数据都极其方便给新图片归类就是把它丢进某个文件夹第三如果你以后想换框架或者换任务这种结构几乎零改造成本。如果你的数据不是单标签分类而是带多个属性标签、数值标签比如你要预测一个零件的磨损度、同时记录它的材质和批次那文件夹方式就撑不开了。这种多标签或回归场景老老实实整理一张CSV索引文件更靠谱。CSV里至少包含三列图片相对路径、第一个标签、第二个标签视需求增减。读CSV用Pandas一行就能把标注信息拉进内存然后交给Dataset索引。实际项目里还有一种情况是数据已经有现成的标注文件尤其是目标检测、实例分割这类任务标注基本是json或者txt。这种格式的解析需要针对任务特化不是单纯几行代码能覆盖的建议别急着从头写解析器先看看有没有成熟工具库能用。以自建图像分类数据集为主要目标来做的话文件夹方式是最稳的起点。1.2 标签体系决定后续工作量很多人忽略标签体系的设计直接在代码里写死label 0等类别一多就后悔了。实际上标签体系要考虑三层标签怎么存、怎么读、怎么喂给损失函数。先说存储。文件夹方式下标签来自目录名CSV方式下标签来自列。我建议即便用文件夹方式也顺手生成一份class_to_idx.json或者普通txt把类别名和整数索引的映射关系固定下来。这份文件非常重要因为训练、验证、测试、推理四个阶段都需要用同一套映射如果某天有人改了文件夹名你的模型输出就全乱套了。养成第一个习惯类别映射只生成一次之后任何地方都从这份文件里读取。再说编码。Pytorch的交叉熵损失函数CrossEntropyLoss()要求输入的是整数索引标签不是one-hot形式也不是字符串。所以在Dataset里返回标签时直接返回整数索引就好。很多人第一次写代码时会先用LabelEncoder或者手动字典把类别字符串转成数字这个思路没毛病但注意要保证训练、验证、测试三个集合用的是同一个字典映射。最后是检查。如果数据量不大我会在构建完成后把每个类别的样本数打印出来列一张表看看是否均衡。不均衡并不是一定不能训练但至少你要心里有数否则训练出来的模型很容易被多数类带偏。1.3 目录结构推荐与踩坑经验目录结构看起来小事但它的影响贯穿整个项目周期。我的习惯是把数据集根目录分成train和val如有测试需求再分test每个子目录下面是类别文件夹。举个例子data_root/ ├── train/ │ ├── cat/ │ │ ├── cat_001.jpg │ │ ├── cat_002.jpg │ │ └── ... │ ├── dog/ │ │ ├── dog_001.jpg │ │ └── ... │ └── ... ├── val/ │ ├── cat/ │ │ ├── cat_003.jpg │ │ └── ... │ └── dog/ │ └── ... └── class_to_idx.json这里有个实操经验要特别提醒整套代码只使用相对路径或统一的前缀路径不要用一串迷之绝对路径。因为项目目录一旦移动绝路径全部失效到时候你会花大量时间改代码。我在实际项目中就用pathlib.Path来处理路径代码里永远不做字符串硬拼接目录分隔符交给系统自动处理。另一个容易踩的坑是图片格式混用。文件夹里可能同时存在jpg、png、bmp、webp甚至还有隐藏文件尤其是Mac系统下的DS_Store。构建samples列表时一定过滤一遍扩展名只加入你认可的类型否则加载图片时那个奇葩文件会让整个DataLoader崩掉。过滤逻辑我会放在后面Dataset实现里。从这三点能看出来数据集构建不是写一个类就完事前面这些设计决策决定了后面代码的复杂度。想清楚再动手比边写边改省时间得多。2. 自定义Dataset类把文件夹变成网络能吃的数据流2.1 自定义Dataset的四个必修方法Pytorch的数据读取有一套约定优于配置的机制你只需要继承torch.utils.data.Dataset然后实现四个方法它就能跟DataLoader无缝配合。这四个方法分别是__init__、__len__、__getitem__、以及可选的__repr__方法。__init__里做的事可以理解为数据集的总策划扫描文件夹、建立样本列表、加载类别映射、保存transform。注意不要在__init__里把所有图片一次性读进内存保存为PIL对象或Tensor。如果数据集有几万张图一次性读进内存轻则几十GB内存爆掉重则直接把训练进程拖死。正确做法是只存路径和标签图片读取放到__getitem__中按需进行。__len__就是返回样本总数。这个很简单但别写错因为后续DataLoader的采样器、批次的生成都依赖这个数字。__getitem__接收一个索引返回一个样本对(image, label)。注意这个函数每次会被DataLoader以不同的索引反复调用如果你在函数内部打开了文件、加载了图片那么确保每次调用都不会产生外部副作用比如把文件句柄留在外面、修改了共享变量否则多进程数据加载时会出现各种诡异问题。还有一个隐藏方法__repr__很多人不写但我建议写一下。它不影响训练但你在打印数据集对象时能直接看到数据集的样本数、类别数、数据根目录这些信息排查问题时很有用。其实Pytorch的torchvision.datasets.ImageFolder就实现了这些逻辑但很多场景下我们需要在加载时加入自己的逻辑比如过滤异常图片、做特殊的文件命名解析所以自己写一遍完全值得。2.2 图像分类数据集的完整实现直接上一个可以跑通的图像分类Dataset实现import os from pathlib import Path from PIL import Image from torch.utils.data import Dataset class ImageFolderDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir Path(root_dir) self.transform transform # 自动扫描类别目录 self.classes sorted([ p.name for p in self.root_dir.iterdir() if p.is_dir() ]) # 类别名 - 整数索引 self.class_to_idx {cls: idx for idx, cls in enumerate(self.classes)} self.samples [] for cls in self.classes: cls_dir self.root_dir / cls for img_path in cls_dir.iterdir(): # 过滤出常见图片格式避免隐藏文件掺进来 if img_path.suffix.lower() in {.jpg, .jpeg, .png, .bmp, .webp}: self.samples.append((str(img_path), self.class_to_idx[cls])) # 顺便打印类别统计瞄一眼分布 print(f[Dataset] 根目录: {self.root_dir}) print(f[Dataset] 类别数: {len(self.classes)} 样本总数: {len(self.samples)}) for cls, idx in self.class_to_idx.items(): cnt sum(1 for _, label in self.samples if label idx) print(f[Dataset] {cls} (idx{idx}): {cnt} 张) def __len__(self): return len(self.samples) def __getitem__(self, index): img_path, label self.samples[index] # 统一转成RGB避免灰度图、四通道图带来的维度问题 image Image.open(img_path).convert(RGB) if self.transform is not None: image self.transform(image) return image, label这个类已经足够用于很多的单标签分类场景。代码里我留意了几个点值得细说。第一我用pathlib.Path替代了os.path.join在Windows、Linux上都不用担心路径分隔符问题代码更简洁。iterdir()返回的是目录下的生成器扫描是延迟执行的不会一次性把所有条目塞到内存里。第二convert(RGB)这一行是很多新手会踩的坑。如果数据集里有灰度图Image.open得到的模式是L直接丢给一个训练好的RGB网络输入维度就对不上往往会报Expected 3D or 4D input这类错误。统一转成RGB后这些问题就消失了。第三我在__init__里打印了样本统计信息。这在调试初期很有用能够第一时间发现某个类别是不是空目录、样本分布是不是严重失衡。2.3 transform到底该放哪Dataset里还是外面这是一个经常被问到的问题。transform可以放在Dataset内部在__getitem__里调用也可以由外部在使用时再加一层包装。两种做法都能跑但我建议把它作为Dataset的一个参数传进来搞定。原因有两个一是灵活性同一个Dataset实例训练阶段可以传入一组包含增强的transform验证阶段传入另一组只有基础处理的transform如果transform写死在Dataset内部你要同时启用增强和验证就得复制出两个Dataset类代码很冗余。二是职责分离Dataset只负责取数据和标签transform则是数据怎么处理的策略两者分开改动互不影响。实操中你还会遇到一个容易混淆的问题to_tensor和normalize的顺序不能乱。正确顺序是先做几何变换比如缩放、裁剪、翻转因为这时候数据还是PIL图像或numpy数组处理效率高然后ToTensor()把图像从HWC格式变成CHW格式、像素值从0-255缩到0-1最后才做归一化Normalize。如果把顺序颠倒比如先ToTensor再Resize就会发现Pytorch的Resize操作只接受PIL或tensor类型、且对tensor进行缩放大小时要额外注意插值方式很容易出问题。3. DataLoader参数调教稳步提升训练效率3.1 DataLoader核心参数使用对照Dataset负责取单个样本DataLoader负责造批次。两者配合起来才是完整的数据流。DataLoader里几个参数的取舍直接关系训练速度、稳定性、显存占用我整理了一张对照表方便你按需选配参数作用推荐配置说明batch_size每次迭代的样本数根据显存调节常见8/16/32/64显存不够就降shuffle每个epoch开始前打乱样本顺序训练True、验证False防止模型学到样本顺序的模式num_workers并行的数据加载进程数Linux下4~8Windows下先设0太大反而拖慢速度容易报错pin_memory锁定内存页框GPU训练推荐True加速CPU到GPU的数据拷贝drop_last末尾不足一个batch时是否丢弃按需求选择训练时建议False避免丢失样本shuffleTrue看似简单但它对模型收敛很重要。如果不打乱顺序连续几个batch都是同一类别的图片模型会在这些batch间来回震荡收敛慢且最终精度也受影响。验证集不需要shuffle因为验证集只关心整体指标顺序无关紧要。drop_last这个参数很多人忽略。如果一个epoch末尾只剩下20张图而你设置的batch_size是64那么这一批会只有20张。对BatchNorm层来说小batch很容易导致统计量抖动尤其是batch里样本数太少时。我一般训练阶段设置drop_lastFalse让末批继续跑但如果你用的是大batch且模型对batch size敏感那也可以选True牺牲掉末尾那点数据换取稳定。3.2 数据流如何无缝接入训练循环Dataset和DataLoader写完之后训练循环就有了干净的数据入口from torch.utils.data import DataLoader from torchvision import transforms # 训练/验证两套transform train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset ImageFolderDataset(data_root/train, transformtrain_transform) val_dataset ImageFolderDataset(data_root/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) for epoch in range(num_epochs): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() model.eval() # 验证循环略这段代码里有一个容易被忽略的机制for images, labels in train_loader这一步DataLoader会自动做一系列事情先用sampler拿到一批索引依次调用__getitem__取出样本再把每个样本图片通过default_collate拼接成一个batch张量维度变成[B, C, H, W]标签也会拼成[B]的Tensor。所以你拿到的images已经是torch.Tensor大小是(batch_size, 3, 224, 224)labels是(batch_size,)的整数索引Tensor。这也是为什么很多新手在__getitem__里返回的图片PIL尺寸大小不一、到DataLoader组装batch时会遇到RuntimeError: stack expects each tensor to be equal size。因为同一批次的图片必须是同一个尺寸才能拼成张量。解决方法就是在transform里统一Resize或者Crop保证batch内尺寸完全一致。3.3 多进程加载的常见坑与性能权衡num_workers是非常值得花时间调的一个参数。它的原理是让多个子进程并行调用__getitem__加载数据主进程只负责把结果收拢。在数据量小、transform轻量的场景下num_workers0反而更稳因为多进程开销大于收益。但图像数据集通常transform比较大尤其是Resize、RandomCrop这些操作这时候多进程收益就出来了。不过有两个高频坑我几乎在每次用Windows的朋友电脑上都能看到。一是BrokenPipeError或程序卡死Windows下num_workers不宜设置太高而且代码入口一定要放在if __name__ __main__:保护里否则多进程递归创建会让Windows系统直接炸掉。二是在Jupyter Notebook里使用多进程同样容易卡死这是因为Notebook的进程环境和多进程启动方式有兼容性问题。我的建议是Notebook里先设num_workers0验证代码没问题再切到本地脚本里用多进程跑。pin_memoryTrue这个参数Pytorch官方文档的解释是把数据放到锁页内存中以便加速与GPU之间的拷贝。实际训练中这个加速确实可见我自己观察大概能省掉5%~15%的单batch数据搬运时间。代价是这些内存不能被操作系统换出到虚拟内存所以内存紧张时要权衡。但在训练服务器上内存通常够用无脑开True基本没错。4. 数据预处理与增强让数据成为模型上限的一部分4.1 归一化到底做了什么参数如何来说到Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])这组数值是ImageNet数据集的统计均值与标准差。归一化把图像的每个通道从0-1范围转换到以0为中心、幅度约为-2到2的分布。这很有必要因为模型的输入如果数值分布相差太大会影响权重更新速度与稳定性。这就好比我们把不同单位的考试成绩都换算成百分制再去比较量纲对齐了才有可比性。问题来了自建数据集是否必须用ImageNet的均值标准差如果数据集内容与ImageNet差得很远比如全是工业零件的灰度图、或是医学影像那从训练集本身统计均值和标准差会更合适。实践中我通常的做法是如果数据量小且迁移学习预训练权重直接沿用ImageNet参数如果数据量充裕写一个小脚本统计训练集的每通道均值方差用这三行代码import torch from torchvision import transforms stat_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor() ]) dataset ImageFolderDataset(data_root/train, transformstat_transform) loader DataLoader(dataset, batch_size128, num_workers4) mean torch.zeros(3) std torch.zeros(3) for images, _ in loader: batch_size images.size(0) images images.view(batch_size, images.size(1), -1) mean images.mean(dim[0, 2]) * batch_size std images.std(dim[0, 2]) * batch_size mean / len(dataset) std / len(dataset)但这个统计脚本会额外花一遍全量计算时间如果数据集几万张图跑分布统计会多一些开销。所以数据量小时我真接用ImageNet统计值数据量大时再自己统计省一段预处理工时。4.2 常用增强策略的取舍与参数建议数据增强是自建数据集里性价比最高的一环。小数据集靠增强能凭空造出很多变体降低过拟合风险。我用得最多的几个增强操作及参数建议如下RandomHorizontalFlip(p0.5)适合绝大多数物体文字、方向敏感的除外推理时不做。RandomRotation(degrees10)小角度旋转适合很多自然图像但人脸方向、文本方向等场景慎用。ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.05)模拟光照变化适合自然图像但对颜色敏感的检测任务不要调太狠。RandomResizedCrop(size224, scale(0.08, 1.0))等价于让网络不断看到目标的不同尺度与裁剪位置是常用的强增强手段。另外要记住一个原则增强只作用于训练集验证集和测试集绝不做随机增强只做固定的Resize、CenterCrop这样才能保证评估指标稳定。一张验证图片今天这结果、明天那结果那是没法用的。还要注意transform的执行顺序。先几何变换、再颜色变换、再ToTensor、最后Normalize这个顺序一旦错了就会出现色调怪异的图片。我有一次把ColorJitter放在ToTensor之后结果颜色变换操作混在归一化向量里batch输出的图片颜色完全偏掉排查了半天才发现是顺序问题。4.3 类别不平衡随机采样之外的更多选择自建数据集十有八九是不均衡的有的类别数据多有的类别只有零头。直接随机采样训练模型会偏向样本多的类别验证集上看似精度不低但少数类的召回率惨不忍睹。这时候可以用WeightedRandomSampler做加权采样少数类样本被采到的概率提高。逻辑其实很简单每个样本的权重 1 / 该类别样本数这样类别权重越大、样本越少的类每次epoch被抽到的次数就越多。from torch.utils.data import WeightedRandomSampler class_counts [] for cls in train_dataset.classes: class_counts.append(sum(1 for _, label in train_dataset.samples if label train_dataset.class_to_idx[cls])) weights 1.0 / torch.tensor(class_counts, dtypetorch.float) sample_weights [] for _, label in train_dataset.samples: sample_weights.append(weights[label]) sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler, num_workers4)注意两个容易忽视的点。第一当你给DataLoader传了sampler之后shuffle参数就不要再设True了两者互斥Pytorch会直接报错sampler本身就决定了采样顺序你不希望它再被打乱。第二加权采样会让每个epoch里少数类被重复采样模型看过这些样本的次数变多但要注意配合早停或者验证指标监控防止一味拟合少数类导致多数类精度掉得厉害。5. 高频报错与自查清单逃过这些坑再开跑5.1 运行期高频报错与解决方案实际运行中报错种类多但高频的就那几类我整理了一张速查表按出现频率从高到低排报错信息常见原因解决思路BrokenPipeError多进程加载数据时Windows或Notebook环境问题num_workers0先跑通再在脚本里调RuntimeError: stack expects each tensor to be equal sizebatch内图片尺寸不一致transform里统一Resize/裁剪OSError: image file is truncated图片损坏或格式不完整PIL打开后忽略截断错误或过滤坏图IndexError: too many indices for tensor标签返回了多余维度或错误索引检查__getitem__返回的label形状CUDA out of memory显存不足降低batch_size、降低输入分辨率Expected 3D or 4D input输入不是标准BCHW张量检查convert(RGB)和transform顺序AssertionError: batch_size 0数据量为0或采样器取不到样本检查目录结构、扩展名过滤逻辑这些报错我全部实际遇到过。以截断图片为例我处理过一个几万张图的爬虫数据集里面不少图片是半截状态PIL打开文件时会抛OSError直接导致DataLoader进程崩溃。后来在__getitem__里加了容错处理from PIL import ImageFile ImageFile.LOAD_TRUNCATED_IMAGES True这一行能让PIL在遇到截断图片时尽量读取已有部分不轻易抛异常。但要注意这只是兜底如果图片损坏到完全无法解码还是要从数据源头处理。5.2 数据集构建后的五项自查构建完数据集别急着开训练。我每次都会按下面五步做自查十分钟不到但能省下后面几天排查训练的力气。一查维度。随便取一个batch打印images.shape和labels.shape。分类任务里images应该是[B, 3, H, W]labels应该是[B]。如果不是立刻回头找__getitem__或collate的问题。二查数值范围。打印图像张量的min/max。如果像素值跑到负数或者超过1很多说明Normalize用错了或者transform顺序有问题。三查标签映射。随机挑几个样本打印图片路径和对应的索引再去文件夹确认是否一致。这一步能发现目录排序、字典映射不一致的问题。四查可视化。用torchvision.utils.make_grid把一批增强后的图片拼成网格存成图看一眼。增强不是越狠越好如果增强出来的图片人眼都认不出来正常训练也很难收敛。from torchvision.utils import make_grid import matplotlib.pyplot as plt images, labels next(iter(train_loader)) grid make_grid(images[:16], nrow4, normalizeTrue) plt.imshow(grid.permute(1, 2, 0)) plt.savefig(batch_preview.png)五查统计分布。打印每个类别的样本数看是否存在空目录或者占比小于5%的类别。发现问题早处理比如补充数据、删掉异常类或者后续用采样策略。5.3 复现性设置随机种子一劳永逸关于结果可复现这件事很多时候不是数据集本身的问题而是随机性没控制住。数据加载环节的随机性主要来自shuffle打乱顺序和增强的随机操作。建议在训练脚本开头设置统一的随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)cudnn.deterministic True会让卷积运算使用确定性的算法benchmarkFalse则避免不同尺寸卷积时自动择优选算法带来的不确定性。代价是训练速度会有轻微下降。在需要精确复现实验结果时这个代价值得日常探索性实验关掉也没问题。另外DataLoader里shuffle的随机种子受Pytorch全局种子影响你在每个epoch开始时如果想严格控制可以手动调用train_loader.sampler.set_epoch(epoch)。多数任务不需要这么严谨但如果你跑对比实验、要严格同数据顺序下对比两个模型这招很实用。数据集的构建永远不是一次性完成的。训练到一半发现某个类别的图片风格不一致、再回头补数据是非常正常的事。我自己在模拟项目X的第一版数据上就吃过亏当时连验证集都没认真分训练曲线看着漂亮一换到新场景就原形毕露。之后我养成了一个习惯每次整理完数据集先把训练集和验证集的类别分布打印出来再做一次单batch可视化确认增强和归一化没有把图片弄坏最后才跑正式训练。这套流程看着多花了十分钟实际上帮我省下了后面几天的排查时间。希望这篇从数据目录设计到报错排查的完整经验能让你少走点弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →