ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习训练数据集加载避坑指南:从解压到DataLoader的完整流程

深度学习训练数据集加载避坑指南:从解压到DataLoader的完整流程

简介:这份深度学习训练数据集面向图像分类与目标检测方向的开发者、学生及研究者,提供可直接用于模型训练与验证的标注图像资源,帮助解决入门与实验阶段缺少真实样本的问题。压缩包共305个文件,以300张jpg图像为主体,另含2个mat数据文件、2个m脚本和1个txt说明文件,整体约22.97MB,体积轻便,便于快速下载与本地加载。图像内容围绕水质轨迹展开,涵盖清澈、浑浊、含藻类等不同状态,可用于卷积神经网络的特征学习,也可支撑Fast R-CNN、YOLO等目标检测算法的边界框训练。目前已有693人学习下载,适合作为课程实验、毕业设计或算法对比的练手数据。使用者可据此完成训练集、验证集与测试集划分,并结合翻转、旋转、裁剪等数据增强手段扩充样本,进而搭建从图像分类到目标定位的完整流程,在环境保护与水质监测等场景中验证模型效果。

1. 拿到「深度学习训练数据集.zip」先别急着解压:三类数据集的血泪分诊法

你从群里、网盘或者某个竞赛页面拖下来一个深度学习训练数据集.zip,双击解压,看到一堆文件夹和图片,心里一阵激动,然后torchvision.datasets.ImageFolder一挂,训练 loss 不降反升,或者干脆报一堆找不到文件的错。这个场景我经历过太多次。问题几乎从来不在模型,而在你根本没搞清楚这个压缩包里装的到底是哪一类数据集。深度学习训练数据集大致分三种:分类数据集(按类别分文件夹)、检测/分割数据集(图片和标注文件分离,靠 json/xml/txt 索引)、自定义格式数据集(csv 索引、npy 打包、tfrecord 等)。三类数据的加载方式、目录结构、校验手段完全不同。这篇笔记就按「解压后先分诊 → 按类型加载 → 避坑 → 进阶验证」的顺序,把深度学习训练数据集.zip从一堆文件变成能喂进模型的 DataLoader,适合刚拿到数据集准备跑 baseline 的新手,也适合被脏数据坑过的熟手。

2. 解压后第一件事:用三条命令给数据集做「体检」

2.1 先看目录树,判断属于哪一类

不要一上来就写 Dataset 类。先花两分钟把结构看清楚,这一步能省掉后面几小时的 debug。

# 只看两层目录,避免几万张图刷屏 find ./dataset -maxdepth 2 -type d | head -50 # 统计各目录下的文件数量,快速判断是不是按类别分文件夹 for d in ./dataset/*/; do echo -n "$d: "; find "$d" -type f | wc -l; done # 看文件扩展名分布,判断是纯图片还是混了标注文件 find ./dataset -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn

第一条命令输出的是目录骨架。如果看到train/cat/、train/dog/这种「父目录是划分、子目录是类别」的结构,基本就是 ImageFolder 能直接吃的分类数据集。第二条命令统计每个子目录的文件数,如果各类别数量差异巨大(比如一类 5000 张、一类 200 张),先别训练,这是典型的类别不平衡,后面要处理。第三条命令看扩展名分布,如果出现大量.xml、.json、.txt,说明这是检测或分割数据集,图片和标注是分开的,不能直接 ImageFolder。

提示:解压前先看压缩包大小和文件数。unzip -l xxx.zip | tail -5能在解压前看到总文件数,避免解压出几十万个碎文件把 inode 占满。

2.2 用 Python 做一次完整性校验

目录结构对了不代表文件没问题。压缩包传输过程中损坏、图片截断、标注和图片对不上,都是常见情况。下面这段脚本做三件事:验证图片能否被正常解码、检查图片尺寸分布、统计损坏文件。

import os from PIL import Image from collections import Counter root = "./dataset" bad_files = [] sizes = Counter() exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} for dirpath, _, filenames in os.walk(root): for fn in filenames: if os.path.splitext(fn)[1].lower() not in exts: continue fp = os.path.join(dirpath, fn) try: # verify() 只读文件头,比 load() 快很多,适合大批量筛查 img = Image.open(fp) img.verify() # verify 后对象不可再用,重新打开拿尺寸 img = Image.open(fp) sizes[img.size] += 1 except Exception as e: bad_files.append((fp, str(e))) print(f"损坏文件数: {len(bad_files)}") for fp, err in bad_files[:10]: print(f" {fp} -> {err}") print("尺寸分布 Top10:") for size, cnt in sizes.most_common(10): print(f" {size}: {cnt}")

verify()是关键,它只解析文件头,不做完整解码,几万张图几十秒就能跑完。如果你直接用img.load(),遇到损坏文件会抛异常,而且速度慢一个数量级。尺寸分布这一栏很重要:如果出现大量(1,1)或者极端长宽比,说明有占位图或爬虫抓下来的垃圾图,训练前必须清掉。损坏文件不要直接删,先移到./quarantine/目录留档,万一后面发现是某个类别整体损坏,你还能回溯。

2.3 判断是否需要重新划分 train/val

很多网上下载的数据集只给了一个大文件夹,没有 train/val 划分。这时候不要用random_split随便切,因为如果数据里有同一视频的连续帧、同一用户的多次拍摄,随机切会导致训练集和验证集泄漏,验证指标虚高。正确做法是按「实体」划分:同一来源的样本必须整体进 train 或整体进 val。

import os, random, shutil from collections import defaultdict src = "./dataset/all" dst = "./dataset/split" random.seed(42) # 假设文件名前缀是实体 ID,如 user001_0001.jpg groups = defaultdict(list) for fn in os.listdir(src): entity = fn.split("_")[0] groups[entity].append(fn) entities = list(groups.keys()) random.shuffle(entities) n_val = max(1, int(len(entities) * 0.2)) val_entities = set(entities[:n_val]) for split in ["train", "val"]: os.makedirs(os.path.join(dst, split), exist_ok=True) for entity, files in groups.items(): split = "val" if entity in val_entities else "train" for fn in files: shutil.copy(os.path.join(src, fn), os.path.join(dst, split, fn))

这里random.seed(42)是为了可复现,团队协作时大家切出来的划分必须一致,否则对比实验没有意义。按实体划分而不是按文件划分,是避免数据泄漏的核心。如果你的文件名没有实体前缀,那就退而求其次按时间戳或来源目录划分,总之不要让同一来源的样本跨集合。

3. 分类数据集:ImageFolder 能直接用,但这三个参数必须改

3.1 目录结构不满足要求时怎么补救

ImageFolder 要求的标准结构是root/class_name/xxx.jpg。但实际拿到的数据集经常是root/train/class_name/或者root/class_name/subclass/。前者多了一层划分目录,后者多了一层子类。补救方式有两种:写一个轻量 Dataset 包装,或者用软链接重组目录。我一般优先用软链接,因为不复制文件、不占额外磁盘。

mkdir -p ./reorg/train ./reorg/val # 把 train 下的类别目录软链过去 for cls in ./dataset/train/*/; do name=$(basename "$cls") ln -s "$(realpath "$cls")" "./reorg/train/$name" done

软链接的好处是原始数据不动,重组结构随时可改。注意realpath要写绝对路径,否则软链接会指向错误位置。Windows 下软链接需要管理员权限,这种情况就老老实实写 Dataset 类,在__getitem__里手动拼路径。

3.2 transform 里最容易翻车的三个参数

分类任务的 transform 看起来简单,但下面三个参数设错,模型精度能差好几个点。

from torchvision import transforms train_tf = transforms.Compose([ transforms.Resize(256), # 短边缩到 256,保持长宽比 transforms.RandomCrop(224), # 随机裁 224,做数据增强 transforms.RandomHorizontalFlip(0.5), transforms.ColorJitter(0.2, 0.2, 0.2), # 亮度/对比度/饱和度抖动 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), # 验证集用中心裁剪,不用随机 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

第一个坑:Resize直接写(224, 224)会强行拉伸,改变长宽比,对细长物体识别影响很大。正确做法是先缩短边再裁剪。第二个坑:验证集用了RandomCrop或RandomHorizontalFlip,导致每次验证结果都在抖,你根本不知道模型是真的提升了还是随机性。验证集 transform 必须确定性。第三个坑:Normalize的 mean/std 用了 ImageNet 的默认值,但你的数据集如果是医学影像、卫星图、工业质检图,像素分布和自然图像差很远,应该自己统计。

# 统计自己数据集的 mean/std import torch from torch.utils.data import DataLoader loader = DataLoader(train_dataset, batch_size=64, shuffle=False, num_workers=4) mean = torch.zeros(3) std = torch.zeros(3) n = 0 for imgs, _ in loader: b = imgs.size(0) imgs = imgs.view(b, 3, -1) mean += imgs.mean(2).sum(0) std += imgs.std(2).sum(0) n += b print("mean:", mean / n) print("std:", std / n)

这段统计脚本跑一遍也就几分钟,但能让你的归一化真正匹配数据分布。工业质检和医学影像上,用错 mean/std 导致收敛慢甚至不收敛的情况我见过不止一次。

3.3 类别不平衡时 sampler 怎么选

前面体检发现类别数量差异大,直接训练会让模型偏向多数类。常见做法有三种:重采样、加权损失、focal loss。我一般先用WeightedRandomSampler,改动最小。

import numpy as np from torch.utils.data import WeightedRandomSampler targets = [s[1] for s in train_dataset.samples] class_count = np.bincount(targets) class_weight = 1.0 / class_count sample_weight = class_weight[targets] sampler = WeightedRandomSampler( weights=sample_weight, num_samples=len(sample_weight), replacement=True ) loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=4)

replacement=True表示有放回采样,少数类会被反复抽到。num_samples设成和数据集一样大,保证每个 epoch 的步数和原来一致。注意用了 sampler 就不能再设shuffle=True,两者冲突会报错。如果少数类样本实在太少(比如只有几十张),重采样会导致严重过拟合,这时候应该上数据增强或者直接换 focal loss。

4. 检测与分割数据集:标注文件和图片对不上是头号杀手

4.1 COCO 格式的解析与校验

检测数据集最常见的是 COCO 格式:一个annotations.json加一个images/目录。加载前必须校验三件事:json 里引用的图片是否都存在、每张图的标注框是否越界、类别 id 是否连续。

import json, os from PIL import Image with open("./dataset/annotations.json") as f: coco = json.load(f) img_dir = "./dataset/images" id2file = {im["id"]: im["file_name"] for im in coco["images"]} missing = [] for im in coco["images"]: if not os.path.exists(os.path.join(img_dir, im["file_name"])): missing.append(im["file_name"]) print(f"缺失图片: {len(missing)}") # 校验标注框 bad_boxes = 0 for ann in coco["annotations"]: x, y, w, h = ann["bbox"] im_info = next(i for i in coco["images"] if i["id"] == ann["image_id"]) if x < 0 or y < 0 or x + w > im_info["width"] or y + h > im_info["height"]: bad_boxes += 1 print(f"越界标注框: {bad_boxes}") # 类别 id 是否连续 cat_ids = sorted(c["id"] for c in coco["categories"]) print("类别 id:", cat_ids)

越界框在训练时会导致 loss 计算异常或者 ROI Align 报错。处理方式是把框裁剪到图像边界内,如果裁剪后面积小于阈值(比如 4 像素)就直接丢弃这条标注。类别 id 不连续是个隐蔽的坑:COCO 的 id 可能从 1 开始也可能从 0 开始,还可能跳号,如果你直接拿 id 当类别索引,会出现索引越界或者类别错位。正确做法是建一个id -> 连续索引的映射表。

4.2 从 COCO 转 YOLO 格式的脚本与边界坑

YOLO 格式要求每张图对应一个同名.txt,每行是class_id cx cy w h,全部归一化到 0-1。转换脚本本身不难,坑在边界处理。

import json, os with open("./dataset/annotations.json") as f: coco = json.load(f) cat_map = {c["id"]: i for i, c in enumerate(sorted(coco["categories"], key=lambda x: x["id"]))} img_info = {im["id"]: im for im in coco["images"]} out_dir = "./dataset/labels" os.makedirs(out_dir, exist_ok=True) for ann in coco["annotations"]: im = img_info[ann["image_id"]] W, H = im["width"], im["height"] x, y, w, h = ann["bbox"] # 裁剪到边界内 x1, y1 = max(0, x), max(0, y) x2, y2 = min(W, x + w), min(H, y + h) if x2 - x1 < 2 or y2 - y1 < 2: continue # 太小丢弃 cx = (x1 + x2) / 2 / W cy = (y1 + y2) / 2 / H nw = (x2 - x1) / W nh = (y2 - y1) / H # 归一化后必须再 clamp 一次,浮点误差可能让值略超 1 cx, cy = min(max(cx, 0), 1), min(max(cy, 0), 1) nw, nh = min(max(nw, 0), 1), min(max(nh, 0), 1) name = os.path.splitext(im["file_name"])[0] + ".txt" with open(os.path.join(out_dir, name), "a") as f: f.write(f"{cat_map[ann['category_id']]} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n")

三个边界坑:第一,COCO 的 bbox 是[x, y, w, h],YOLO 要的是中心点加宽高,转换公式别写反。第二,裁剪后要再 clamp 一次,因为浮点运算可能让归一化值变成 1.0000001,某些框架会直接报错。第三,用append模式写文件,因为一张图可能有多个目标,每次覆盖会丢标注。转换完记得抽查几张图,用可视化脚本把框画出来看一眼,比任何单元测试都直观。

4.3 分割掩码的类别对齐问题

分割数据集常见的是 PNG 掩码,每个像素值代表类别。坑在于:掩码里的像素值可能是 0/1/2,也可能是 0/255,还可能是调色板索引。训练前必须确认像素值和类别 id 的对应关系。

import numpy as np from PIL import Image mask = np.array(Image.open("./dataset/masks/001.png")) print("唯一值:", np.unique(mask)) print("dtype:", mask.dtype)

如果唯一值是[0, 255],说明是二值掩码,训练时除以 255 即可。如果是[0, 1, 2, 3],直接当类别索引。如果出现[0, 128, 255]这种,多半是调色板图,需要用Image.open(...).convert("P")后读 palette,或者直接找数据集说明文档确认映射关系。掩码和原图尺寸必须严格一致,不一致的话用最近邻插值缩放,千万别用双线性,否则会插出中间类别值,把类别搞乱。

5. 避坑与排查:数据集加载阶段最常见的五个翻车现场

5.1 现象:训练第一个 epoch 就 OOM,但显存明明够

原因通常不是模型太大,而是 DataLoader 的num_workers设太高,每个 worker 都复制了一份数据集元数据,加上pin_memory=True和过大的batch_size,显存和内存一起爆。解决:先把num_workers降到 2,batch_size减半,确认能跑通后再逐步往上加。另外检查 Dataset 的__init__里是不是把整个数据集读进了内存,比如self.images = [Image.open(p) for p in paths],这种写法在几万张图时必炸,应该只存路径,在__getitem__里按需读取。

5.2 现象:loss 一直是 nan,换小学习率也没用

先别怀疑模型,去检查数据里有没有 NaN 或者全黑图。全黑图经过 Normalize 后可能产生极端值,全白图同理。用前面的体检脚本筛一遍,把像素均值接近 0 或 255 的图找出来。另一个常见原因是标注里有面积为 0 的框,导致除零。解决:在 Dataset 的__getitem__里加断言,发现异常样本直接跳过并记录,不要让脏数据进网络。

5.3 现象:验证集准确率比训练集还高

这几乎可以断定是数据泄漏。检查 train 和 val 里有没有重复图片,尤其是同一张图的不同增强版本被分到了两个集合。用图片的 md5 去重是最快的手段。

find ./dataset -type f -name "*.jpg" -exec md5sum {} \; | sort | awk '{print $1}' | uniq -d

如果有重复 md5,说明同一张图出现了多次。按实体划分那一步如果没做好,就会出这个问题。另外,如果验证集太小(比如只有几十张),指标波动大也会造成这种假象,建议验证集至少占总量的 10% 且每类不少于 30 张。

5.4 现象:Windows 上 num_workers 大于 0 就报错

这是 Windows 多进程的经典问题,DataLoader 的 worker 在 Windows 下需要if __name__ == "__main__":保护,否则会无限递归创建进程。解决:把训练入口包在 main 保护里,或者干脆在 Windows 上设num_workers=0调试,最终训练放到 Linux 上跑。这不是代码 bug,是平台差异,别在这上面浪费时间。

5.5 现象:换了台机器,同样的代码加载数据集报路径错误

绝对路径写死在代码里是万恶之源。所有路径都应该基于一个DATA_ROOT环境变量或者配置文件,用os.path.join拼接。另外注意 Linux 和 Windows 的路径分隔符差异,以及大小写敏感问题:Linux 下Cat和cat是两个目录,Windows 下是一个。跨平台协作时,目录名统一用小写加下划线,能省掉很多玄学问题。

6. 让数据集可复现:写一个 dataset_card 和固定随机种子

数据集处理完,别急着开训。我现在的习惯是给每个数据集写一份dataset_card.md,记录来源、样本总数、类别分布、划分方式、已知问题。这份卡片在团队协作和三个月后自己回头看时,价值极高。配合固定随机种子,才能保证实验可复现。

import random, numpy as np, torch, os def set_seed(seed=42): 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 os.environ["PYTHONHASHSEED"] = str(seed) set_seed(42)

cudnn.deterministic=True会让卷积算法选择确定性版本,速度可能慢 10%-20%,但同样的种子能跑出同样的结果。如果追求速度且能接受微小随机性,把这两行注释掉即可。DataLoader 的worker_init_fn也要设种子,否则每个 worker 的增强随机性不受控。

def worker_init_fn(worker_id): np.random.seed(42 + worker_id) random.seed(42 + worker_id) loader = DataLoader(dataset, batch_size=32, num_workers=4, worker_init_fn=worker_init_fn)

最后分享一个我踩过的坑:有次处理一个医学影像数据集,体检时图片全部正常,训练却一直不收敛。查了两天才发现,标注文件里的类别名和文件夹名差了一个下划线,导致标签全部错位。从那以后,我拿到任何深度学习训练数据集.zip,第一件事永远是打印前 20 个样本的「路径 + 标签 + 图片尺寸」三元组,肉眼扫一遍。这个习惯帮我拦下了至少五次类似事故。数据集这层地基没打好,上面模型再花哨都是空中楼阁。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进