NumPy 数组的内部组织结构#
了解 NumPy 数组的底层处理方式有助于更好地理解 NumPy。本节不会深入细节。希望了解完整细节的读者,请参考 Travis Oliphant 的书籍 NumPy 指南。
NumPy 数组包含两个主要组件:原始数组数据(以下称为数据缓冲区)和有关原始数组数据的信息。数据缓冲区通常是人们在 C 或 Fortran 中认为的数组,是一个连续的(且固定的)内存块,包含固定大小的数据项。NumPy 还包含大量描述如何解释数据缓冲区中的数据的数据。这些额外信息包含(除其他外):
基本数据元素的字节大小。
数据在数据缓冲区中的起始位置(相对于数据缓冲区开头的偏移量)。
维数以及每个维的大小。
每个维的元素间距(步幅)。它不一定是元素大小的倍数。
数据的字节序(可能不是本机字节序)。
缓冲区是否为只读。
关于基本数据元素解释的信息(通过
dtype
对象)。基本数据元素可以像整数或浮点数一样简单,也可以是复合对象(例如 类似结构体)、固定字符字段或 Python 对象指针。
这种安排允许非常灵活地使用数组。它允许的一件事是对元数据进行简单的更改以更改对数组缓冲区的解释。更改数组的字节序是一个简单的更改,无需重新排列数据。可以很容易地更改数组的形状,而无需更改数据缓冲区中的任何内容或进行任何数据复制。
它还使得能够创建新的数组元数据对象,该对象使用相同的数据缓冲区来创建对该数据缓冲区的新的视图,该视图具有对缓冲区的不同解释(例如,不同的形状、偏移量、字节序、步幅等),但共享相同的数据字节。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
确实有效,但不会产生连续的列数据。
事实证明,在处理ufuncs时,NumPy 足够智能,可以确定哪个索引在内存中变化最快,并将其用于最内层循环。因此,对于 ufuncs,在大多数情况下,两种方法都没有很大的内在优势。另一方面,使用ndarray.flat
和 FORTRAN 顺序数组将导致非最佳内存访问,因为扁平化数组(实际上是迭代器)中的相邻元素在内存中不连续。
事实上,Python 对列表和其他序列的索引自然会导致从外到内的排序(第一个索引获得最大的分组,下一个获得次大的分组,最后一个获得最小的元素)。由于图像数据通常按行存储,因此这对应于行内位置作为最后一个索引项。
如果您确实想要使用 Fortran 顺序,请意识到需要考虑两种方法:1)接受第一个索引在内存中并非变化最快的事实,并让所有 I/O 例程在从内存到磁盘或反之亦然时重新排序您的数据,或者使用 NumPy 的机制将第一个索引映射到变化最快的数 据。如果可能,我们建议采用前者。后者的缺点是,除非您小心使用order
关键字,否则 NumPy 的许多函数将产生没有 Fortran 顺序的数组。这样做将非常不方便。
否则,我们建议您学习在访问数组元素时反转通常的索引顺序。诚然,这与常规做法相悖,但它更符合 Python 语义和数据的自然顺序。