news 2026/9/30 7:54:21

数据集格式怎么选?从CSV到TFRecord的工程化实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据集格式怎么选?从CSV到TFRecord的工程化实践指南

平时做数据相关的工作,打交道最多的就是数据集和数据集格式。刚开始入行时,我拿到一份数据就习惯性用pd.read_csv()一把梭,不管它是图像、文本还是传感器数据。后来踩了不少坑,才意识到数据集格式这个东西,往小里说是文件怎么摆、字段怎么写,往大里说直接决定训练管道的效率、团队的协作方式,甚至影响你到底跑不跑得动某个任务。这篇就把我实际用过的、以及看到别人踩坑后换掉的几种常见数据集格式系统梳理一遍,重点说它们各自适合什么场景、底层原理是什么、切换时要注意什么。

1. 表格型数据:CSV 与 Parquet,绕不开的入门选择

1.1 CSV 依然是通用交换格式,但别拿它当存储主力

CSV 大概是所有人接触到的第一种结构化数据集格式。它本质上就是纯文本,一行一个样本,字段用逗号分隔。优点很直接:任何语言、任何工具都能读,Excel 能打开,Git 也能直接 diff,出问题时你可以用文本编辑器打开看一眼。在团队协作和对外交付时,CSV 依然是最高兼容度的那张安全牌。

但它的问题也很致命。首先是类型信息丢失:一个整数列、浮点数列、时间列,在 CSV 里都是字符串,读取时全靠下游自己推断。其次,解析成本高,我实测过一个 2GB 的 CSV,用 Pandas 读取大约要十几秒,内存峰值远高于文件本身大小,因为字符串解析中间对象的开销非常大。再有一个隐蔽问题:字段内容里如果包含逗号、引号、换行,必须做转义处理(RFC 4180),很多不规范导出的 CSV 在切分时直接干崩整份数据。

所以我的原则是:CSV 只用来做数据交换和人工查看,不用于正式训练集存储。如果必须在项目里用 CSV,记得做两件事:用dtype参数显式指定每列类型,避免 Pandas 反复推断;用chunksize分块读取,别一次性把大文件全部 load 进内存。

import pandas as pd # 显式指定类型,避免对 2GB 文件做全量类型推断 dtypes = { "user_id": "int32", "item_id": "int32", "score": "float32", "timestamp": "int64", } reader = pd.read_csv("large_ratings.csv", dtype=dtypes, chunksize=500_000) for chunk in reader: # 每 50 万行做一次处理,内存不会爆 pass

1.2 Parquet 的高效秘密:列式存储与压缩

如果说 CSV 是通用交换格式,那 Parquet 就是目前大数据和训练场景下的存储主力。它采用列式存储:同一列的数据物理上连续存放,而不是像 CSV 那样一行行挨着放。这个差异在跑聚合统计时格外明显,比如你想算某一列的平均值,列式存储只需要扫描那一列的数据块,而行式存储必须把整行全部读出来再丢掉无关字段。

Parquet 另一个核心优势是压缩。列式存储天然让同类型数据聚在一起,编码器可以针对整数、浮点数、字符串分别选择压缩算法。我手头一个 15 亿行的日志表,CSV 大约 120GB,转成 Parquet 后不到 15GB,压缩比达到 8 倍左右。磁盘省了,IO 也省了,训练时读取效率自然上去了。

此外,Parquet 每一列都会存储统计信息(min、max、null 数量),这为查询引擎做谓词下推创造了条件。你可以只读取满足条件的行和用到的列,这在超大数据集上性能差距是数量级的。用 Pandas 和 pyarrow 读写非常简单:

import pandas as pd import pyarrow.parquet as pq # 写入 Parquet,指定压缩算法 df = pd.DataFrame({"a": range(1000000), "b": range(1000000, 2000000)}) df.to_parquet("data.parquet", engine="pyarrow", compression="snappy") # 只读取 a 列,命中列裁剪 table = pq.read_table("data.parquet", columns=["a"])

我踩过的一个坑是:Parquet 的 schema 比较严格,同一个文件内的列名、类型必须一致,如果多份数据直接按字符串拼接后再写入,可能会出现类型推断不一致导致写失败。解决办法是在写入前统一做一次astype转换,确保每个分区列的 schema 都是固定死的。

2. 标注型数据:COCO、YOLO、Labelme 三种格式怎么选

2.1 COCO JSON:目标检测与分割的通用语言

做目标检测、实例分割相关项目的人,几乎躲不开 COCO 格式。它把整个数据集的标注信息全部写进一个 JSON 文件里,核心结构分三块:images存图像路径、宽高、ID;annotations存每个目标实例的类别、边界框、分割多边形;categories存类别名称到 ID 的映射。

COCO 格式我用了好几年,最大的优点是完全不依赖文件目录结构,标注信息集中管理,适合跨团队协作。下面是一个最小可用的 COCO JSON 结构:

{ "images": [ {"id": 1, "file_name": "000001.jpg", "width": 640, "height": 480} ], "annotations": [ { "id": 1, "image_id": 1, "category_id": 2, "bbox": [100, 120, 200, 150], "area": 30000, "segmentation": [[105, 125, 290, 120, 300, 260, 110, 275]], "iscrowd": 0 } ], "categories": [ {"id": 1, "name": "person"}, {"id": 2, "name": "car"} ] }

这里的bbox是[x, y, width, height],是绝对像素坐标,不是归一化坐标。segmentation是多边形顶点坐标的平铺列表。很多人第一次转 COCO 时容易把坐标搞成归一化值,或者把宽高写成右下角坐标,这两种错误会直接导致模型训练时的 anchor 匹配和 loss 计算全部崩掉。

还有一个使用细节:COCO 官方工具包pycocotools对segmentation的多边形格式要求非常严格,必须是每个多边形作为一个 list 传入,并且经过编码的 RLE 格式和普通多边形格式不能混用。建议入库前统一做一遍校验,别把数据源的锅留到训练中期排查。

2.2 YOLO txt 和 Labelme JSON:轻量方案与标注工具绑定

YOLO 系列用得非常多,它的标注格式比 COCO 简单很多:每张图像对应一个同名的.txt文件,每一行是一个目标,格式是class x_center y_center width height,所有数值都归一化到 0~1 之间。

我经常提醒项目里的人,YOLO 格式的坐标是归一化的中心点坐标,和 COCO 的左上角 + 宽高完全不一样。转格式时如果直接把像素坐标除以图像宽高就完事,那没问题;但有很多标注工具导出时默认是像素值,忘记归一化就会导致训练时 loss 直接爆炸且无法收敛。下面是读取 YOLO 标注并画框的简单示例:

def read_yolo_label(txt_path, img_w, img_h): boxes = [] with open(txt_path, "r") as f: for line in f: parts = line.strip().split() cls_id = int(parts[0]) x_c, y_c, w, h = map(float, parts[1:]) x1 = int((x_c - w / 2) * img_w) y1 = int((y_c - h / 2) * img_h) x2 = int((x_c + w / 2) * img_w) y2 = int((y_c + h / 2) * img_h) boxes.append((cls_id, x1, y1, x2, y2)) return boxes

Labelme JSON 则是我用标注工具时最常遇到的中间格式。它把每个标注对象保存为一个shapes数组,里面是多个点和shape_type,支持多边形、矩形、圆形等。Labelme 格式最大的价值是便于人工标注,但直接用于训练并不高效,因为它的坐标同样是绝对像素点阵,且文件数量庞大。

我的做法是:搞一个转换脚本,把 Labelme JSON 统一转换成 COCO JSON 或者 YOLO txt,而不是在训练代码里每次去解析 Labelme 文件。标注工具负责生产原始信息,训练管道只消费规范化的统一格式,这样多人协作时不会因为标注工具的版本差异导致数据解析逻辑互相冲突。

3. 图像分类场景:ImageFolder 与 LMDB 的取舍

3.1 ImageFolder:最省事的图像数据集目录结构

图像分类大概是所有深度学习任务里最基础的一种,PyTorch 的torchvision.datasets.ImageFolder直接把数据集格式定义成了一种“约定优于配置”的目录结构:

dataset/ class_0/ 0001.jpg 0002.jpg class_1/ 0001.jpg 0002.jpg

每个子文件夹的名字就是类别名,图片自然按照类别归组。ImageFolder会自动扫描目录,按文件夹名字母顺序映射类别 ID,并从每个图片的元信息里解析标签。这种格式最大的优势是零成本开始:你不需要编写任何标注文件,只要把图片放进对应文件夹就能开始训练。

但这里有一个隐蔽的坑:类别 ID 是目录名的字母序决定的。比如你有cat和dog两个类,那cat是 0、dog是 1。如果后来新增一个bird文件夹,排序就变成bird=0, cat=1, dog=2,之前训练保存的 checkpoint 里最后一层类别映射就全乱了。所以正规项目的做法是永远不要依赖ImageFolder的隐式排序,而是在代码里显式声明一份class_to_idx映射并固定下来,哪怕未来增删类别也不影响旧模型。

3.2 LMDB:海量小文件场景下的工程选择

图像数据集常见的痛点是海量小文件。一个 100 万张图片的数据集,按小图平均 50KB 来算总量 50GB,但文件数量高达 100 万。如果直接放在普通文件系统里,每次读取都要经过目录查找、inode 分配、文件打开关闭,训练时数据加载往往成为瓶颈,GPU 一直空转等数据。

LMDB(Lightning Memory-Mapped Database)就是解决这个问题的经典方案。它把数据存成一个内存映射的 key-value 数据库,读取时通过mmap把数据直接映射到内存地址空间,不需要频繁的系统调用。性能上,我做过一个对比:相同的数据量,从普通文件夹读取 100 万张小图大约需要 25 分钟,换成 LMDB 后压缩到不到 6 分钟。

LMDB 的写入和使用也非常简单:

import lmdb import pickle env = lmdb.open("train.lmdb", map_size=1024**4) # 1TB map size with env.begin(write=True) as txn: for idx, (img_bytes, label) in enumerate(dataset_samples): txn.put(str(idx).encode(), pickle.dumps((img_bytes, label))) txn.put(b"__len__", str(len(dataset_samples)).encode())

读取时,先从环境里查到样本数量,再根据索引直接获取。这里有个经验:LMDB 的map_size一定要设置够大,如果写入过程中超过 map 大小会直接报MapFullError,很多初学者以为 map_size 是预分配磁盘,不敢设大,其实它只是地址空间大小,不会立刻占用物理磁盘。

不过 LMDB 也不是银弹。它适合“数据量大、单条数据独立、顺序访问为主”的场景,但如果你的数据需要频繁按条件筛选、跨样本做复杂的增强组合,那 LMDB 的优势就会被严重稀释,这时候用普通目录加缓存策略说不定更灵活。

4. 序列化大数据格式:TFRecord 与 HDF5 的工程化应用

4.1 TFRecord:为训练管道而生的二进制格式

TFRecord 是 TensorFlow 生态里最常用的数据集格式。它的本质是一个二进制序列化容器,内部使用 Protocol Buffers 定义结构,每一条记录就是一个Example,里面用Feature保存多种类型的字段:int64、float、bytes。

我为什么会在非 TensorFlow 项目里也用它?核心原因是它把“无数个小文件”变成“少数几个大文件”,大大减少了文件系统的访问压力。训练时,tf.data.TFRecordDataset可以顺序读取大文件,结合shuffle、map、prefetch构建出高效的输入管道。下面是一个把图像数据写入 TFRecord 的示例:

import tensorflow as tf def serialize_example(image_bytes, label): feature = { "image": tf.train.Feature(bytes_list=tf.train.BytesList(value=[image_bytes])), "label": tf.train.Feature(int64_list=tf.train.Int64List(value=[label])), } example = tf.train.Example(features=tf.train.Features(feature=feature)) return example.SerializeToString() with tf.io.TFRecordWriter("train.tfrecord") as writer: for image_path, label in sample_list: image_bytes = open(image_path, "rb").read() writer.write(serialize_example(image_bytes, label))

TFRecord 有一个特点:它不是自描述的,也就是说光看文件不知道里面有哪些字段、字段类型是什么。所以团队内部一定要维护一份独立的 schema 文档,并在文件名的前缀或者固定的元数据文件里标明字段版本。我见过多次“换了个机器加载 TFRecord 结果字段对不上”的翻车现场,基本都是因为 schema 没有版本管理。

另一个要注意的点是shuffle的时机。如果你把整个数据集封装成一个巨大的 TFRecord,shuffle需要足够大的 buffer 才能打散样本顺序,否则一个 batch 里全是近邻样本,训练效果会受影响。解决办法是写多个分片文件(shard),每个 shard 内部再 shuffle。

4.2 HDF5:面向科学计算的多维数组容器

HDF5 则是另一种完全不同的思路,它特别擅长存储大规模多维数组。HDF5 自带层次结构,类似文件系统里的目录树:group 相当于文件夹,dataset 相当于数据对象。每个 dataset 可以直接存 NumPy 数组,支持切片读取,并且内置 chunk 压缩功能,非常适合遥感、医学影像、物理模拟这种高维数据。

h5py 的 API 很直接:

import h5py import numpy as np with h5py.File("features.h5", "w") as f: grp = f.create_group("train") grp.create_dataset( "images", data=np.zeros((10000, 224, 224, 3), dtype=np.float32), chunks=(128, 224, 224, 3), compression="gzip", ) grp.create_dataset("labels", data=np.arange(10000))

HDF5 的一个核心概念是 chunk。设置chunks后,数据在物理上按块存储,读取时只加载相关块,而不会像普通数组那样把整个数据集载入内存。压缩也可以针对 chunk 进行,gzip、lzf是常用方案。但 chunk 的大小需要平衡:太大,随机读取时多余 IO 多;太小,压缩率下降,元数据开销变大。我的经验是让每个 chunk 的大小在 1MB 到 8MB 之间,既兼顾了随机访问效率,也保留不错的压缩率。

HDF5 的坑在于并发写。多个进程同时写同一个 HDF5 文件很容易损坏文件,因为 HDF5 的文件锁机制和常见的训练框架多进程 DataLoader 配合得并不好。如果非要并发写,我建议要么先单进程写入临时文件,要么每个进程写独立文件,最后再合并。这个坑我踩过两次,每次都是训练到一半发现数据文件读不出来了,整个流程重新跑,代价很大。

5. 数值计算与中间结果:npy/npz 的使用边界

5.1 npy:单一数组的最佳保存方式

在预处理特征、保存 embedding、存中间训练结果的时候,最容易想到的就是 NumPy 的.npy格式。它保存的是单个 NumPy 数组,包括 dtype、shape、strides 等信息,加载时可以完全还原原始数组结构,不需要额外的元信息文件。

np.save和np.load非常方便,但我更想强调np.memmap的用法:它可以按内存映射的方式读取超大 NumPy 数组,而不像np.load把整个数组加载进内存。这对于单机处理几百 GB 的数值数据特别有用。一个典型场景是保存全量图像特征:几百万张图各提取一个 1024 维向量,总大小超过 10GB,直接np.load容易把内存占满,换成memmap就可以按行抽样、按 batch 切片:

import numpy as np # 写一个超大特征矩阵 features = np.memmap("features.npy", dtype=np.float32, mode="w+", shape=(500_0000, 1024)) features[:] = compute_all_features() features.flush() # 之后读取,内存只占一个页面的量 features = np.memmap("features.npy", dtype=np.float32, mode="r", shape=(500_0000, 1024)) batch = features[:256] # 只加载 256 行

使用 npy 存储有一点要小心:文件头部只包含数组元信息,并不包含字段名。你保存的是纯数组,所以如果数组的每一行代表不同含义,必须在外部维护一份说明文档或者在文件名上写清楚。这听起来很简单,但实践里我见过太多“这个 npy 到底啥意思”的灵魂拷问。

5.2 npz 与 pickle:多数组和通用对象的取舍

当你要一次性保存多个数组时,可以用.npz,它其实是一个 zip 容器,内部保存多个.npy文件。np.savez支持关键字参数,加载时通过字典访问:

np.savez("batch_data.npz", images=img_array, labels=label_array, metadata=meta_array) loaded = np.load("batch_data.npz") img_array = loaded["images"]

.npy和.npz都是相对“安全”的格式,因为它们只描述 NumPy 数组的结构,加载时不会有代码执行。

但.pickle就不一样了。pickle 可以序列化任意 Python 对象,所以很多初学者图省事,把整个数据对象直接pickle.dump出来。我强烈建议不要用 pickle 作为数据集的长期存储格式,哪怕自己写着方便。原因有两个:一是 pickle 的格式和 Python 版本、类的定义强绑定,类结构一变,老文件可能直接反序列化失败;二是加载 pickle 是存在任意代码执行风险的,一旦你拿到一个来路不明的.pkl,执行pickle.load相当于让别人在你的机器上跑代码。

如果只是想保存字典或对象配置,更好的选择是用 JSON,或者用safetensors这类专门为模型张量设计的格式。safetensors 不仅在安全性上做了约束(加载时不执行任意代码),而且支持内存映射加载,在大模型场景下已经逐渐成为主流。

6. 自定义数据集格式:什么时候该自己造轮子

6.1 设计自研格式前先想清楚三个问题

看到这里你会发现,大部分场景都有成熟的现成格式可用。但现实中的确存在一些场景,通用格式不满足需求,比如点云数据带着变长的时间戳、流式日志和样本关联、超高吞吐的强化学习轨迹回放等。这时候确实要考虑自定义格式。

但动手造轮子之前,我建议你先回答三个问题。第一,现有的格式是真的“不能”,还是仅仅“不够顺手”?如果只是性能不够,先试试调 chunk、换压缩算法、改并行读取策略,很多“瓶颈”其实是使用方式的问题。第二,你的自定义格式是否具备完整的写入、读取、校验工具链?只有写入没有读取,等于没做。第三,团队成员未来是否都能维护这套格式?长期没有人维护的自定义格式,最终会变成团队的负担,比技术选型问题严重得多。

如果以上三个问题都能给出正面答案,那自定义格式是完全合理的。设计时有几个原则很关键:

  • 文件头写入魔数(magic number)和版本号,便于快速识别文件类型和兼容性。
  • 固定长度的头部信息,比如样本总数、每条记录的字节长度,方便随机访问和进度条展示。
  • 每条记录包含长度前缀和校验码,损坏时可以快速定位并跳过,而不是整个文件报废。
  • 尽量与存储介质对齐,比如大块顺序读优于随机小读。

6.2 一个自定义二进制数据集格式的简化例子

我之前为一个联邦学习项目设计过一个简化自研格式。数据源是手机端上报的梯度更新,每条记录长度不固定、字段类型混杂、数量高达几千万条。用 JSON 存体积爆炸,用 Parquet 又因为每条样本的字段结构不同而难以对齐。最终我们定义了一个二进制格式:

int32 magic # 0xFAFA int32 version # 格式版本 int32 sample_count # 样本总数 int64 data_offset # 数据区起始偏移 ------------------ 数据区 ------------------ 每条记录: int32 record_bytes # 本条记录总字节数 int32 sample_id # 样本 ID int32 feature_len # 特征数组长度 float32 features[] # 特征值 uint32 crc32 # 本条记录校验值

核心思路是每条记录前先写一个长度前缀,读取时先定位到某个样本的偏移,再一次性把整条记录读出来。sample_count写在文件头,所以我们可以直接二分法定位到任意样本的偏移,实现随机访问。crc32校验挽救了我好几次,因为早期日志传输偶尔出现坏块,没有校验时只能靠运行时 loss 异常去反查,有了校验码可以训练前直接全量验证一遍。

如果你也要设计类似格式,我的建议是文件头多留 64 字节保留区,以后扩展字段不用改整体布局。这个设计细节看着不起眼,等你真的要加新字段时就会感谢当初留的冗余空间。

7. 一文速查:数据集格式怎么选

把上面各种格式汇总成一张速查表,方便你做技术选型时快速对照。

格式适用场景优点主要缺点推荐工具
CSV小型表格、数据交换通用、可读性高无类型、解析慢Pandas
Parquet大规模表格、数仓列式压缩、查询高效不适合频繁小改pyarrow
COCO JSON目标检测、分割标注集中、生态完善文件大、解析慢pycocotools
YOLO txt目标检测轻量方案文件小、读取直接坐标归一化易出错自写脚本
Labelme JSON人工标注过程可视化友好结构冗余、依赖工具labelme
ImageFolder图像分类起步零配置、直观类别映射隐式torchvision
LMDB海量小图高性能、mmap并发写脆弱lmdb
TFRecordTensorFlow 管道顺序读快、生态完善不自描述tensorflow
HDF5科学计算多维数组分层结构、切片好并发写差h5py
npy/npzNumPy 数组中间结果简单、保留完整信息无字段名numpy
pickle临时对象持久化任意对象不安全、版本依赖不建议长期用

选格式这件事,在我看来从来不是“哪个最好”,而是“哪个最合适”。同样是十几 GB 的数据,图像分类用 LMDB 很顺手,表格特征用 Parquet 更香,做 TensorFlow 的项目绕不开 TFRecord,科学计算又离不开 HDF5。一切取决于你的数据形态、训练框架、团队维护能力和后续扩展方向。

我个人实际项目里最常用的一套组合是:原始数据统一进 Parquet 归档,标注信息用 COCO JSON 做中间层,训练管道的输入再按框架转成 TFRecord 或直接读取 LMDB。越往链路下游,格式越贴近底层框架;越往数据源上游,格式越贴近通用标准。这套思路帮我避开了很多数据读取和协作上的坑。

最后再分享一个感受:格式转换本身不复杂,真正复杂的是转换过程中暴露出来的数据质量问题,坐标算错、字段缺失、编码混乱,这些才是常见痛点。所以无论你用哪种数据集格式,一定要保留一层数据校验环节,在写入和读取两端都做检查。格式只是容器,数据质量才是底牌,这两点抓牢了,后面跑模型会顺畅得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 7:54:19

Git实战指南:分布式版本控制的原理、操作与团队协作

Git这个工具,我在一线写了十几年代码,从一开始觉得“多此一举”,到后来彻底离不开它,中间的转变过程还挺值得聊聊。Git不是某个公司出的某个网盘,也不是“代码备份工具”这么简单,它是一套分布式版本管理系…

作者头像 李华
网站建设 2026/9/30 7:54:07

机器视觉缺陷检测全链路实战:光源、相机、镜头选型与算法落地

简介:这份PDF资料面向从事机器视觉与图像处理的工程师、算法开发者及工业检测相关技术人员,系统梳理了工业缺陷检测中的核心知识体系。内容围绕硬件选型展开,涵盖光源类型与照明方式的选择(如背向照明、前向照明、同轴光、低角度光…

作者头像 李华
网站建设 2026/9/30 7:54:05

尤文图斯网上商城Java Web开发实战:JSP+Servlet+MySQL全解析

1. 项目先拆明白:这个“尤文图斯网上商城”到底在做什么 1.1 选题背景与需求场景还原 先说结论:jspm尤文图斯足球俱乐部网上商城系统,是一个典型的电商类Java Web毕业设计项目。标题里的“jspm”不是某个高深框架,而是这类老牌毕…

作者头像 李华
网站建设 2026/9/30 7:53:39

基于Django和协同过滤的电影推荐系统

1.毕业设计(论文)课题来源及内容:(注明自拟、科研、科技服务类别及任务提出单位) 课题来源:自拟课题内容:随着我们国家的互联网规模不断的扩大,用户规模越来越庞大&#…

作者头像 李华
网站建设 2026/9/30 7:52:30

鸿蒙设备上Flutter Card列表实战:搭建、适配与性能优化

1. 为什么我决定在鸿蒙设备上跑 Flutter,还专门研究 Card 列表先交代一下背景。我手头有一批基于 OpenHarmony 生态的测试设备,同时团队里的核心业务代码早就用 Flutter 写好了。如果为了鸿蒙单独维护一套原生实现,成本实在太高。Flutter 的跨…

作者头像 李华
网站建设 2026/9/30 7:52:29

接触式测量细长薄壁管件:从定位、平行度到压表基准的完整工艺解析

做精密管类零件测量的人,对这类作业指导书应该都不陌生:“将包壳管移动至两端塞距离小于3mm处,于外表安装于与包壳管轴线平行的模组上,沿垂直于轴线的径向移动到包壳管的最高点后压标0.3mm。再带表移动模组至……”。我第一次看到…

作者头像 李华