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. 固定大小的维度。处理空间向量的代码通常明确针对的是二维或三维空间(例如,来自 基本天文标准 的代码,作者希望使用 gufuncs 为 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)

3 向量叉积

(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 太复杂了。对于可以省略的维度,这可以说是成立的,但这也有最强的用例。冻结维度有一个非常简单的实现,其含义也很明显。一旦支持灵活维度,广播的能力也很简单。

参考文献和脚注#