NumPy 数组的内部组织#
了解一些 NumPy 数组在底层是如何处理的,有助于更好地理解 NumPy.本节提供简要说明.更多详情请参见 Travis Oliphant 的著作 Guide to NumPy .
NumPy 数组由两个主要组成部分构成:原始数组数据(以下简称为数据缓冲区)和关于原始数组数据的信息.数据缓冲区通常是人们在 C 或 Fortran 中所认为的数组,即一个 contiguous (且固定) 的内存块,其中包含固定大小的数据项.NumPy 还包含大量数据,用于描述如何解释数据缓冲区中的数据.这些额外信息包含(除其他外):
基本数据元素的大小(以字节为单位).
数据在数据缓冲区中的起始位置(相对于数据缓冲区起点的偏移量).
dimensions 的数量和每个维度的大小.
每个维度中元素之间的间隔( stride ). 这不必是元素大小的倍数.
数据的字节顺序(可能不是本机字节顺序).
缓冲区是否为只读.
关于基本数据元素解释的信息(通过
dtype对象). 基本数据元素可以像 int 或 float 一样简单,也可以是复合对象(例如, struct-like ),固定字符字段或 Python 对象指针.数组是被解释为 C-order 还是 Fortran-order .
这种安排允许非常灵活地使用数组.它允许的一件事是对元数据进行简单的更改,以更改数组缓冲区的解释.更改数组的字节顺序是一个简单的更改,不涉及数据的重新排列.可以非常容易地更改数组的 shape ,而无需更改数据缓冲区中的任何内容或任何数据复制.
其他可能的事情是,可以创建一个新的数组元数据对象,该对象使用相同的数据缓冲区来创建一个新的 view ,该 slicing 对该数据缓冲区具有不同的解释(例如,不同的形状,偏移量,字节顺序,步幅等),但共享相同的数据字节.NumPy 中的许多操作都这样做,例如 slicing .其他操作(如转置)不会在数组中移动数据元素,而是更改关于形状和步幅的信息,以便更改数组的索引,但数组中的数据不会移动.
通常,这些数组元数据的新版本但相同的数据缓冲区是对数据缓冲区的新视图.存在一个不同的 ndarray 对象,但它使用相同的数据缓冲区.这就是为什么如果真的想创建数据缓冲区的一个新的,独立的副本,就必须通过使用 copy 方法来强制复制.
数组的新视图意味着数据缓冲区的对象引用计数增加.如果数据缓冲区的其他视图仍然存在,则简单地删除原始数组对象不会删除数据缓冲区.
多维数组索引顺序问题#
对多维数组进行索引的正确方法是什么?在对索引多维数组的唯一正确方法下结论之前,了解为什么这是一个令人困惑的问题是有意义的.本节将尝试详细解释 NumPy 索引的工作原理,以及为什么我们采用我们对图像采用的约定,以及何时可能适合采用其他约定.
首先要理解的是,对于二维数组的索引有两种相互冲突的约定.矩阵表示法使用第一个索引来指示正在选择哪一行,使用第二个索引来指示正在选择哪一列.这与面向几何的图像约定相反,在图像约定中,人们通常认为第一个索引表示 x 位置(即列),第二个索引表示 y 位置(即行).仅此一点就是造成许多困惑的根源;面向矩阵的用户和面向图像的用户在索引方面期望的是两件不同的事情.
需要理解的第二个问题是索引如何对应于数组在内存中的存储顺序.在 Fortran 中,当在内存中移动二维数组的元素时,第一个索引是变化最快的索引.如果采用矩阵约定进行索引,这意味着矩阵一次存储一列(因为第一个索引在改变时移动到下一行).因此,Fortran 被认为是一种列优先 (Column-major) 的语言.C 语言的约定恰恰相反.在 C 语言中,当在内存中移动数组时,最后一个索引变化最快.因此,C 语言是一种行优先 (Row-major) 的语言.矩阵按行存储.请注意,在这两种情况下,它都假定使用了矩阵约定进行索引,即,对于 Fortran 和 C 语言,第一个索引都是行. 请注意,此约定意味着索引约定是不变的,并且数据顺序会改变以保持不变.
但这并不是看待它的唯一方式.假设一个大的二维数组(图像或矩阵)存储在数据文件中.假设数据按行而不是按列存储.如果要保留索引约定(无论是矩阵还是图像),这意味着,根据我们使用的语言,如果将数据读入内存以保留索引约定,我们可能被迫对数据进行重新排序.例如,如果我们在不重新排序的情况下将按行排序的数据读入内存,它将匹配 C 语言的矩阵索引约定,但不匹配 Fortran 的索引约定.相反,它将匹配 Fortran 的图像索引约定,但不匹配 C 语言的图像索引约定.对于 C 语言,如果使用的是按行顺序存储的数据,并且想要保留图像索引约定,则在读入内存时必须对数据进行重新排序.
最终,对于 Fortran 或 C 语言,你所做的事情取决于哪个更重要,是不重新排序数据还是保留索引约定.对于大型图像,重新排序数据的成本可能很高,并且通常会反转索引约定以避免这种情况.
NumPy 的情况使这个问题变得更加复杂.NumPy 数组的内部机制足够灵活,可以接受任何索引顺序.可以通过操作数组的内部 stride 信息来简单地重新排序索引,而无需重新排序数据.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 的语义和数据的自然顺序.