NumPy 数组的内部组织#
了解 NumPy 数组在底层是如何处理的,有助于更好地理解 NumPy。本节提供简要解释。更多详情可在 Travis Oliphant 的著作《NumPy 指南》中找到。
NumPy 数组由两个主要组件组成:原始数组数据(以下称为数据缓冲区),以及关于原始数组数据的信息。数据缓冲区通常被认为是 C 或 Fortran 中的数组,即一个包含固定大小数据项的连续的(且固定)内存块。NumPy 还包含大量数据,用于描述如何解释数据缓冲区中的数据。这些额外信息包括(其中包括)
基本数据元素的大小(字节)。
数据在数据缓冲区内的起始位置(相对于数据缓冲区开头的偏移量)。
维度的数量和每个维度的大小。
每个维度中元素之间的间隔(步长)。这不必是元素大小的倍数。
数据的字节顺序(可能不是本机字节顺序)。
缓冲区是否为只读。
关于基本数据元素解释的信息(通过
dtype
对象)。基本数据元素可以像 int 或 float 一样简单,也可以是复合对象(例如,类结构体)、固定字符字段或 Python 对象指针。数组是否被解释为C 顺序或Fortran 顺序。
这种安排使得数组的使用非常灵活。其中一个功能是可以通过简单地更改元数据来改变数组缓冲区的解释。更改数组的字节顺序是一个简单的操作,不涉及数据的重新排列。数组的形状可以非常容易地更改,而无需改变数据缓冲区中的任何内容或进行任何数据复制。
此外,还可以创建一个新的数组元数据对象,该对象使用相同的数据缓冲区来创建该数据缓冲区的新视图,该视图对缓冲区有不同的解释(例如,不同的形状、偏移量、字节顺序、步长等),但共享相同的数据字节。NumPy 中的许多操作正是如此,例如切片。其他操作,如转置,不会在数组中移动数据元素,而是改变关于形状和步长的信息,从而改变数组的索引,但数组中的数据不会移动。
通常,这些新的数组元数据版本(但使用相同的数据缓冲区)是数据缓冲区的新视图。存在一个不同的 ndarray
对象,但它使用相同的数据缓冲区。这就是如果确实想创建一个新的独立的数据缓冲区副本,则有必要通过使用 copy
方法来强制复制的原因。
数组的新视图意味着数据缓冲区的对象引用计数增加。如果数据缓冲区仍有其他视图存在,简单地删除原始数组对象并不会移除数据缓冲区。
多维数组索引顺序问题#
另请参阅
索引多维数组的正确方法是什么?在你对索引多维数组的唯一正确方法下结论之前,有必要了解为什么这是一个令人困惑的问题。本节将尝试详细解释 NumPy 索引的工作原理,以及我们为什么对图像采用这种约定,以及何时可能适合采用其他约定。
首先要理解的是,二维数组的索引有两种相互冲突的约定。矩阵表示法使用第一个索引指示选择哪个行,第二个索引指示选择哪个列。这与图像的几何导向约定相反,图像中人们通常认为第一个索引表示 x 位置(即列),第二个索引表示 y 位置(即行)。仅这一点就造成了很大的困惑;面向矩阵的用户和面向图像的用户在索引方面期望不同的事物。
第二个要理解的问题是索引如何对应于数组在内存中的存储顺序。在 Fortran 中,当遍历二维数组的元素时,第一个索引是变化最快的索引,因为它存储在内存中。如果你采用矩阵索引约定,这意味着矩阵是按列存储的(因为第一个索引改变时会移动到下一行)。因此,Fortran 被认为是一种列主序语言。C 则恰好相反。在 C 语言中,当遍历内存中存储的数组时,最后一个索引变化最快。因此,C 是一种行主序语言。矩阵是按行存储的。请注意,在这两种情况下,都假定使用矩阵索引约定,即对于 Fortran 和 C,第一个索引都是行。请注意,此约定意味着索引约定是不变的,并且数据顺序会随之改变以保持不变。
但这并不是看待它的唯一方式。假设有存储在数据文件中的大型二维数组(图像或矩阵)。假设数据是按行而不是按列存储的。如果我们要保留我们的索引约定(无论是矩阵还是图像),这意味着根据我们使用的语言,如果数据被读入内存,我们可能被迫重新排序数据以保留我们的索引约定。例如,如果我们按行顺序读取数据到内存而不进行重新排序,它将符合 C 语言的矩阵索引约定,但不符合 Fortran。反之,它将符合 Fortran 的图像索引约定,但不符合 C。对于 C 语言,如果使用按行顺序存储的数据,并且希望保留图像索引约定,则在读入内存时必须重新排序数据。
最终,你为 Fortran 或 C 所做的事情取决于哪一个更重要,是不重新排序数据还是保留索引约定。对于大型图像,重新排序数据可能代价高昂,因此通常会反转索引约定以避免这种情况。
NumPy 的情况使这个问题变得更加复杂。NumPy 数组的内部机制足够灵活,可以接受任何索引顺序。通过简单地操作数组的内部步长信息,就可以重新排列索引,而无需重新排序数据。NumPy 将知道如何将新的索引顺序映射到数据,而无需移动数据。
既然如此,为什么不选择与你期望最匹配的索引顺序呢?特别是,为什么不定义按行排序的图像使用图像约定呢?(这有时被称为 Fortran 约定与 C 约定,因此 NumPy 中数组排序有 ‘C’ 和 ‘FORTRAN’ 选项。)这样做的一个缺点是可能带来性能损失。通常,数据是顺序访问的,无论是隐式地在数组操作中,还是显式地通过遍历图像的行。当这样做时,数据将以非最优顺序访问。当第一个索引递增时,实际发生的是内存中相距很远的元素被顺序访问,通常导致糟糕的内存访问速度。例如,对于一个二维图像 im
,定义为 im[0, 10]
表示 x = 0
,y = 10
处的值。为了与通常的 Python 行为保持一致,那么 im[0]
将表示 x = 0
处的一列。然而,由于数据是按行存储的,该数据将分散在整个数组中。尽管 NumPy 的索引具有灵活性,但它无法真正掩盖以下事实:基本操作由于数据顺序而效率低下,或者获取连续的子数组仍然很尴尬(例如,im[:, 0]
用于第一行,而 im[0]
)。因此,不能使用诸如 for row in im
这样的习语;for col in im
确实有效,但不会产生连续的列数据。
事实证明,NumPy 在处理 ufuncs 时足够智能,能够确定内存中变化最快的索引,并将其用于最内层循环。因此,对于 ufuncs 而言,在大多数情况下,这两种方法都没有大的内在优势。另一方面,对 FORTRAN 顺序的数组使用 ndarray.flat
将导致非最优的内存访问,因为扁平化数组(实际上是迭代器)中的相邻元素在内存中不是连续的。
事实上,Python 在列表和其他序列上的索引自然会导致从外到内的排序(第一个索引获取最大的分组,其次是第二大的,最后一个获取最小的元素)。由于图像数据通常按行存储,这对应于行内的位置是最后一个被索引的项。
如果你确实想使用 Fortran 顺序,请注意有两种方法可以考虑:1) 接受第一个索引在内存中不是变化最快的,并让你的所有 I/O 例程在数据从内存到磁盘或反向传输时重新排序数据,或者使用 NumPy 的机制将第一个索引映射到变化最快的数据。如果可能,我们建议前者。后者的缺点是 NumPy 的许多函数会生成不带 Fortran 顺序的数组,除非你小心地使用 order
关键字。这样做会非常不方便。
否则,我们建议你只需学习在访问数组元素时反转通常的索引顺序。诚然,这与直觉相悖,但它更符合 Python 语义和数据的自然顺序。