NumPy 数组的内部组织#

了解 NumPy 数组在底层的处理方式有助于更好地理解 NumPy。本节将对此进行简要说明。更多详细信息请参阅 Travis Oliphant 的著作《Guide to NumPy》。

NumPy 数组由两个主要部分组成:原始数组数据(以下称为数据缓冲区)和关于该原始数组数据的信息。数据缓冲区通常就是人们在 C 或 Fortran 中所认为的数组,即一块连续(且固定)的内存块,其中包含固定大小的数据项。NumPy 还包含大量用于描述如何解释数据缓冲区中数据的信息。这些额外信息包含(除其他事项外):

  1. 基本数据元素的大小(以字节为单位)。

  2. 数据在数据缓冲区内的起始位置(相对于数据缓冲区开头的偏移量)。

  3. 维度的数量以及每个维度的大小。

  4. 每个维度中元素之间的间隔(步长)。它不必是元素大小的倍数。

  5. 数据的字节序(可能不是原生字节序)。

  6. 缓冲区是否为只读。

  7. 关于基本数据元素解释的信息(通过 dtype 对象)。基本数据元素可以像 int 或 float 一样简单,也可以是复合对象(例如 结构体式)、固定字符字段或 Python 对象指针。

  8. 数组应被解释为 C 顺序还是 Fortran 顺序

这种结构使得数组的使用非常灵活。它允许的一项功能是通过简单地更改元数据来改变对数组缓冲区的解释。更改数组的字节序是一个简单的操作,无需重新排列数据。可以非常轻松地更改数组的 形状,而无需更改数据缓冲区中的任何内容或进行任何数据拷贝。

此外,还可以创建一个新的数组元数据对象,它使用相同的数据缓冲区来创建该缓冲区的一个新 视图,该视图对缓冲区的解释不同(例如,不同的形状、偏移量、字节序、步长等),但共享相同的数据字节。NumPy 中的许多操作(如 切片)正是这样做的。其他操作,如转置,不会移动数组中的数据元素,而是更改关于形状和步长的信息,从而改变数组的索引方式,但数组中的数据不会移动。

通常,这些具有相同数据缓冲区的新版数组元数据是数据缓冲区的新视图。虽然存在不同的 ndarray 对象,但它们使用相同的数据缓冲区。这就是为什么如果真的想要制作数据缓冲区的一个新的、独立的副本,必须通过使用 copy 方法来强制拷贝的原因。

数组的新视图意味着数据缓冲区的对象引用计数会增加。仅仅销毁原始数组对象并不会移除数据缓冲区,如果其他视图仍然存在的话。

多维数组索引顺序问题#

另请参阅

ndarray 的索引

索引多维数组的正确方法是什么?在您断言索引多维数组的“唯一真理”之前,值得了解为什么这是一个令人困惑的问题。本节将详细解释 NumPy 索引的工作原理,以及我们为何为图像采用这种惯例,以及何时采用其他惯例可能是恰当的。

首先要理解的是,索引二维数组有两种相互冲突的惯例。矩阵符号使用第一个索引表示选中的行,第二个索引表示选中的列。这与面向几何的图像惯例相反,在图像惯例中,人们通常认为第一个索引代表 x 位置(即列),第二个索引代表 y 位置(即行)。仅仅这一点就是造成许多困惑的根源;面向矩阵的用户和面向图像的用户对索引有着不同的期望。

第二个要理解的问题是索引如何对应于数组在内存中的存储顺序。在 Fortran 中,当遍历二维数组的元素时,第一个索引是变化最快的索引。如果您采用矩阵索引惯例,这意味着矩阵是按列存储的(因为第一个索引变化时移动到下一行)。因此,Fortran 被认为是一种“列优先”(Column-major)语言。C 语言的惯例恰恰相反。在 C 语言中,遍历数组时最后一个索引变化最快。因此 C 语言是一种“行优先”(Row-major)语言。矩阵是按行存储的。请注意,这两种情况都假定了使用矩阵索引惯例,即对于 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 的机制将第一个索引映射到变化最快的数据上。如果可能,我们建议前者。后者的缺点是,除非您小心使用 order 关键字,否则许多 NumPy 函数产生的数组将不具有 Fortran 顺序。这样做会非常不方便。

否则,我们建议简单地学会在访问数组元素时反转通常的索引顺序。诚然,这违背了直觉,但它更符合 Python 的语义和数据的自然顺序。