NumPy 数组的内部组织#
为了更好地理解 NumPy,了解 NumPy 数组在底层是如何处理的会很有帮助。本节提供简要解释。更多细节请参阅 Travis Oliphant 的著作 Guide to NumPy。
NumPy 数组包含两个主要部分:原始数组数据(以后称为数据缓冲区)和关于原始数组数据的元信息。数据缓冲区通常是人们在 C 或 Fortran 中所说的数组,即一块连续的(且固定)的内存块,其中包含固定大小的数据项。NumPy 还包含大量描述如何解释数据缓冲区中数据的信息。这些额外信息包含(但不限于)
数据基本元素的字节大小。
数据缓冲区中数据的起始位置(相对于数据缓冲区开头的偏移量)。
数据的维度数量以及每个维度的大小。
每个维度元素之间的间隔(步幅)。这不一定是元素大小的倍数。
数据的字节顺序(可能不是本机字节顺序)。
缓冲区是否是只读的。
关于基本数据元素的解释信息(通过
dtype对象)。基本数据元素可以像简单的整数或浮点数一样,也可以是复合对象(例如,结构化数据类型)、固定字符字段或 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 的语义和数据的自然顺序。