广义通用函数 API#
存在一个普遍的需求,不仅需要对标量函数进行循环,还需要对向量(或数组)上的函数进行循环.这个概念在 NumPy 中通过泛化通用函数(ufuncs)来实现.在常规的 ufuncs 中,基本函数仅限于逐元素操作,而泛化版本(gufuncs)支持”子数组”对”子数组”的操作.Perl 向量库 PDL 提供了类似的功能,并且在以下内容中重新使用了其术语.
每个广义ufunc都有与之相关的信息,这些信息说明了输入的”核心”维度是什么,以及相应的输出维度(逐元素ufuncs的核心维度为零).所有参数的核心维度列表称为ufunc的”签名”.例如,ufunc numpy.add 的签名是 (),()->(),定义了两个标量输入和一个标量输出.
另一个例子是函数 inner1d(a, b) ,其签名是 (i),(i)->() .这将对每个输入的最后一个轴应用内积,但保持其余索引不变.例如,当 a 的形状是 (3, 5, N) ,而 b 的形状是 (5, N) 时,这将返回一个形状为 (3,5) 的输出.底层的基本函数被调用了 3 * 5 次.在签名中,我们为每个输入指定了一个核心维度 (i) ,并为输出指定了零个核心维度 () ,因为它接受两个 1-d 数组并返回一个标量.通过使用相同的名字 i ,我们指定这两个相应的维度应该具有相同的大小.
超出核心维度的维度被称为”循环”维度.在上述示例中,这对应于 (3, 5).
签名决定了如何将每个输入/输出数组的维度划分为核心维度和循环维度:
签名中的每个维度都与相应传入数组的维度匹配,从形状元组的末尾开始.这些是核心维度,它们必须存在于数组中,否则会引发错误.
在签名中分配给同一标签的核心维度(例如,``inner1d`` 的
(i),(i)->()中的i)必须具有完全匹配的大小,不会执行广播.核心维度从所有输入中移除,剩余的维度一起广播,定义了循环维度.
每个输出的形状由循环维度加上输出的核心维度决定
通常,输出中所有核心维度的大小将由具有相同标签的输入数组中的核心维度的大小决定.这不是一个要求,并且可以定义一个签名,其中标签在输出中首次出现,尽管在调用此类函数时必须采取一些预防措施.一个例子是函数 euclidean_pdist(a),其签名 (n,d)->(p),给定一个包含 n 个 d 维向量的数组,计算它们之间的所有唯一成对欧几里得距离.输出维度 p 因此必须等于 n * (n - 1) / 2,但默认情况下,调用者有责任传入大小正确的输出数组.如果无法从传入的输入或输出数组确定输出核心维度的大小,则会引发错误.这可以通过定义一个 PyUFunc_ProcessCoreDimsFunc 函数并将其分配给 PyUFuncObject 结构的 proces_core_dims_func 字段来更改.有关更多详细信息,请参见下文.
注意:在 NumPy 1.10.0 之前,检查较为宽松:缺失的核心维度会根据需要在形状前添加 1 来创建,具有相同标签的核心维度会一起广播,未确定的维度会以大小 1 创建.
定义#
- 基本函数
每个 ufunc 由一个执行对数组参数的最基本操作的基本函数组成(例如,加两个数是加两个数组中最基本的操作).ufunc 将基本函数多次应用于数组的不同部分.基本函数的输入/输出可以是向量;例如,``inner1d`` 的基本函数以两个向量作为输入.
- 签名
签名是一个描述 ufunc 基本函数的输入/输出维度的字符串.有关更多详细信息,请参见下面的章节.
- 核心维度
每个基本函数的输入/输出的维度由其核心维度定义(零核心维度对应于标量输入/输出).核心维度映射到输入/输出数组的最后维度.
- 维度名称
维度名称表示签名中的一个核心维度.不同的维度可以共享一个名称,表示它们的大小相同.
- 维度索引
维度索引是一个表示维度名称的整数.它根据每个名称在签名中首次出现的顺序枚举维度名称.
签名详情#
签名定义了输入和输出变量的”核心”维度,从而也定义了维度的收缩.签名由以下格式的字符串表示:
每个输入或输出数组的核心维度由括号中的维度名称列表表示,``(i_1,…,i_N)``;标量输入/输出用
()表示.可以用任何有效的Python变量名代替i_1,``i_2`` 等.不同参数的维度列表用
","分隔.输入/输出参数用"->"分隔.如果在多个位置使用相同的维度名称,这将强制相应维度的大小相同.
签名的正式语法如下:
<Signature> ::= <Input arguments> "->" <Output arguments>
<Input arguments> ::= <Argument list>
<Output arguments> ::= <Argument list>
<Argument list> ::= nil | <Argument> | <Argument> "," <Argument list>
<Argument> ::= "(" <Core dimension list> ")"
<Core dimension list> ::= nil | <Core dimension> |
<Core dimension> "," <Core dimension list>
<Core dimension> ::= <Dimension name> <Dimension modifier>
<Dimension name> ::= valid Python variable name | valid integer
<Dimension modifier> ::= nil | "?"
注意:
所有的引号都是为了清晰.
共享相同名称的未修改核心维度必须具有相同的大小.每个维度名称通常对应于基本函数实现中的一级循环.
空白字符会被忽略.
将整数作为维度名称会将该维度冻结为该值.
如果名称后缀带有”?”修饰符,则该维度仅在所有共享它的输入和输出上存在时才是核心维度;否则它将被忽略(并且基本函数将用大小为1的维度替换).
以下是一些签名的示例:
名字 |
signature |
常见用法 |
|---|---|---|
添加 |
|
二进制 ufunc |
sum1d |
|
reduction |
inner1d |
|
向量-向量乘法 |
matmat |
|
矩阵乘法 |
vecmat |
|
向量-矩阵乘法 |
matvec |
|
矩阵-向量乘法 |
matmul |
|
以上四者的组合 |
outer_inner |
|
在最后一个维度上进行内部操作,在倒数第二个维度上进行外部操作,并在其余维度上进行循环/广播. |
cross1d |
|
叉积,其中最后一个维度被冻结且必须是3 |
最后一个是冻结核心维度的一个实例,可以用来提高ufunc性能
实现基本功能的C-API#
当前接口保持不变,并且 PyUFunc_FromFuncAndData 仍然可以用来实现(专门的)ufuncs,由标量基本函数组成.
可以使用 PyUFunc_FromFuncAndDataAndSignature 来声明一个更通用的 ufunc.参数列表与 PyUFunc_FromFuncAndData 相同,额外的一个参数指定签名作为 C 字符串.
此外,回调函数的类型与之前相同,``void (*foo)(char **args, intp *dimensions, intp *steps, void *func)``.当调用时,``args`` 是一个长度为 nargs 的列表,包含所有输入/输出参数的数据.对于标量基本函数,``steps`` 的长度也是 nargs,表示用于参数的步幅.``dimensions`` 是一个指向单个整数的指针,定义要循环的轴的大小.
对于一个非平凡的签名,``dimensions`` 还将包含核心维度的大小,从第二个条目开始.每个唯一的维度名称只提供一个大小,并且大小按照签名中维度名称的第一次出现顺序给出.
steps 的前 nargs 个元素与标量 ufuncs 的相同.以下元素按顺序包含所有核心维度的所有参数的步幅.
例如,考虑一个带有签名 (i,j),(i)->() 的 ufunc.在这种情况下,``args`` 将包含三个指向输入/输出数组 a, b, c 数据的指针.此外,``dimensions`` 将是 [N, I, J],以定义循环的大小 N 和核心维度 i 和 j 的大小 I 和 J.最后,``steps`` 将是 [a_N, b_N, c_N, a_i, a_j, b_i],包含所有必要的步幅.
自定义核心维度大小处理#
可选的 PyUFunc_ProcessCoreDimsFunc 类型的函数,存储在 ufunc 的 process_core_dims_func 属性中,为 ufunc 的作者提供了一个”钩子”,用于处理传递给 ufunc 的数组的核心维度.这个”钩子”的两个主要用途是:
检查 ufunc 所需的核心维度的约束是否满足(如果不满足,则设置一个异常).
计算任何未由输入数组确定的输出核心维度的输出形状.
作为一个第一个用法的例子,考虑具有签名 (n)->(2) 的通用 ufunc minmax,它同时计算序列的最小值和最大值.它应该要求 n > 0,因为长度为 0 的序列的最小值和最大值是没有意义的.在这种情况下,ufunc 作者可能会这样定义函数:
int minmax_process_core_dims(PyUFuncObject ufunc, npy_intp *core_dim_sizes) { npy_intp n = core_dim_sizes[0]; if (n == 0) { PyExc_SetString("minmax requires the core dimension " "to be at least 1."); return -1; } return 0; }
在这种情况下,数组 core_dim_sizes 的长度将为 2.数组中的第二个值总是 2,因此函数不需要检查它.核心维度 n 存储在第一个元素中.如果函数发现 n 为 0,则设置一个异常并返回 -1.
“钩子”的第二个用途是在输出数组未由调用者提供且输出的一维或多维不是输入核心维时计算输出数组的大小.如果 ufunc 在 process_core_dims_func 属性上没有定义函数,未指定的输出核心维大小将导致异常被引发.通过 process_core_dims_func 提供的”钩子”,ufunc 的作者可以将输出大小设置为适合 ufunc 的任何值.
在传递给”钩子”函数的数组中,未由输入确定的核维度在 core_dim_sizes 数组中以值 -1 表示.函数可以用基于输入数组中出现的核维度的适当值替换 -1.
警告
函数绝不能更改 core_dim_sizes 中输入时不是 -1 的值.更改不是 -1 的值通常会导致 ufunc 的输出不正确,并可能导致 Python 解释器崩溃.
例如,考虑通用ufunc conv1d,其基本函数计算两个一维数组 x 和 y 的”完整”卷积,长度分别为 m 和 n.这个卷积的输出长度为 m + n - 1.为了将其实现为通用ufunc,签名设置为 (m),(n)->(p),在”钩子”函数中,如果核心维度 p 被发现为 -1,则用 m + n - 1 替换它.如果 p 不是 -1,则必须验证给定值等于 m + n - 1.如果不等于,函数必须设置一个异常并返回 -1.为了得到有意义的结果,操作还要求 m + n 至少为 1,即两个输入都不能有长度 0.
以下是代码中可能的示例:
int conv1d_process_core_dims(PyUFuncObject *ufunc, npy_intp *core_dim_sizes) { // core_dim_sizes will hold the core dimensions [m, n, p]. // p will be -1 if the caller did not provide the out argument. npy_intp m = core_dim_sizes[0]; npy_intp n = core_dim_sizes[1]; npy_intp p = core_dim_sizes[2]; npy_intp required_p = m + n - 1; if (m == 0 && n == 0) { // Disallow both inputs having length 0. PyErr_SetString(PyExc_ValueError, "conv1d: both inputs have core dimension 0; the function " "requires that at least one input has size greater than 0."); return -1; } if (p == -1) { // Output array was not given in the call of the ufunc. // Set the correct output size here. core_dim_sizes[2] = required_p; return 0; } // An output array *was* given. Validate its core dimension. if (p != required_p) { PyErr_Format(PyExc_ValueError, "conv1d: the core dimension p of the out parameter " "does not equal m + n - 1, where m and n are the " "core dimensions of the inputs x and y; got m=%zd " "and n=%zd so p must be %zd, but got p=%zd.", m, n, required_p, p); return -1; } return 0; }