ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

告别小文件IO:ImageNet100图像数据集转Parquet实战指南

告别小文件IO:ImageNet100图像数据集转Parquet实战指南 做深度学习实验最怕的不是模型不收敛而是数据准备阶段就把耐心磨没了。我上个月在ImageNet100上跑蒸馏实验先被那几万张散落在子目录里的小图搞崩溃每次DataLoader随机读取都要频繁打开文件训练还没跑起来IO等待就先吃掉了大半时间。后来我花了一个下午把ImageNet100整个训练集转成了单个Parquet文件从那以后训练脚本再也不用跟文件系统较劲了。这篇内容就围绕“ImageNet100 Parquet数据集转换”这条主线展开为什么值得转换、转换前需要准备什么、具体怎么写转换脚本、转换后如何高效加载以及我实际踩过的那些坑。如果你也在用ImageNet100或者类似的中小规模图像数据集做实验正被小文件IO、加载速度、多机拷贝这些问题折磨这篇文章可以直接当一份操作手册来用。1. 为什么要把ImageNet100转成Parquet先说结论Parquet不是深度学习生态里的原生产物它来自大数据分析领域但恰恰是这种“跨界”给它带来了独特优势。ImageNet100是ImageNet-1K的100类子集单类大约1300张图整个训练集约13万张图。13万张图片如果平铺在目录里就是13万个JPEG文件每个文件平均几十KB到一百多KB不等。这个量级在单机上勉强能忍但一旦涉及多机训练、数据清洗、子集采样整个人都会变得很不舒服。1.1 小文件IO到底有多痛硬件都有一个特点顺序读大块的效率远高于随机读小块。一个JPEG文件可能只有60KB但打开这个文件需要目录项解析、inode查找、页缓存读写文件系统层付出的固定开销远大于数据本身的传输量。在机械硬盘上尤其明显随机读13万个小文件几百次的寻道时间就够你喝一壶。即便在SSD上频繁的小文件打开也会推高CPU开销尤其是inode和dentry缓存频繁失效的场景。Parquet通过把数据打包成连续的文件块解决了这个问题。所有图片的字节流被塞进一列binary字段读取时只要顺序加载一个或者几个文件块就能拿到成百上千张图片的数据。这等于把随机IO变成了顺序IO吞吐量至少能提升一个量级。我在实际对比中感受非常明显同样一个epoch目录读取光IO就要快一倍多如果算上解码前的页缓存命中整体更稳。1.2 Parquet相比其他格式的优势有人可能会说想解决小文件问题为什么不用TFRecord或者HDF5这几个方案各有拥趸但Parquet有几个特性确实是实际工程里很讨喜的存储方式随机读取小文件IO可直接浏览生态适配元数据管理原图目录好差好一般弱TFRecord略差好差TensorFlow为主弱HDF5中等好中等科学计算为主中等Parquet中等偏好好好Python/大数据全生态强TFRecord最大的问题在于它是一串不可读的字节流想看看里面有没有混进脏数据或者想用SQL查一下类别分布几乎没有可行路径。HDF5的单文件写入和并发读在分布式场景下也容易成为瓶颈。Parquet底层是列式存储自描述schema非常清晰用pandas、pyarrow、DuckDB、Spark都能直接读取配合Hugging Face datasets库还能无缝转成Arrow格式继续用。1.3 什么时候才需要转换也不能无脑转。如果你的数据集只有几千张图单机显存训练多开几个DataLoader worker就能把IO压在可接受范围内那没必要折腾。但出现下面几种情况建议认真考虑转换训练集超过5万张图片且每次epoch都要全量遍历需要频繁切分训练子集比如按类别采样、按路径过滤需要在多台机器之间拷贝数据集小文件拷贝速度惨不忍睹需要把数据集上传到对象存储做数据版本管理想在训练之外用SQL式的分析手段快速看数据分布ImageNet100恰好落在“已经有点规模但还没大到不可控”的区间是非常适合练习这套流程的场景。2. 转换前的准备数据源与工具选型动手之前先把工具链列清楚。我用的环境是Python 3.10 PyTorch 2.x核心依赖其实只有几个pillow用于图像读写pyarrow负责Parquet文件的组装和写入tqdm用来显示进度。如果希望转换后直接用Hugging Face datasets库加载可以再装一个datasets库。2.1 确认你的ImageNet100目录结构ImageNet100没有唯一的官方下载地址不同渠道分发下来的目录结构可能有差异但最常见的结构是这样imagenet100/ ├── train/ │ ├── n01440764/ │ │ ├── n01440764_10026.JPEG │ │ ├── n01440764_10027.JPEG │ │ └── ... │ ├── n01443537/ │ │ └── ... │ └── ... └── val/ ├── n01440764/ │ ├── ILSVRC2012_val_00000293.JPEG │ └── ... └── ...每个子目录名是WordNet的synset ID比如n01440764代表tench。转换前建议先用一条命令确认根目录下有多少类别、每个类别大概多少张图避免最后写入时发现类别缺失或者数量对不上。find train -mindepth 1 -maxdepth 1 -type d | wc -l find train -name *.JPEG | wc -l2.2 为什么选pyarrow而不是直接硬写Parquet格式本身很复杂包含列块、页、压缩编码、统计信息等如果你准备全部手写字节那基本是在造轮子。pyarrow是Arrow生态的Python接口arrow格式在内存里是列式布局pyarrow可以高效地把多个数组组装成一张表然后一次性落盘成Parquet文件。它还内置了snappy、zstd、gzip等多种压缩算法用起来非常省心。也有人走Hugging Face datasets的ImageFolder然后导出Parquet这条路也能通但中间会多出cache构建和格式转换的开销。我自己更喜欢直接扫目录、自己组装Record全流程可控出问题也好排查。如果你想追求更少代码datasets也是不错的备选方案。2.3 压缩算法别小看这一步Parquet允许为每一列配置压缩算法。图片本身已经是JPEG压缩过的数据再度压缩的空间不大但也不是完全没有收益。snappy压缩速度快压缩率一般zstd在压缩率和速度之间平衡很好gzip压缩率最高但慢。我的选择是zstdlevel用的默认值实测在ImageNet100上文件体积只比原图目录总和略大一点点因为row group内部还有重复检测等额外开销读取速度完全在可接受范围内。如果你是追求极致读取速度的实验场景可以直接用snappy解码更快文件稍微大点没关系。压缩这个环节不用过度纠结两个都跑一次看体积和加载时间选自己舒服的就行。3. 核心转换实现从目录到Parquet的完整流程这一节是全文的重头戏。我先把完整的转换脚本结构拆开讲再给出一份可以直接跑的示例代码。整个过程分成四步扫描目录、读取并编码图像、构造Arrow Table、写入Parquet文件。3.1 第一步安全地扫描目录并构建类别映射扫描目录时需要特别注意一点类别的排序必须固定。如果直接用os.scandir或者Path.iterdir返回顺序可能依赖文件系统这会导致同一个数据在不同时间转换出的label编号不一致。我这个脚本里先把所有子目录名排序再建立synset_id - 整数label的映射并把映射关系单独缓存成JSON文件方便训练时对照。import json from pathlib import Path def build_label_map(root_dir): class_dirs sorted([p for p in Path(root_dir).iterdir() if p.is_dir()]) label_to_name {i: d.name for i, d in enumerate(class_dirs)} name_to_label {d.name: i for i, d in enumerate(class_dirs)} return label_to_name, name_to_label这里有个细节有的ImageNet100版本里的JPEG后缀是.jpeg小写有的是.jpg有的是.JPEG大写。扫描时需要用.lower()统一后缀判断不要只匹配一种。3.2 第二步图像统一转成RGB并编码为字节流Parquet里存图片有几种方案存原始JPEG字节、存解码后的像素数组、存PNG重编码字节。存像素数组会让文件极其膨胀因为每个像素三个通道13万张224x224图就是约20亿个数值完全失去了压缩的意义。存原始JPEG字节最省事但有些图片可能是灰度图或者带Alpha通道的PNG直接存会让后面训练时出现通道数不统一。我的做法是在转换阶段统一处理读入图片后用Pillow转成RGB再重新编码成JPEG字节质量设为95。这样所有数据进Parquet时已经保证三通道、同编码格式训练时解出来直接用不必担心意外。from PIL import Image from io import BytesIO def image_path_to_bytes(path: str, quality: int 95) - bytes: with Image.open(path) as img: if img.mode ! RGB: img img.convert(RGB) buffer BytesIO() img.save(buffer, formatJPEG, qualityquality) return buffer.getvalue()3.3 第三步用PyArrow组装表并写入Parquet写入Parquet的关键在于“批量”而不是“逐行”。有些人习惯构建一个list然后每凑够一行就调用一次pq.write_table那样等于反复写文件尾和元数据性能很差。正确姿势是先把所有图片bytes、label、路径分别收集到三个列表里最后一次性构造pa.table再调用write_table。这样Arrow能自动优化列块划分压缩效率也更高。import pyarrow as pa import pyarrow.parquet as pq from tqdm import tqdm def build_parquet(root_dir, output_path, label_json_path): label_to_name, name_to_label build_label_map(root_dir) image_bytes_list [] label_list [] path_list [] for class_dir in sorted(Path(root_dir).iterdir()): if not class_dir.is_dir(): continue label name_to_label[class_dir.name] for img_path in tqdm(list(class_dir.rglob(*))): if img_path.suffix.lower() not in [.jpg, .jpeg, .png]: continue image_bytes_list.append(image_path_to_bytes(str(img_path))) label_list.append(label) path_list.append(str(img_path)) table pa.table({ image_bytes: pa.array(image_bytes_list, typepa.binary()), label: pa.array(label_list, typepa.int32()), label_name: pa.array([label_to_name[i] for i in label_list], typepa.string()), file_path: pa.array(path_list, typepa.string()), }) pq.write_table( table, output_path, compressionzstd, row_group_size1000, ) with open(label_json_path, w) as f: json.dump(label_to_name, f, indent2)这句话里有几个参数值得解释一下。row_group_size1000表示每1000行组成一个row group对应Parquet内部的列块组织单元。这个值直接关系到读取粒度太小会导致列块元数据膨胀太大则随机读取某一小段数据时会加载过多无关内容。1000对于图像bytes这种一列就很大数据量的场景比较折中。如果机器内存充足且追求更快的顺序读取调到2000也可以。3.4 转换脚本的完整执行示例把以上函数拼成一个脚本后实际操作只需要三行python build_parquet.py \ --root ./imagenet100/train \ --output ./imagenet100_train.parquet \ --label-json ./imagenet100_label.json转换速度取决于图片尺寸和CPU性能。我机器上是13万张图大约花了8分钟其中重编码JPEG占绝大部分时间。如果你不想改变原图的编码可以直接读取原文件的bytes写入Parquet速度会快很多但会牺牲通道统一性。从这个角度也可以看出imagebytes方案的实际经验追求省事原始字节直接写入追求后续训练稳定还是过一遍Pillow更稳妥。3.5 顺手把验证集一起转了训练集转换完验证集建议用同一套代码处理但要注意保存验证集自己的label映射。不要直接复用训练集的label_to_name因为验证集的类别顺序可能和训练集不同。稳妥做法是把验证集目录也扫一遍确保它和训练集的类别集合一致然后各自生成映射文件。我额外写了一个小函数检查两个集合的差异def check_label_alignment(train_json, val_json): with open(train_json) as f: train_map json.load(f) with open(val_json) as f: val_map json.load(f) assert set(train_map.values()) set(val_map.values()), 类别不一致这一步看似多余实际上能拦截很多坑。有一次我下载的ImageNet100验证集里混进了几个额外类别训练时label编码直接错位模型训了半天acc始终在10%徘徊。自从加了校验再没出过这类低级问题。4. 加载与验证转换不是终点用起来才算数Parquet文件转换完成后你要做的第一件事不是直接开始训练而是先把它加载出来检查数据是否完整、解码是否正常、类别分布是否符合预期。4.1 快速验证Parquet内容最简单的方式是直接用pyarrow读取并转成pandasimport pandas as pd df pd.read_parquet(imagenet100_train.parquet) print(df.shape) print(df[label].value_counts().head())如果只想看前几行可以用pq.read_table(..., columns[...])只读取需要的列。Parquet列式存储的好处这时候就体现出来了不需要把image_bytes整个读进内存只读label列就能统计分布。这个特性在调数据时非常宝贵。更专业的做法是用DuckDB直接跑SQL查询import duckdb duckdb.sql( SELECT label_name, COUNT(*) AS c FROM imagenet100_train.parquet GROUP BY label_name ORDER BY c DESC LIMIT 10; 这样能快速确认有没有类别图片数异常。分布严重不均衡时训练前就要决定是不是要做类别重采样而不是训练到一半才发现。4.2 在PyTorch DataLoader中高效读取PyTorch没有直接加载Parquet的内置接口但写一个Dataset并不复杂。核心思路是用pq.read_table把整个表映射到内存每次__getitem__时从表里取出一行解码图片bytes返回(image_tensor, label)。import torch from torch.utils.data import Dataset import pyarrow.parquet as pq from PIL import Image from io import BytesIO class ParquetImageDataset(Dataset): def __init__(self, parquet_path, transformNone, columnsNone): self.table pq.read_table( parquet_path, memory_mapTrue, columnscolumns or [image_bytes, label], ) self.transform transform def __len__(self): return self.table.num_rows def __getitem__(self, idx): row self.table.slice(idx, 1).to_pylist()[0] img Image.open(BytesIO(row[image_bytes])).convert(RGB) if self.transform: img self.transform(img) return img, row[label]这里面的关键参数是memory_mapTrue。Parquet文件加载到内存有两种方式普通读入整个文件或者通过mmap映射。mmap的好处是不会一次性分配大内存而是按页缓存映射读取时按需换入。对于10GB级别的Parquet文件这个差别足以决定你是在本地跑还是在服务器上被OOM杀掉。4.3 批量切片读取替代逐行读取上面的Dataset写法能跑但有一个隐藏性能问题table.slice(idx, 1)是一个Arrow表切片操作如果每张图都做一次Python层调用开销和Arrow切片开销会叠加。对于13万行数据批量切片会更划算。class BatchParquetImageDataset(Dataset): def __init__(self, parquet_path, transformNone, prefetch_size256): self.table pq.read_table(parquet_path, memory_mapTrue) self.transform transform self.prefetch_size prefetch_size self._buffer [] def __len__(self): return self.table.num_rows def _refill(self, idx): start idx end min(idx self.prefetch_size, self.table.num_rows) self._buffer self.table.slice(start, end - start).to_pylist() def __getitem__(self, idx): if not self._buffer or idx self._buffer[-1][_row_idx]: self._refill(idx) ...这个方案在prefetch_size范围内用一次to_pylist()批量转换出多个样本后续读取直接命中内存list速度提升相当可观。我实测在普通机械硬盘上批量读取比逐行切片快了三倍以上。当然也需要控制prefetch_size太大会导致内存占用过高图像类数据集建议256或者512。4.4 DataLoader参数怎么配PyTorch训练时shuffleTrue和num_workers0几乎是标配。Parquet文件数据源的小文件问题解决了但随机读取仍然要面对“在大文件里随机跳转”的开销。这里有两个经验一是优先使用persistent_workersTrue和prefetch_factor4减少worker反复重启的代价。二是如果数据量不大可以初始化Dataset时就把整个to_pylist()结果缓存到内存里训练时完全避开Parquet读取。ImageNet100总共约13万张图如果每张图按60KB算bytes总容量才7GB左右再加Python对象开销可能到10GB对于32GB内存的机器完全放得下。这是最简单粗暴但最有效的办法。5. 踩坑记录与性能调优心得转换Parquet看着简单实际操作中问题还挺多。我把这段时间遇到的典型问题整理成了速查表然后挑几个重要的展开讲。现象可能原因解决办法写入时内存暴涨一次性构造超大list分批构建表使用pq.write_to_dataset读取时图片颜色不对原图是RGBA/灰度转换时没转RGB统一convert(RGB)文件比原图总和还大压缩算法选了gzip且质量设置过高换zstdquality降到90-95加载速度不升反降装了太多列或row_group_size太小只加载必要列row_group_size设为1000以上label错乱两个数据集的类别顺序不一致单独保存label映射JSON训练前校验对齐内存映射失败文件太大且平台不支持mmap关闭memory_map或分块读取5.1 图片重编码带来的副作用用Pillow重新保存JPEG会改变图片的原始字节。对于训练任务来说这不是问题但如果你之后要做一些依赖原始编码的校验比如对比某张图在原始数据集里的MD5就需要注意转换是不可逆的。如果只是保存原始bytes文件体积会更小读取效率也更高代价是训练时每次都要处理可能的通道不一致问题。我的建议是如果是正式实验重编码基本无损肉眼都看不出差别完全可以接受。但如果是做数据竞赛或者要确保评测集和原始分布完全一致最好在验证集上保留原始bytes只在训练集上做统一预处理。5.2 别忽略Parquet数据集的分块与分区ImageNet100写成一个单文件完全没问题但如果你想更灵活可以按类别或者按shard写成多个Parquet文件用pq.write_to_dataset实现。这样做的优势在于可以按需读取某个类别或者把不同shard分给不同的训练进程。劣势是如果你的DataLoader没有做好文件级缓存多个小Parquet文件一样会退化回小文件IO。我自己实际使用中还是倾向单文件方案因为macOS和Linux对大文件读取都很稳定单文件备份、传输、上传对象存储都方便。只有数据集超过几十GB单文件在对象存储上操作不便时才会考虑分shard。5.3 多机训练时的数据分发技巧Parquet单文件带来的另一个隐藏福利是集群分发非常容易。假设你有4台机器不需要像目录结构那样rsync十几万个小文件只需要分发一个5GB左右的Parquet文件和label映射JSON。文件拷贝速度通常能快十倍以上而且文件完整性校验也更简单直接对单个文件做MD5即可。如果你用Hugging Face datasets库还可以直接这样加载from datasets import Dataset ds Dataset.from_parquet(imagenet100_train.parquet)之后就能利用datasets库的cast、select、train_test_split等函数做各种操作非常顺手。这是我后来用的最多的方式尤其在做细粒度类别筛选时比手写Pandas过滤要简洁很多。5.4 关于压缩比的实测感受我在同一个ImageNet100训练集上分别用snappy和zstd导出一版。原图目录总共约6.2GBsnappy版Parquet大约5.8GBzstd版大约4.9GB。考虑到JPEG已经是一种高压缩率格式能再压缩11%已经相当不错。读取速度方面snappy的解码速度略优于zstd但在现代CPU上差距很小。如果你关心存储成本直接选zstd如果你希望最佳读取效率选snappy。两条路都不会错。这里还想强调一点row_group_size会影响压缩效果。row group越大单个列块中可用的压缩上下文越丰富理论上压缩比更高。但过大的row group会导致随机读取单条数据时加载更多额外内容。对于图片bytes这种大字段row group size在500到2000之间是甜点区。5.5 转换后别忘了测试加载闭环最后一步往往最容易被忽略。Parquet文件生成完不等于万事大吉一定要跑一个端到端的加载测试写一个脚本读取全部数据解码每一张图片确认图片尺寸、通道数、label范围都正常再保存一个小批次做可视化检查。这一步能发现很多隐藏问题比如某些图片虽然后缀是JPEG但内容实际已经损坏。我自己就遇到过一张0字节的损坏文件扫描时后缀合法但Image.open的时候直接报错。转换脚本里如果没有异常捕获整个任务会在最后阶段崩溃。所以读取图像时要用try/except捕获异常记录坏图路径而不是让整个脚本中断。try: image_bytes image_path_to_bytes(str(img_path)) except Exception: print(f跳过损坏图片: {img_path}) continue另外转成Parquet之后原本的单张图损坏会影响一条记录通过损坏图片检测可以及时发现。我在脚本里统计过转换失败的图片数全部为0才放心开始训练。写在最后的小经验ImageNet100本身不大但它足够用来验证“图像数据集转Parquet”这套方法的正确性。转换完一次之后你会发现后续所有跟数据打交道的操作都顺畅了很多查看分布用SQL切子集用pandas训练加载用批量切片多机传输用单文件拷贝。这套思路也可以迁移到CIFAR、TinyImageNet、甚至你自己爬取的行业数据集上。最后再分享一个小技巧转换时把label_name一并存进去不要只存整数label。虽然整数label可以省空间但在排查数据、可视化结果、做混淆矩阵的时候字符串名字带来的便利远大于那一点点存储开销。调过几次模型的人应该都懂看到一个错分类样本能直接看到它的类别名比查一个数字映射舒服太多了。
返回列表