超越基础#

发现之旅不在于寻找新的风景,而在于拥有
新的视角。
马塞尔·普鲁斯特
发现是看到每个人都看到的东西,并思考没有人
思考过的事情。
阿尔伯特·圣捷尔吉

迭代数组中的元素#

基本迭代#

一个常见的算法要求是能够遍历多维数组中的所有元素。数组迭代器对象使得以一种通用的方式轻松完成此操作,该方式适用于任何维度的数组。当然,如果您知道将要使用的维度数量,那么您始终可以编写嵌套的 for 循环来完成迭代。但是,如果您想编写适用于任意维度数量的代码,则可以使用数组迭代器。当访问数组的 .flat 属性时,会返回一个数组迭代器对象。

基本用法是调用 PyArray_IterNew ( array ),其中 array 是 ndarray 对象(或其子类之一)。返回的对象是一个数组迭代器对象(与 ndarray 的 .flat 属性返回的对象相同)。该对象通常被强制转换为 PyArrayIterObject*,以便可以访问其成员。唯一需要的成员是 iter->size,其中包含数组的总大小, iter->index,其中包含数组的当前一维索引,以及 iter->dataptr,它是指向数组当前元素数据的指针。有时访问 iter->ao 也很有用,它是指向底层 ndarray 对象的指针。

在处理完数组当前元素的数据后,可以使用宏 PyArray_ITER_NEXT ( iter ) 获取数组的下一个元素。迭代始终以 C 风格的连续方式进行(最后一个索引变化最快)。 PyArray_ITER_GOTO ( iter, destination ) 可用于跳转到数组中的特定点,其中 destination 是 npy_intp 数据类型的数组,其空间足以处理至少底层数组的维度数。有时使用 PyArray_ITER_GOTO1D ( iter, index ) 也很有用,它将跳转到 index 值给出的一维索引。但是,最常见的用法如下例所示。

PyObject *obj; /* assumed to be some ndarray object */
PyArrayIterObject *iter;
...
iter = (PyArrayIterObject *)PyArray_IterNew(obj);
if (iter == NULL) goto fail;   /* Assume fail has clean-up code */
while (iter->index < iter->size) {
    /* do something with the data at it->dataptr */
    PyArray_ITER_NEXT(it);
}
...

您还可以使用 PyArrayIter_Check ( obj ) 来确保您拥有一个迭代器对象,并使用 PyArray_ITER_RESET ( iter ) 将迭代器对象重置回数组的开头。

此时应该强调的是,如果您的数组已经是连续的,则可能不需要数组迭代器(使用数组迭代器会起作用,但会比您可以编写的最快代码慢)。数组迭代器的主要目的是封装对具有任意步幅的 N 维数组的迭代。它们在 NumPy 源代码本身的许多地方使用。如果您已经知道您的数组是连续的(Fortran 或 C),那么只需将元素大小添加到正在运行的指针变量即可非常有效地遍历数组。换句话说,对于连续的情况,这样的代码可能会更快(假设是双精度)。

npy_intp size;
double *dptr;  /* could make this any variable type */
size = PyArray_SIZE(obj);
dptr = PyArray_DATA(obj);
while(size--) {
   /* do something with the data at dptr */
   dptr++;
}

迭代除一个轴之外的所有轴#

一个常见的算法是循环遍历数组的所有元素,并通过发出函数调用对每个元素执行某些函数。由于函数调用可能很耗时,因此加快这种算法的一种方法是编写函数使其接受一个数据向量,然后编写迭代,以便一次为一整个维度的数据执行函数调用。这增加了每次函数调用所完成的工作量,从而将函数调用开销减少到总时间的一小部分。即使在没有函数调用情况下执行循环内部,在具有最大元素数量的维度上执行内部循环也可能是有利的,这样可以利用微处理器上可用的加速功能,这些微处理器使用流水线来增强基本操作。

PyArray_IterAllButAxis ( array, &dim ) 构造一个迭代器对象,该对象经过修改,使其不会迭代由 dim 指示的维度。对此迭代器对象的唯一限制是不能使用 PyArray_ITER_GOTO1D ( it, ind ) 宏(因此,如果您将此对象传递回 Python,则平面索引也无法工作——因此您不应该这样做)。请注意,从此例程返回的对象通常仍然强制转换为 PyArrayIterObject *。所做的只是修改返回的迭代器的步幅和维度,以模拟迭代 array[…,0,…],其中 0 放置在 \(\textrm{dim}^{\textrm{th}}\) 维度上。如果 dim 为负数,则会找到并使用具有最大轴的维度。

迭代多个数组#

通常,需要同时迭代多个数组。通用函数是这种行为的一个示例。如果您只想迭代形状相同的数组,那么只需创建多个迭代器对象就是标准过程。例如,以下代码迭代两个假设形状和大小相同的数组(实际上 obj1 只需要具有至少与 obj2 一样多的元素总数)

/* It is already assumed that obj1 and obj2
   are ndarrays of the same shape and size.
*/
iter1 = (PyArrayIterObject *)PyArray_IterNew(obj1);
if (iter1 == NULL) goto fail;
iter2 = (PyArrayIterObject *)PyArray_IterNew(obj2);
if (iter2 == NULL) goto fail;  /* assume iter1 is DECREF'd at fail */
while (iter2->index < iter2->size)  {
    /* process with iter1->dataptr and iter2->dataptr */
    PyArray_ITER_NEXT(iter1);
    PyArray_ITER_NEXT(iter2);
}

在多个数组上广播#

当多个数组参与运算时,你可能希望使用与数学运算( ufuncs)相同的广播规则。这可以使用 PyArrayMultiIterObject 轻松完成。这个对象是从 Python 命令 numpy.broadcast 返回的,并且在 C 中使用起来几乎一样简单。函数 PyArray_MultiIterNew ( n, ... ) 被使用(其中 n 个输入对象代替 ... )。输入对象可以是数组,也可以是任何可以转换为数组的对象。返回一个指向 PyArrayMultiIterObject 的指针。广播已经完成,它调整了迭代器,使得只需对每个输入调用 PyArray_ITER_NEXT 即可前进到每个数组中的下一个元素。这个递增由宏 PyArray_MultiIter_NEXT ( obj ) 自动执行(它可以将多重迭代器 obj 处理为 PyArrayMultiIterObject*PyObject*)。使用 PyArray_MultiIter_DATA ( obj, i ) 可以获取输入编号为 i 的数据。下面是一个使用此功能的示例。

mobj = PyArray_MultiIterNew(2, obj1, obj2);
size = mobj->size;
while(size--) {
    ptr1 = PyArray_MultiIter_DATA(mobj, 0);
    ptr2 = PyArray_MultiIter_DATA(mobj, 1);
    /* code using contents of ptr1 and ptr2 */
    PyArray_MultiIter_NEXT(mobj);
}

函数 PyArray_RemoveSmallest ( multi ) 可以用来获取一个多重迭代器对象,并调整所有迭代器,使其不遍历最大维度(它使该维度的大小为 1)。循环遍历并使用指针的代码很可能也需要每个迭代器的 strides 数据。此信息存储在 multi->iters[i]->strides 中。

NumPy 源代码中有几个使用多重迭代器的示例,因为它使编写 N 维广播代码非常简单。浏览源代码以获取更多示例。

用户定义的数据类型#

NumPy 附带 24 个内置数据类型。虽然这涵盖了大多数可能的用例,但用户可能会需要额外的数据类型。NumPy 系统提供了一些添加额外数据类型的支持。这种额外的数据类型行为与常规数据类型非常相似,只是 ufunc 必须单独注册 1 维循环才能处理它。此外,检查其他数据类型是否可以“安全地”强制转换为此新类型或从该新类型强制转换,将始终返回“可以转换”,除非你还注册了新数据类型可以强制转换为哪些类型以及从哪些类型强制转换。

NumPy 源代码在其测试套件中包含自定义数据类型的示例。源代码目录 numpy/_core/src/umath/ 中的文件 _rational_tests.c.src 包含数据类型的实现,该数据类型将有理数表示为两个 32 位整数的比率。

添加新数据类型#

要开始使用新数据类型,你需要首先定义一个新的 Python 类型来保存新数据类型的标量。如果你的新类型具有二进制兼容的布局,则可以接受从数组标量之一继承。这将允许你的新数据类型具有数组标量的方法和属性。新数据类型必须具有固定的内存大小(如果要定义需要灵活表示的数据类型,例如可变精度数字,则使用指向对象的指针作为数据类型)。新 Python 类型的对象结构的内存布局必须是 PyObject_HEAD,后跟数据类型所需的固定大小的内存。例如,新 Python 类型的合适结构是

typedef struct {
   PyObject_HEAD;
   some_data_type obval;
   /* the name can be whatever you want */
} PySomeDataTypeObject;

在你定义了新的 Python 类型对象后,你必须定义一个新的 PyArray_Descr 结构,其 typeobject 成员将包含指向你刚刚定义的数据类型的指针。此外,必须定义 “.f” 成员中的必需函数:nonzero、copyswap、copyswapn、setitem、getitem 和 cast。但是,你在 “.f” 成员中定义的函数越多,新数据类型就越有用。将未使用的函数初始化为 NULL 非常重要。这可以使用 PyArray_InitArrFuncs (f) 来实现。

一旦创建了一个新的 PyArray_Descr 结构并填充了所需的信息和有用的函数,你调用 PyArray_RegisterDataType (new_descr)。此调用的返回值是一个整数,为你提供一个唯一的 type_number,用于指定你的数据类型。此类型编号应由你的模块存储并提供,以便其他模块可以使用它来识别你的数据类型。

请注意,此 API 本质上是线程不安全的。有关 NumPy 中线程安全性的更多详细信息,请参阅 thread_safety

注册强制转换函数#

你可能希望允许将内置(和其他用户定义的)数据类型自动强制转换为你的数据类型。为了实现这一点,你必须向你要从中进行强制转换的数据类型注册强制转换函数。这需要为你要支持的每个转换编写底层强制转换函数,然后使用数据类型描述符注册这些函数。底层强制转换函数具有以下签名。

void castfunc(void *from, void *to, npy_intp n, void *fromarr, void *toarr)#

n 个元素从一种类型 from 强制转换为另一种类型 to。要强制转换的数据位于由 from 指向的连续、正确交换和对齐的内存块中。要强制转换到的缓冲区也是连续、正确交换和对齐的。fromarr 和 toarr 参数应仅用于灵活元素大小的数组(字符串、unicode、void)。

一个示例 castfunc 是

static void
double_to_float(double *from, float* to, npy_intp n,
                void* ignore1, void* ignore2) {
    while (n--) {
          (*to++) = (double) *(from++);
    }
}

然后可以使用以下代码将其注册以将双精度浮点数转换为单精度浮点数

doub = PyArray_DescrFromType(NPY_DOUBLE);
PyArray_RegisterCastFunc(doub, NPY_FLOAT,
     (PyArray_VectorUnaryFunc *)double_to_float);
Py_DECREF(doub);

注册强制规则#

默认情况下,不会假定所有用户定义的数据类型都可以安全地强制转换为任何内置数据类型。此外,也不会假定内置数据类型可以安全地强制转换为用户定义的数据类型。这种情况限制了用户定义的数据类型参与 ufunc 使用的强制转换系统以及 NumPy 中发生自动强制转换的其他时间的能力。可以通过将数据类型注册为可以从特定数据类型对象安全强制转换来更改此情况。函数 PyArray_RegisterCanCast (from_descr, totype_number, scalarkind) 应用于指定数据类型对象 from_descr 可以强制转换为具有类型编号 totype_number 的数据类型。如果你不尝试更改标量强制规则,则使用 NPY_NOSCALAR 作为 scalarkind 参数。

如果你希望你的新数据类型也能够参与标量强制规则,则需要在数据类型对象的 “.f” 成员中指定 scalarkind 函数,以返回新数据类型应视为的标量的种类(标量的值可用于该函数)。然后,你可以为可能从用户定义的数据类型返回的每个标量种类单独注册可以强制转换的数据类型。如果你不注册标量强制处理,则你的所有用户定义的数据类型都将被视为 NPY_NOSCALAR

注册 ufunc 循环#

您可能还希望为您的数据类型注册底层 ufunc 循环,以便您的数据类型的 ndarray 可以无缝地应用数学运算。使用完全相同的 arg_types 签名注册新循环会静默地替换该数据类型先前注册的任何循环。

在您可以为 ufunc 注册 1 维循环之前,必须先创建 ufunc。然后,您需要使用循环所需的信息调用 PyUFunc_RegisterLoopForType(…)。如果过程成功,此函数的返回值是 0,如果未成功,则返回 -1 并设置错误条件。

在 C 中对 ndarray 进行子类型化#

自 Python 2.2 以来,一直潜伏在 Python 中的一个较少使用的特性是在 C 中对类型进行子类化的能力。此功能是将 NumPy 基于 Numeric 代码库(已经在 C 中)的重要原因之一。C 中的子类型在内存管理方面具有更大的灵活性。即使您仅对如何为 Python 创建新类型有基本的了解,C 中的子类型化也并不困难。虽然从单个父类型进行子类型化最容易,但也可能从多个父类型进行子类型化。C 中的多重继承通常不如 Python 中的多重继承有用,因为 Python 子类型的一个限制是它们具有二进制兼容的内存布局。也许出于这个原因,从单个父类型进行子类型化会稍微容易一些。

与 Python 对象对应的所有 C 结构都必须以 PyObject_HEAD(或 PyObject_VAR_HEAD)开头。同样,任何子类型都必须具有一个 C 结构,该结构的开头与父类型(或在多重继承的情况下,所有父类型)的内存布局完全相同。这样做的原因是 Python 可能会尝试访问子类型结构的成员,就像它具有父结构一样(,它会将给定指针转换为指向父结构的指针,然后取消引用其成员之一)。如果内存布局不兼容,则此尝试将导致不可预测的行为(最终导致内存违规和程序崩溃)。

PyObject_HEAD 中的元素之一是指向类型对象结构的指针。通过创建新的类型对象结构并用函数和指针填充它以描述该类型的所需行为,来创建新的 Python 类型。通常,还会创建一个新的 C 结构来包含该类型的每个对象所需的实例特定信息。例如,&PyArray_Type 是指向 ndarray 的类型对象表的指针,而 PyArrayObject* 变量是指向 ndarray 的特定实例的指针(ndarray 结构的成员之一反过来是指向类型对象表 &PyArray_Type 的指针)。最后,必须为每个新的 Python 类型调用 PyType_Ready (<pointer_to_type_object>)。

创建子类型#

要创建子类型,必须遵循类似的过程,但只有不同的行为才需要在类型对象结构中添加新条目。所有其他条目都可以为 NULL,并且将由 PyType_Ready 用来自父类型(s) 的适当函数填充。特别是,要在 C 中创建子类型,请执行以下步骤

  1. 如果需要,创建一个新的 C 结构来处理类型的每个实例。典型的 C 结构如下所示

    typedef _new_struct {
        PyArrayObject base;
        /* new things here */
    } NewArrayObject;
    

    请注意,完整的 PyArrayObject 用作第一个条目,以确保新类型实例的二进制布局与 PyArrayObject 完全相同。

  2. 在新的 Python 类型对象结构中填充指向新函数的指针,这些函数将覆盖默认行为,同时保留任何应保持不变的函数(或 NULL)。tp_name 元素应该不同。

  3. 用指向(主要)父类型对象的指针填充新类型对象结构的 tp_base 成员。对于多重继承,还用包含所有父对象的元组填充 tp_bases 成员,其顺序应按用于定义继承的顺序。请记住,为了使多重继承正常工作,所有父类型都必须具有相同的 C 结构。

  4. 调用 PyType_Ready (<pointer_to_new_type>)。如果此函数返回负数,则表示发生故障,并且该类型未初始化。否则,该类型已准备好使用。通常,将对新类型的引用放入模块字典中非常重要,这样可以从 Python 中访问它。

可以通过阅读 PEP 253(可在 https://pythonlang.cn/dev/peps/pep-0253 上找到)了解有关在 C 中创建子类型的更多信息。

ndarray 子类型的特定功能#

数组使用一些特殊方法和属性,以便于子类型与基本 ndarray 类型之间的互操作。

__array_finalize__ 方法#

ndarray.__array_finalize__#

ndarray 的几个数组创建函数允许指定要创建的特定子类型。这允许在许多例程中无缝处理子类型。然而,当以这种方式创建子类型时,既不会调用 __new__ 方法,也不会调用 __init__ 方法。相反,会分配子类型并填充适当的实例结构成员。最后,在对象字典中查找 __array_finalize__ 属性。如果它存在且不为 None,则它可以是包含指向 PyArray_FinalizeFunc 的指针的 PyCapsule,也可以是接受单个参数(可以为 None)的方法

如果 __array_finalize__ 属性是一个 PyCapsule,则该指针必须是指向具有以下签名的函数的指针

(int) (PyArrayObject *, PyObject *)

第一个参数是新创建的子类型。第二个参数(如果不是 NULL)是“父”数组(如果该数组是使用切片或存在明显可区分的父级的其他操作创建的)。此例程可以执行任何它想做的事情。它应该在错误时返回 -1,否则返回 0。

如果 __array_finalize__ 属性既不是 None 也不是 PyCapsule,则它必须是接受父数组作为参数(如果没有父数组,则可以为 None)并且不返回任何内容的 Python 方法。此方法中的错误将被捕获和处理。

__array_priority__ 属性#

ndarray.__array_priority__#

此属性允许简单但灵活地确定当出现涉及两个或多个子类型的操作时,应将哪个子类型视为“主要”子类型。在使用不同子类型的操作中,具有最大 __array_priority__ 属性的子类型将决定输出的子类型。如果两个子类型具有相同的 __array_priority__,则第一个参数的子类型将决定输出。默认 __array_priority__ 属性为基本 ndarray 类型返回 0.0,为子类型返回 1.0。此属性也可以由不是 ndarray 子类型的对象定义,并且可用于确定应为返回输出调用哪个 __array_wrap__ 方法。

__array_wrap__ 方法#

ndarray.__array_wrap__#

任何类或类型都可以定义此方法,该方法应接受一个 ndarray 参数并返回该类型的实例。它可以被看作是 __array__ 方法的反面。此方法被 ufunc(和其他 NumPy 函数)使用,以允许其他对象传递。对于 Python >2.4,它还可以用来编写一个装饰器,该装饰器将仅适用于 ndarray 的函数转换为适用于任何具有 __array____array_wrap__ 方法的类型的函数。