超越基础#

发现之旅不在于寻求新风景,而在于拥有
新的眼光。
马塞尔·普鲁斯特
发现,是看到别人都看到的东西,却思考别人都没有想到的东西。
别人都看到的东西,却思考别人都没有想到的东西。
阿尔伯特·圣-捷尔吉

迭代数组中的元素#

基本迭代#

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

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

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

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++;
}

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

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

The 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* 处理)。输入号 i 的数据可通过 PyArray_MultiIter_DATA ( obj, 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)。循环遍历使用指针的代码很可能还需要每个迭代器的步长数据。此信息存储在 multi->iters[i]->strides 中。

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

用户定义数据类型#

NumPy 自带 24 种内置数据类型。尽管这涵盖了绝大多数可能的用例,但用户可能需要额外的自定义数据类型。NumPy 系统为添加额外的数据类型提供了一些支持。这种额外的数据类型将表现得与常规数据类型非常相似,只是 ufuncs 必须注册 1-d 循环来单独处理它。此外,除非您也注册了您的新数据类型可以安全地转换到哪些类型以及从哪些类型转换,否则检查其他数据类型是否可以“安全”地转换为此新类型或从其转换时,将始终返回“可以转换”。

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);

注册强制转换规则#

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

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

注册ufunc循环#

您可能还希望为您的数据类型注册低级 ufunc 循环,以便您的数据类型的 ndarray 可以无缝地应用数学运算。注册一个具有完全相同 arg_types 签名的循环,将悄然替换任何先前为该数据类型注册的循环。

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

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

自 Python 2.2 以来,有一个不常用但一直存在的特性,那就是在 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 从父类型(或多个父类型)中填充相应的函数。具体来说,要在 C 中创建子类型,请遵循以下步骤

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

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

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

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

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

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

有关在 C 中创建子类型的更多信息,请阅读 PEP 253(可在 https://pythonlang.cn/dev/peps/pep-0253 获取)。

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,那么它必须是一个 Python 方法,该方法接受父数组作为参数(如果没有父数组,则可以是 None),并且不返回任何内容。此方法中的错误将被捕获并处理。

__array_priority__ 属性#

ndarray.__array_priority__#

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

__array_wrap__ 方法#

ndarray.__array_wrap__#

任何类或类型都可以定义此方法,该方法应接受一个 ndarray 参数并返回该类型的一个实例。它可以被视为 __array__ 方法的对立面。此方法被 ufuncs(和其他 NumPy 函数)用于允许其他对象通过。对于 Python >2.4,它也可以用于编写一个装饰器,将仅适用于 ndarrays 的函数转换为适用于任何具有 __array____array_wrap__ 方法的类型。