Python 存储大量 NumPy Array 等数据的方案:HDF5

对于序列化保存各种 array / data frame 等类型的数据,一直以来有各种各样的办法。例如我用过的,对于简单的一个 array,NumPy 有提供读写的方法;pandas 也有对应的 data frame 读写;而字符串/字典,可以变成 json 保存等。

但是,如果数量多了,例如有 100 个 array,上面的方法就不太方便了。我比较懒,会把这些 array 放到一个 dict 里面,然后用 pickle 把这个 dict pickle下来——保存和读取都非常方便,而且兼容所有数据类型。

后来,数据量多了之后,就发现 pickle 的方案也是有缺点的,就是性能不好(文末有初步的性能对比)。所以调研了一下后,选择了 HDF5。以前只是听过,没有用过,现在用了感觉不错,在下面稍微总结一下。

目标用户

无论是科学研究,还是各行各业,都有 HDF5 的身影。高效、跨平台、无上限,尤其适合数据量大的情景。见官网的 Who Uses HDF?

安装

HDF5 支持各种语言,Python 对应的库是 h5py。

$ pip install h5py
 or
$ conda install h5py  # Anaconda

核心概念

HDF5 里只有 2 种类型:datasetgroup
– dataset 就像数组,类似 Python 的 list (一维或多维),或 NumPy 的 ndarray。dataset 的语法和 ndarray 类似。
– group 就像 Python 的 dict,在我看来,它更像是带路径的文件夹。group 的语法和 dict 类似。

就像是在一层一层的文件夹中,存放着不同的 dataset。记住以上两点,就🆗

创建 HDF5 文件、dataset

通过创建一个属性为wa (append) 的File对象,来新建/修改一个 hdf5 文件,下面的例子,在 mytestfile.hdf5 的“根目录”下新建一个 dataset,数据类型是整数,shape 是一维的 (100,),初始化为 0。

>>> import h5py
>>> with h5py.File('mytestfile.hdf5', 'w') as f:
>>>     dset = f.create_dataset("mydataset", (100,), dtype='i')  # shape (100, )

读取 HDF5 文件

读取刚才创建的文件:

>>> import h5py
>>> f = h5py.File('mytestfile.hdf5', 'r')
>>> list(f.keys())
['mydataset']
>>> dset = f['mydataset']  # 是一个dataset object  不是array
>>> dset[:]  # 这才是array的数据
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
>>> dset.shape
(100,)
>>> dset.dtype
dtype('int32')
>>> f.close()

层级

刚才创建的时候,是在 mytestfile.hdf5 的“根目录”下新建一个 dataset,我们看一下它是怎么表示的。

>>> f = h5py.File('mytestfile.hdf5', 'a')
>>> f.name
'/'
>>> f['mydataset'].name
'/mydataset'

可以看到,基本上和 Linux / Mac OS 路径结构是一样的。

接下来在“根目录”下创建一个 subgroup(“文件夹”):

>>> subgroup = f.create_group('subgroup')
>>> subgroup.name  # 就像创建了一个文件夹
'/subgroup'

然后就可以在 subgroup 中创建 dataset。

>>> dset2 = subgroup.create_dataset('another_dataset', (50,), dtype='f')
>>> dset2.name
'/subgroup/another_dataset'

一个比较方便的 feature 是可以不用创建 group 也能直接创建“子目录”下的 dataset:

>>> dset3 = f.create_dataset('subgroup2/dataset_three', (10,), dtype='i')
>>> dset3.name
'/subgroup2/dataset_three'
>>> dataset_three = f['subgroup2/dataset_three']  # 不用一层一层进去

遍历一个 group:

>>> for name in f:
...     print(name)
mydataset
subgroup
subgroup2

和 Python dict 类似,也可以用 key in f, keys(), values(), items() 等方法。

属性 (Attribute)

HDF5 是可以自我描述 (self-describing) 的,每个 group 和 dataset 都有一个 .attrs 属性,。它就像一个 dict,可以往里面塞东西:

>>> dset.attrs['date'] = '2020-01-01'
>>> dset.attrs['date']
'2020-01-01'
>>> 'date' in dset.attrs
True

以上就是官方文档介绍的快速入门。接下来是一些入门之外的内容。

进阶

压缩

dataset 的压缩是 on the fly 的,即写入时即时压缩,读取时即时解压。相比不压缩,只是多花了点时间,读取的代码没有任何区别,写入时需要增加参数:

dset = f.create_dataset("zipped_dataset", (100, 100), compression="gzip", compression_opts=4)  # 压缩等级0~9 默认为4

字符串

h5py 支持的字符串有 3 种:定长 ASCII、变长 ASCII、变长 UTF-8。我用到的一般只有变长 UTF-8,所以下面只介绍它。

# 用在属性
dset.attrs['name'] = 'Hello'

# 用在dataset
str_dtype = h5py.string_dtype()
dset = f.create_dataset("a_string", data='aaaaaaaaaaaa', dtype=str_dtype)

与 pickle 的性能对比

我在实际的项目里,会用多个 Python 进程进行一些计算,然后把计算的结果存下来。对于 pickle,我用 4 个 Python 进程,保存 4 个 pickle 文件;对于 HDF5,4 个进程分别计算,保存时加锁,存到同一个 .hdf5 文件中。我在某一个实际项目中,粗略测试的性能数据如下:

保存格式计算+保存运行时间计算+保存峰值内存占用序列化文件总大小从序列化文件导出数据耗时导出数据内存占用
pickle1m 26s2600 MB
(pickle时内存翻倍)
1400 MB2m 30s900 MB
hdf51m 36s1800 MB700 MB
gzip 压缩
2m 15s150 MB

参考

1. 官方文档


发表评论