3.2 读写数据
使用Dataset对象就跟你平常使用NumPy数组是一样的。h5py模块的一个设计目标就是在数据集上尽量复现NumPy语义,让你能够以熟悉的方式操作它们。
但是,就算你是个熟练的NumPy用户也千万不要跳过本节!两者上有一些细微的性能差别和实现细节会让你大吃一惊。
在我们深入了解数据集读写的具体细节之前,先花几分钟讨论一下Dataset对象和NumPy数组之间不同的地方,特别是在性能方面。
3.2.1 高效率切片
为了高效使用Dataset对象,我们必须了解一点背后的细节。让我们看一个读取数据集的例子。假设我们有一个形状为(100,1 000)的数组:
现在我们对它进行切片:
这里是我们切片操作背后的细节。
1.h5py计算出结果数组对象的形状是(10,50);
2.分配一个空的NumPy数组,形状为(10,50);
3.HDF5选出数据集中相应的部分;
4.HDF5将数据集中的数据复制给空的NumPy数组;
5.返回填好的NumPy数组。
你会注意到在读取数据之前有不少隐藏的开销。我们不仅需要为每一次切片创建一个新的NumPy数组,还必须计算数组对象的大小,检查切片范围不超出数据集边界,让HDF5执行切片查询。
这导致了我们在使用数据集上的第一个也是最重要的性能建议:选择合理的切片大小。
看看下面的例子,对于一个形状为(100, 1 000)的数据集,哪一种性能更高?
还是
第一种方案进行了100 000次切片操作。第二种则仅有100次。
这个例子看上去简单,却是我们走向真实世界代码的第一步:在现代计算机上对内存中的NumPy数组切片速度非常快,但当你开始对磁盘上的HDF5数据集进行切片读取时就会陷入性能瓶颈。
写入时的步骤略少一些,但基本道理也是一样:
会产生下列步骤。
1.h5py计算出切片大小并检查是否跟输入的数组大小匹配;
2.HDF5选出数据集中相应的部分;
3.HDF5从输入数组读取并写入文件。
计算切片大小等所有开销也依然存在。对数据集一次写入一个甚或少数几个元素必然导致性能的低下。
3.2.2 start-stop-step索引
h5py使用了NumPy基本切片功能的一个子集。这也是人们最熟悉的切片形式,提供最多3个索引表示开始、停止和间隔。
如下例,我们创建一个具有10个元素的数据集,其值递增:
提供一个索引指向一个特定元素:
提供两个索引指向一个范围,恰止于第二个索引之前:
提供3个索引,则第三个表示选取元素时需要跳过的间隔:
当然,只提供“:”表示获取全部元素:
和NumPy一样,你可以用负数表示从尾部开始反向计算,-1指向最后一个元素:
不过h5py不支持NumPy索引的某些高级运用,比如,令NumPy数组反转的传统操作如下:
但如果你在数据集上这样做,你会得到:
3.2.3 多维切片和标量切片
目前为止,你已经在上面的例子中看到过很多次“…,”切片表达式了。这个对象在Python世界中有一个内建的名字叫Ellipsis。你可以用它来指代那些你不想特别指定的维度:
当然你也可以只用Ellipsis来获取全部内容:
有一个特例值得我们讨论,那就是标量数据集。NumPy有两种数组仅包含一个元素。第一种具有形状(1,),这是一个普通的1维数组。你可以用切片或索引得到其中的值:
注意使用Ellipsis时返回的是一个具有1个元素的数组,而使用索引则是返回了那个元素本身。
第二种数组的形状是()(一个空元组),无法通过索引访问:
注意使用Ellipsis时返回的又是一个数组,不过这次是一个标量数组。
那么我们如何才能得到那个值本身而不是一个NumPy数组呢?答案是用另一种看上去有点奇怪的以空元组为索引的切片形式:
记住它们的区别:
1.用Ellipsis获取数据集中的所有元素,结果永远是一个数组。
2.用空元组“()”获取数据集中的所有元素,对于1维或更高维数据集,结果是一个数组,对于0维数据集则是一个标量。
提示
你也许在某些代码里见过使用数据集的.value特征,这种用法和使用dataset[()]完全等效。但由于历史原因它们已被取代,在现代的h5py版本中不支持这种用法。
3.2.4 布尔索引
在之前的一个例子里,我们使用了一个有趣的表达式将NumPy数组“val”中的负值置为0:
这是NumPy特色的布尔数组索引。如果val是一个NumPy整型数组,那么表达式val<0的结果是一个布尔数组,其中的元素当val中相应元素为负时为True,否则为False。在NumPy里也被称为数据筛选(mask)。
这里的关键在于NumPy和HDF5都支持以一个布尔数组作为索引表达式。其行为跟你期望的一样:当布尔数组索引中的元素为True时,数据集中对应的元素被选中,反之亦然。
看下面的例子,假设我们有一个数据集,其元素被初始化为一组在-1和1之间平均分布的随机数:
现在让我们用一个布尔数组将负值截断为0:
在HDF5这边,它会将这个布尔数组转换成数据集内部的一个坐标列表。而这么做会导致下列的结果。
首先,对于含有大量True值的超大索引表达式,在Python中修改数据并写回数据集可能会更快。如果有某个性能问题你怀疑是这个原因导致的,建议你用Python做个测试。
其次,表达式的右边必须要么是一个标量,要么是一个正好具有相同元素个数的数组。这个需求其实只是看上去有点多余。如果元素个数较少,它实际上将是一个“更新”数据集的高效做法。
比如,如果我们不是将负值截断为0而是将其转正会如何?我们可以修改原始数组并将其整个写回磁盘。或者我们可以只修改必须的元素:
记住等号左右两边的元素个数是相等的(都是5)。
3.2.5 坐标列表
本特性在NumPy的近似功能上做了一些修改。在对数据集进行切片时,你可以对任何维度指定索引列表而不是x:y:z风格的切片表达式。让我们以之前那个10元素数据集为例:
假设我们只需要元素1、2、7。我们可以像这样一一抽取:dset[1]、dset[2]、dset[7]。或者我们也可以用一个布尔索引数组,其1、2、7位置为True。
又或者我们可以提供一个列表,简单地指定需要的元素:
这看上去不值一提,但其实现方式令其比在大数据集上进行布尔数组筛选要有效率得多。相比于直接生成一堆有待访问的坐标列表,h5py会将一次查询分成几个连续的“子查询”,在涉及多个维度时这样会快很多。
提示
如果你需要查找NumPy访问数组的各种奇妙手段,搜索关键字fancy indexing。
当然,我们的功能跟原始的NumPy坐标列表切片功能相比还是有一些区别的,消除了下面这些来自NumPy的限制:
1.列表一次只能切一个维度;
2.不允许重复的元素;
3.列表中的索引必须递增排序。
3.2.6 自动广播
在之前的一些例子里,我们已经看到当等号左右两边的元素数量不等时会发生切片赋值。比如下面这个布尔数组:
对此类表达式的处理称为广播,和NumPy内建的广播功能类似。谨慎使用广播可以为你的程序带来性能上的提升。
拿之前那个形状为(100, 1000)的数组为例。假设它是一个含有100条记录的日志,每条记录包含1 000个元素:
现在假设我们希望复制dset[0,:]的记录并覆盖所有其他记录。我们可以用for循环:
这种写法,我们需要手写一个循环,正确处理边界条件,当然还需要进行100次切片操作。
更简单的写法是,使用h5py内建的高效的广播功能:
等号右边的数组形状是(1 000),左边的则是(100,1 000)。由于最后一维匹配,h5py会将数据重复复制到全部100条记录中。这是最有效的做法,仅有一次切片操作,其余时间就是在写入磁盘。
3.2.7 直读入一个已存在的数组
最后我们又回到了read_direct,这是Dataset对象最有威力的方法之一。在不考虑h5py内部细节的情况下,它是最接近HDF5的C接口的方法。
之前我们已经讨论过,你可以用read_direct来让HDF5将数据填入一个已经存在的数组,并自动进行类型转换。之前我们见过如何将float32数据读入NumPy的float64数组:
虽然这样可以工作,但它需要你一次性读取整个数据集。让我们举一个更有用的例子。假设我们需要读取位于dset[0,:]的第一条记录,并复制到out[50,:]的位置。我们可以用source_sel和dest_sel关键字分别选择源和目标:
那个奇怪的np.s_是一个小装置,它以一个数组切片格式为参数,将相应信息记录在NumPy的一个slice对象,并返回这个slice对象。
你不需要让输出数组和数据集的形状匹配。假设我们的程序需要计算每条记录前50个数据点的中位数,在真实世界中这是评估直流补偿常见的方法。你可以这样使用标准的切片技术:
或者使用read_direct,如下所示:
这两种方法看上去可能只有很小的差异,其实有一个很重要的区别。第一个方法,h5py每次都会在内部创建一个out数组用于保存切片数据,用完就被丢弃。第二种方法的out数组则是由用户分配的,且可以被后续的read_direct循环利用。
这两种方法在使用形状为(100, 50)的数组时可能没什么性能差异,但如果是(10000, 10000)的形状呢?
让我们实际检查一下这种情况下的性能开销吧。我们将创建一个测试数据集以及两个函数。为了让事情简化并仅观察out的状态不同带来的性能差异,我们总是选择在数据集同样的位置进行读取:
现在我们会看到保留out数组带来的效果,如果我们用for循环读100次:
不错,差距2秒,14%的提升。当然,最终还是由你自己决定如何优化。上面那个simple的方法可读性肯定更高一些。但在对同样形状进行多次读取,特别是数组较大的情况下,它很难打败read_direct。
提示
出于历史上的原因,还存在一个write_direct方法。它跟read_direct做同样的事情,只是方向相反。然而,在h5py现代的版本中,它的效率已经不再高于普通的切片赋值了。你依然可以使用它,但是不会有什么性能提升。
3.2.8 数据类型注解
HDF5的设计允许你以任何格式保存数据。这意味着有时候你可能得到一个文件,其内容的格式跟你系统最方便处理的格式有所不同。我们之前讨论过的一个例子就是endian问题,它关系到系统如何表示一个多字节的数字。比如,你可以在内存中保存一个四字节浮点数,其低位字节在前(little-endian),或高位字节在前(big-endian)。现代的Intel X86架构的芯片使用little-endian格式,但这两种格式的数据都可以被HDF5保存。
由于h5py不知道你是想要读取数据进行处理还是将其发送到别的地方,默认行为是直接以存储时的格式返回数据。就endian来说这是透明的,因为NumPy对两种endian都支持。然而这里有隐藏的性能问题。让我们创建两个NumPy数组,一个little-endian,一个big-endian,然后看看他们在X86系统上的性能:
糟糕!性能差一倍。如果你正在处理来自别人的数据,且你的应用程序运算了很长时间,那么值得花点时间检查一下。
为了将数据转换成符合你系统的“本地”endian,你基本上有3个选择:使用read_direct直接读入一个你自己创建的数组,使用astype环境管理器,或者在读取数组后手动转换。对于最后这个选择,有一个快速的方法可以将NumPy数组就地转换,无需复制:
这是一个常见的性能问题,不仅限于endian的转换。比如,还有单双精度浮点转换,或者你在处理16位整型的代码中用到了大于216的值。时刻注意你的数据类型,尽可能使用HDF5提供的功能来帮你进行转换。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论