NEP 20 — 通用通用函数签名扩展#

作者:

Marten van Kerkwijk <mhvk@astro.utoronto.ca>

状态:

最终

类型:

标准跟踪

创建日期:

2018-06-10

决议:

https://mail.python.org/pipermail/numpy-discussion/2018-April/077959.html, https://mail.python.org/pipermail/numpy-discussion/2018-May/078078.html

注意

添加固定(i)和灵活(ii)维度的提案已获接受,而添加可广播(iii)维度的提案则被延期。

摘要#

通用通用函数,顾名思义,是通用函数的一种泛化:它们操作的是非标量元素。它们的签名描述了所操作元素的结构,其中名称链接了操作数中应相同的维度。在此,建议扩展签名,使其能够指示一个维度(i)具有固定大小;(ii)可以不存在;以及(iii)可以广播。

详细描述#

提案的每个部分都由具体需求驱动 [1]

  1. 固定大小维度。处理空间向量的代码通常明确地用于二维或三维空间(例如,作者希望使用 gufunc 为 astropy 封装的《基本天文学标准》中的代码 [2])。签名应该能够指示这一点。例如,将极角转换为二维笛卡尔单位向量的函数的签名目前必须是 ()->(n),没有办法指示 n 必须等于 2。事实上,这个签名特别令人恼火,因为如果不传入输出参数,当前的 gufunc 包装代码会因为无法确定 n 而失败。同样,两个三维向量的叉积签名必须是 (n),(n)->(n),再次没有办法指示 n 必须等于 3。因此,本提案允许在变量名之外给出数值。因此,从角度到二维单位向量的签名将是 ()->(2);从两个角度到三维单位向量的签名将是 (),()->(3);而两个三维向量的叉积签名将是 (3),(3)->(3)

  2. 可能缺失的维度。这一部分几乎完全是为了将 matmul 封装在 gufunc 中而驱动的。matmul 代表矩阵乘法,如果它只做这个,那么可以用签名 (m,n),(n,p)->(m,p) 来表示。然而,当维度缺失时,它有特殊情况,允许将任一参数视为单个向量,从而使函数有效地变为向量-矩阵、矩阵-向量或向量-向量乘法(但不进行广播)。为了支持这一点,建议允许在维度名称后面加上问号,以指示该维度不一定必须存在。

    有了这个补充,matmul 的签名可以表示为 (m?,n),(n,p?)->(m?,p?)。这表明,例如,如果第二个操作数只有一个维度,则为了基本函数的目的,它将被视为输入的核心形状为 (n, 1),并且输出具有相应的核心形状 (m, 1)。然而,实际的输出数组移除了灵活维度,即其形状将是 (..., m)。同样,如果两个参数都只有一个维度,则输入将以形状 (1, n)(n, 1) 呈现给基本函数,输出为 (1, 1),而实际返回的输出数组形状将是 ()。通过这种方式,签名允许一个基本函数处理四个相关但不同的签名,即 (m,n),(n,p)->(m,p)(n),(n,p)->(p)(m,n),(n)->(m)(n),(n)->()

  3. 可广播的维度。对于某些应用,操作数之间的广播是有意义的。例如,一个比较数组中向量的 all_equal 函数可以有一个签名 (n),(n)->(),但这强制要求两个操作数都是数组,而检查向量的所有部分是否恒定(可能是零)也很有用。该提案是允许 gufunc 的实现者通过在维度名称后添加 |1 来指示该维度可以广播。因此,all_equal 的签名将变为 (n|1),(n|1)->()。该签名更普遍地适用于“链式 ufunc”;例如,另一个应用可能是在实现 sumproduct 的假定 ufunc 中。

    讨论中出现的另一个例子是加权平均值,它可能看起来像 weighted_mean(y, sigma[, axis, ...]),返回平均值及其不确定性。如果签名是 (n),(n)->(),(),则会强制要求提供与数据点数量相同的 sigma,而广播则允许为所有点提供一个单一的 sigma(这对于计算平均值的不确定性仍然有用)。

实现#

所有提议的更改均已实现 [3][4][5]。这些 PR 扩展了 ufunc 结构,增加了两个新字段,每个字段的大小等于不同维度的数量,其中 core_dim_sizes 存储可能的固定大小,core_dim_flags 存储指示维度是否可以缺失或广播的标志。为了确保能够区分新旧版本,一个未使用的条目 reserved1 被重新用作版本号。

在实现中,需要注意的是,对于基本函数,标记的维度与未标记的维度没有区别对待:例如,固定大小维度的尺寸仍然传递给基本函数(但现在循环可以依赖该尺寸等于签名中给定的固定尺寸)。

一个有待决定的实现细节是,是否方便提供所有标志的摘要。这可能存储在 core_enabled 中(目前是一个布尔值),非零值继续指示一个 gufunc,但特定的标志指示 gufunc 是否使用固定、灵活或可广播的维度。

综上所述,语法的正式定义将变为 [4]

<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" | "?"
  1. 所有引用均为了清晰起见。

  2. 共享相同名称的未修改核心维度必须具有相同的大小。每个维度名称通常对应于基本函数实现中的一个循环层级。

  3. 空白字符将被忽略。

  4. 维度名称中的整数将该维度固定为该值。

  5. 如果名称后缀有 |1 修饰符,则允许它与具有相同名称的其他维度进行广播。所有输入维度必须共享此修饰符,而输出维度不应包含它。

  6. 如果名称后缀有 ? 修饰符,则只有当它存在于所有共享该维度的输入和输出上时,该维度才是一个核心维度;否则它将被忽略(并被基本函数的尺寸为 1 的维度替换)。

签名示例 [4]

签名

可能用途

(),()->()

加法

(i)->()

沿最后一个轴求和

(i|1),(i|1)->()

沿轴测试相等性,允许与标量进行比较

(i),(i)->()

向量内积

(m,n),(n,p)->(m,p)

矩阵乘法

(n),(n,p)->(p)

向量-矩阵乘法

(m,n),(n)->(m)

矩阵-向量乘法

(m?,n),(n,p?)->(m?,p?)

同时处理以上四种情况,除了向量不能有循环维度(即,像 matmul

(3),(3)->(3)

3D 向量的叉积

(i,t),(j,t)->(i,j)

对最后一个维度进行内积运算,对倒数第二个维度进行外积运算,并对其余维度进行循环/广播。

向后兼容性#

一个可能的担忧是 ufunc 结构的变化。对于大多数调用 PyUFunc_FromDataAndSignature 的应用程序来说,这是完全透明的。此外,通过将 reserved1 重新用作版本号,针对旧版本 NumPy 编译的代码将继续工作(尽管在导入该代码时,如果使用较新版本的 NumPy,将会收到警告),除非代码显式更改了 reserved1 条目。

替代方案#

有人建议不要扩展签名,而是采用多重调度,以便 matmul 等函数简单地拥有其支持的多个签名,即,不是 (m?,n),(n,p?)->(m?,p?),而是 (m,n),(n,p)->(m,p) | (n),(n,p)->(p) | (m,n),(n)->(m) | (n),(n)->()。这种方法的缺点是,开发人员现在必须确保基本函数能够处理这些不同的签名。此外,这种扩展很快变得繁琐。例如,对于 all_equal 的签名 (n|1),(n|1)->(),将需要有五个条目:(n),(n)->() | (n),(1)->() | (1),(n)->() | (n),()->() | (),(n)->()。对于像 (m|1,n|1,o|1),(m|1,n|1,o|1)->() 这样的签名(来自 [4] 中的 cube_equal 测试用例),甚至不值得写出其扩展形式。

对于广播,曾建议使用 ^ 作为替代后缀(因为广播可以被认为是增加数组的大小)。这似乎不太清楚。此外,人们曾想知道它是否不应该只是一个全有或全无的标志。这可能是这种情况,但考虑到灵活维度的后缀,可以说另一个后缀更清晰(就像实现一样)。

讨论#

这里的提案在邮件列表中进行了充分的讨论 [6][7]。主要争议点在于用例是否足够充分。特别是对于固定维度,有人认为正确的数值检查可以放在循环选择代码中。这样做似乎效果不佳且无益。

对于广播,人们注意到缺少可能需要它的基本函数示例,并质疑 all_equal 这样的功能是否最好通过 gufunc 而不是作为 np.equal 上的特殊方法来实现。对此的一个反驳是,all_equal 实际上有一个 PR [8]。另一个反驳是,即使使用方法,也最好能够表达它们的签名(就像至少对 reduceaccumulate 而言是可能的)。

最后一个论点是,我们正在使 gufunc 变得过于复杂。这可以说适用于可以省略的维度,但这也是最强烈的用例。固定维度实现非常简单,其含义也显而易见。一旦支持灵活维度,广播能力也变得简单。

参考文献和脚注#