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,在大多数情况下,这两种方法都没有很大的内在优势。另一方面,使用 ndarray.flat
和 FORTRAN 顺序数组会导致非最佳内存访问,因为扁平化数组(实际上是迭代器)中的相邻元素在内存中并不连续。
事实上,Python 中对列表和其他序列的索引自然会导致从外到内的排序(第一个索引获得最大的分组,第二个索引获得第二大的分组,最后一个索引获得最小的元素)。由于图像数据通常按行存储,因此这对应于行内的位置是最后一个被索引的项目。
如果你确实想使用 Fortran 顺序,请意识到有两种方法需要考虑:1)接受第一个索引不是内存中变化最快的索引,并让所有 I/O 例程在数据从内存到磁盘或反之亦然时重新排序数据,或者使用 NumPy 的机制将第一个索引映射到变化最快的 数据。我们建议在可能的情况下使用前者。后者的缺点是,除非你小心使用 order
关键字,否则 NumPy 的许多函数将生成没有 Fortran 顺序的数组。这样做将非常不方便。
否则,我们建议简单地学习在访问数组元素时反转通常的索引顺序。诚然,这违背了常规,但它更符合 Python 语义和数据的自然顺序。