PEP 465 – 用于矩阵乘法的专用中缀运算符
- 作者:
- Nathaniel J. Smith <njs at pobox.com>
- 状态:
- 最终
- 类型:
- 标准跟踪
- 创建:
- 2014年2月20日
- Python 版本:
- 3.5
- 历史记录:
- 2014年3月13日
- 决议:
- Python-Dev 消息
摘要
本 PEP 提出一个用于矩阵乘法的新二元运算符,称为 @
。(助记符:@
是 mATrix 的 *
。)
规范
一个新的二元运算符被添加到 Python 语言中,以及相应的就地版本
运算符 | 优先级/结合性 | 方法 |
---|---|---|
@ |
与 * 相同 |
__matmul__ 、__rmatmul__ |
@= |
n/a | __imatmul__ |
在内置或标准库类型中没有添加这些方法的实现。但是,许多项目已经就这些操作的推荐语义达成共识;有关详细信息,请参阅下面的预期用法细节。
有关此运算符如何在 CPython 中实现的详细信息,请参阅实现细节。
动机
执行摘要
在数值代码中,有两个重要的运算争夺 Python 的 *
运算符的使用:逐元素乘法和矩阵乘法。在 Numeric 库首次提出近二十年来,已经进行了许多尝试来解决这种冲突[13];没有一个真正令人满意。目前,大多数数值 Python 代码使用 *
进行逐元素乘法,并使用函数/方法语法进行矩阵乘法;但是,这在常见情况下会导致代码丑陋且难以阅读。这个问题非常严重,以至于大量代码继续使用相反的约定(它具有在不同情况下产生丑陋且难以阅读的代码的优点),而这种跨代码库的 API 分裂又会产生更多问题。在当前的 Python 语法中,设计数值 API 似乎没有好的解决方案——只有各种在不同方面都很糟糕的选项。解决这些问题的足够小的 Python 语法更改是添加一个用于矩阵乘法的新的中缀运算符。
矩阵乘法具有独特的特征组合,使其有别于其他二元运算,这些特征共同为添加专用中缀运算符提供了极具说服力的理由
- 就像现有的数值运算符一样,存在大量先验证据支持在所有数学、科学和工程领域中使用中缀表示法进行矩阵乘法;
@
和谐地填补了 Python 现有运算符系统中的空白。 @
极大地阐明了现实世界的代码。@
为经验不足的用户提供了更平滑的入门体验,他们特别容易受到难以阅读的代码和 API 分裂的困扰。@
有利于 Python 用户群中一个庞大且不断增长的部分。@
将被频繁使用——事实上,有证据表明它可能比//
或按位运算符使用得更频繁。@
允许 Python 数值社区减少碎片化,并最终标准化所有数值数组对象的单个共识鸭子类型。
背景:现状有什么问题?
当我们在计算机上处理数字时,我们通常需要处理大量的数字。尝试一次处理一个数字既麻烦又缓慢——尤其是在使用解释型语言时。相反,我们希望能够写下简单的运算,这些运算可以一次应用于大量数字。n 维数组是所有流行的数值计算环境用来实现这一目标的基本对象。Python 有几个库提供了这样的数组,其中 numpy 目前是最突出的。
在处理 n 维数组时,我们可能希望定义两种不同的乘法方式。一种是逐元素乘法
[[1, 2], [[11, 12], [[1 * 11, 2 * 12],
[3, 4]] x [13, 14]] = [3 * 13, 4 * 14]]
另一种是矩阵乘法
[[1, 2], [[11, 12], [[1 * 11 + 2 * 13, 1 * 12 + 2 * 14],
[3, 4]] x [13, 14]] = [3 * 11 + 4 * 13, 3 * 12 + 4 * 14]]
逐元素乘法很有用,因为它允许我们轻松快速地在大量值上执行许多乘法运算,而无需编写缓慢且麻烦的 for
循环。这作为一种非常通用的模式的一部分起作用:当使用 numpy 或其他数值库提供的数组对象时,所有 Python 运算符都对所有维度的数组进行逐元素运算。结果是,可以使用像 a * b + c / d
这样的简单代码编写函数,将变量视为简单值,然后立即使用此函数有效地对大量值执行此计算,同时使用任何最适合当前问题的复杂数组布局来组织它们。
矩阵乘法更像是一个特例。它仅在 2d 数组(也称为“矩阵”)上定义,并且乘法是唯一具有重要“矩阵”版本的运算——“矩阵加法”与逐元素加法相同;没有“矩阵按位或”或“矩阵向下取整”之类的东西;可以定义“矩阵除法”和“矩阵幂”,但它们不是很有用,等等。但是,矩阵乘法仍然在所有数值应用领域中被大量使用;从数学上讲,这是最基本的运算之一。
由于 Python 语法目前只允许使用单个乘法运算符 *
,因此提供类数组对象的库必须做出决定:要么使用 *
进行逐元素乘法,要么使用 *
进行矩阵乘法。不幸的是,事实证明,在进行通用数值计算时,两种运算都经常使用,并且在两种情况下使用中缀表示法而不是函数调用语法都有很大的优势。因此,目前尚不清楚哪种约定是最佳的,甚至是可以接受的;通常情况下,它会根据具体情况而有所不同。
尽管如此,网络效应意味着我们必须选择一个约定。例如,在 numpy 中,可以在约定之间切换,因为 numpy 提供了两种具有不同 __mul__
方法的不同类型。对于 numpy.ndarray
对象,*
执行逐元素乘法,而矩阵乘法必须使用函数调用(numpy.dot
)。对于 numpy.matrix
对象,*
执行矩阵乘法,而逐元素乘法需要函数语法。使用 numpy.ndarray
编写代码可以正常工作。使用 numpy.matrix
编写代码也可以正常工作。但是,一旦我们尝试将这两部分代码集成在一起,麻烦就开始了。期望 ndarray
并获得 matrix
或反之亦然的代码可能会崩溃或返回不正确的结果。跟踪哪些函数期望哪些类型的输入并返回哪些类型的输出,然后一直进行转换,这非常麻烦,并且不可能在任何规模上都能正确执行。防御性地尝试处理这两种类型的输入并 DTRT 的函数发现自己陷入 isinstance
和 if
语句的泥潭中。
PEP 238 将 /
分裂成两个运算符:/
和 //
。想象一下,如果它改为将 int
分裂成两种类型:classic_int
,其 __div__
实现向下取整除法,以及 new_int
,其 __div__
实现真除法,那么会造成怎样的混乱。这在某种程度上是 Python 数值计算人员目前所面临的状况。
在实践中,绝大多数项目都已采用使用 *
进行逐元素乘法,并使用函数调用语法进行矩阵乘法的约定(例如,使用 numpy.ndarray
而不是 numpy.matrix
)。这减少了 API 分裂造成的问题,但并没有消除它们。强烈希望对矩阵乘法使用中缀表示法导致许多专门的数组库继续使用相反的约定(例如,scipy.sparse、pyoperators、pyviennacl),尽管这会造成问题,并且 numpy.matrix
本身仍然用于入门编程课程,经常出现在 StackOverflow 答案中,等等。因此,编写良好的库必须继续准备处理这两种类型的对象,并且当然也必须继续使用令人不快的函数调用语法进行矩阵乘法。经过近二十年的尝试,数值社区仍然没有找到任何方法在当前 Python 语法约束下解决这些问题(请参阅下面的添加新运算符的替代方案被拒绝)。
本 PEP 提出对 Python 语法进行最小的有效更改,以解决当前存在的混乱情况。它将 *
运算符拆分为两个,就像对 /
所做的那样:*
用于元素级乘法,@
用于矩阵乘法。(为什么不反过来?因为这种方式与现有的共识相符,并且它提供了一条一致的规则,即所有内置的数值运算符也以元素级的方式应用于数组;反向约定会导致更多特殊情况。)
所以这就是矩阵乘法不使用也不可以使用 *
的原因。现在,在本节的其余部分,我们将解释为什么它仍然满足添加新运算符的高标准。
为什么矩阵乘法应该使用中缀表示法?
目前,Python 中的大多数数值代码使用类似 numpy.dot(a, b)
或 a.dot(b)
的语法来执行矩阵乘法。这显然有效,那么为什么人们对此如此大惊小怪,甚至到了造成 API 分裂和兼容性混乱的地步呢?
矩阵乘法与普通算术运算(如数字的加法和乘法)具有两个共同特征:(a) 它在数值程序中被大量使用——通常每行代码多次使用——以及 (b) 它有着悠久且普遍采用的传统,即使用中缀语法表示。这是因为,对于典型的公式,这种表示法比任何函数调用语法都更易于阅读。以下是一个演示示例。
用于检验统计假设的最有用工具之一是 OLS 回归模型的线性假设检验。我刚才说的话到底是什么意思并不重要;如果我们发现自己必须实现这个东西,我们将做的是查阅一些教科书或论文,并遇到许多看起来像这样的数学公式。
这里各种变量都是向量或矩阵(好奇者可以参考细节:[5])。
现在我们需要编写代码来执行此计算。在当前的 numpy 中,可以使用函数或方法调用语法来执行矩阵乘法。两者都没有提供对公式的特别易读的转换。
import numpy as np
from numpy.linalg import inv, solve
# Using dot function:
S = np.dot((np.dot(H, beta) - r).T,
np.dot(inv(np.dot(np.dot(H, V), H.T)), np.dot(H, beta) - r))
# Using dot method:
S = (H.dot(beta) - r).T.dot(inv(H.dot(V).dot(H.T))).dot(H.dot(beta) - r)
使用 @
运算符,上述公式的直接转换变为
S = (H @ beta - r).T @ inv(H @ V @ H.T) @ (H @ beta - r)
请注意,现在在原始公式中的符号和实现它的代码之间存在透明的、一对一的映射。
当然,有经验的程序员可能会注意到这不是计算此表达式的最佳方法。可能应该将 Hβ − r 的重复计算分解出来;并且,类似 dot(inv(A), B)
形式的表达式几乎总是应该替换为数值上更稳定的 solve(A, B)
。当使用 @
时,执行这两个重构操作将得到
# Version 1 (as above)
S = (H @ beta - r).T @ inv(H @ V @ H.T) @ (H @ beta - r)
# Version 2
trans_coef = H @ beta - r
S = trans_coef.T @ inv(H @ V @ H.T) @ trans_coef
# Version 3
S = trans_coef.T @ solve(H @ V @ H.T, trans_coef)
请注意,当比较每一对步骤时,很容易确切地看到发生了什么变化。如果我们将等效的转换应用于使用 .dot 方法的代码,那么更改将更难以读取或验证其正确性。
# Version 1 (as above)
S = (H.dot(beta) - r).T.dot(inv(H.dot(V).dot(H.T))).dot(H.dot(beta) - r)
# Version 2
trans_coef = H.dot(beta) - r
S = trans_coef.T.dot(inv(H.dot(V).dot(H.T))).dot(trans_coef)
# Version 3
S = trans_coef.T.dot(solve(H.dot(V).dot(H.T)), trans_coef)
可读性很重要!使用 @
的语句更短,包含更多空格,可以直接轻松地相互比较以及与教科书公式进行比较,并且只包含有意义的括号。最后一点对可读性尤其重要:当使用函数调用语法时,每次操作所需的括号会造成视觉混乱,即使对于像这样的相对简单的公式,也难以用肉眼解析出公式的整体结构。眼睛不擅长解析非正则语言。在尝试写出上面的“dot”公式时,我犯了很多错误并发现了它们。我知道它们仍然至少包含一个错误,也许更多。(练习:找到它或它们。)相比之下,@
示例不仅正确,而且一目了然。
如果我们是更有经验的程序员,并且编写了我们期望重用的代码,那么速度或数值精度的考虑可能会导致我们更喜欢某种特定的求值顺序。因为 @
使我们能够省略不相关的括号,所以我们可以确定,如果我们确实编写了类似 (H @ V) @ H.T
的内容,那么我们的读者就会知道必须有意添加括号以实现某种有意义的目的。在 dot
示例中,无法知道哪些嵌套决策很重要,哪些是任意的。
中缀 @
显著提高了矩阵代码在程序员交互各个阶段的可用性。
透明的语法对于非专业程序员尤其重要
很大一部分科学代码是由在其领域内是专家但在编程方面不是专家的人编写的。而且每年都有许多大学课程,课程名称类似于“社会科学家的数据分析”,这些课程假设没有编程背景,并在 10-15 周的时间内教授数学技巧、编程入门以及使用编程来实现这些数学技巧的组合。这些课程越来越经常地使用 Python 而不是 R 或 Matlab 等专用语言来教授。
对于这些编程知识薄弱的用户来说,公式和代码之间存在透明的映射通常意味着成功与否的区别,甚至根本无法编写该代码。这一点非常重要,以至于此类课程经常使用 numpy.matrix
类型,该类型将 *
定义为矩阵乘法,即使这种类型存在错误并且由于其导致的分裂而被 numpy 社区的其余部分强烈不推荐。实际上,这种教学用例是 numpy.matrix
仍然是 numpy 支持的一部分的唯一原因。添加 @
将通过更好的语法使初学者和高级用户受益;此外,它将允许这两组用户从一开始就标准化相同的表示法,为专业知识提供更平滑的入门途径。
但是,矩阵乘法不是一个非常小众的需求吗?
世界充满了连续数据,计算机越来越多地被要求以复杂的方式处理它。数组是金融、机器学习、3D 图形、计算机视觉、机器人技术、运筹学、计量经济学、气象学、计算语言学、推荐系统、神经科学、天文学、生物信息学(包括遗传学、癌症研究、药物发现等)、物理引擎、量子力学、地球物理学、网络分析以及许多其他应用领域的通用语言。在大多数或所有这些领域,Python 正在迅速成为主要的参与者,这很大程度上归因于它能够优雅地将传统的离散数据结构(哈希表、字符串等)与现代数值数据类型和算法放在同等地位。
我们都生活在我们自己的小社区中,因此一些 Python 用户可能会惊讶地意识到 Python 用于数值计算的程度——尤其是在这个特定子社区的许多活动发生在传统的 Python/FOSS 渠道之外的情况下。因此,为了大致了解实际上有多少数值 Python 程序员,这里有两个数字:2013 年,专门针对数值 Python 举办了 7 个国际会议[3] [4]。在 2014 年的 PyCon 上,大约 20% 的教程似乎涉及矩阵的使用[6]。
为了进一步量化这一点,我们使用了 Github 的“搜索”功能来查看在各种现实世界的代码(即 Github 上的所有代码)中实际导入了哪些模块。我们检查了几个流行的 stdlib 模块、各种面向数值的模块以及各种其他非常著名的模块(如 django 和 lxml(后者是 PyPI 上下载次数最多的第 1 个包))的导入。带星号的行表示如果批准本 PEP,将采用 @
的导出数组或矩阵类对象的包。
Count of Python source files on Github matching given search terms
(as of 2014-04-10, ~21:00 UTC)
================ ========== =============== ======= ===========
module "import X" "from X import" total total/numpy
================ ========== =============== ======= ===========
sys 2374638 63301 2437939 5.85
os 1971515 37571 2009086 4.82
re 1294651 8358 1303009 3.12
numpy ************** 337916 ********** 79065 * 416981 ******* 1.00
warnings 298195 73150 371345 0.89
subprocess 281290 63644 344934 0.83
django 62795 219302 282097 0.68
math 200084 81903 281987 0.68
threading 212302 45423 257725 0.62
pickle+cPickle 215349 22672 238021 0.57
matplotlib 119054 27859 146913 0.35
sqlalchemy 29842 82850 112692 0.27
pylab *************** 36754 ********** 41063 ** 77817 ******* 0.19
scipy *************** 40829 ********** 28263 ** 69092 ******* 0.17
lxml 19026 38061 57087 0.14
zlib 40486 6623 47109 0.11
multiprocessing 25247 19850 45097 0.11
requests 30896 560 31456 0.08
jinja2 8057 24047 32104 0.08
twisted 13858 6404 20262 0.05
gevent 11309 8529 19838 0.05
pandas ************** 14923 *********** 4005 ** 18928 ******* 0.05
sympy 2779 9537 12316 0.03
theano *************** 3654 *********** 1828 *** 5482 ******* 0.01
================ ========== =============== ======= ===========
这些数字应该谨慎看待(请参阅脚注以了解讨论:[12]),但在可以信任的范围内,它们表明 numpy
可能是整个 Python 世界中导入次数最多的单个非 stdlib 模块;它甚至比 subprocess
、math
、pickle
和 threading
等 stdlib 常用模块导入次数更多。而 numpy 用户仅代表将从 @
运算符中受益的更广泛数值社区的一个子集。矩阵曾经可能是一种利基数据类型,仅限于在大学实验室和军事集群中运行的 Fortran 程序,但那些日子早已一去不复返了。数值计算是现代 Python 使用的主流部分。
此外,为处理更专业的算术运算而添加中缀运算符有一些先例:地板除运算符 //
与按位运算符一样,在某些情况下对离散值执行精确计算时非常有用。但似乎很多 Python 程序员从未有过使用 //
(或按位运算符)的理由。@
并不比 //
更利基。
所以 @
适用于矩阵公式,但这些公式到底有多常见?
我们已经看到,@
使矩阵公式对于专家和非专家来说都变得更容易使用,矩阵公式出现在许多重要的应用中,并且 numpy 等数值库被 Python 用户群体的很大一部分使用。但数值库不仅仅是关于矩阵公式,而且重要性并不一定意味着占用大量代码:如果矩阵公式只出现在平均面向数值的项目中的一个或两个地方,那么添加新运算符仍然不值得。那么矩阵乘法到底有多常见呢?
当情况变得艰难时,坚强的人会寻求实证。为了大致估计 @
运算符将有多有用,下表显示了不同 Python 运算符在 stdlib 以及两个知名数值包(scikit-learn 机器学习库和 nipy 神经影像库)中实际使用的频率,并按代码行数 (SLOC) 进行了归一化。行按“组合”列排序,该列将所有三个代码库汇总在一起。因此,组合列的权重很大程度上偏向于 stdlib,stdlib 比这两个项目加起来都大得多(stdlib:411575 SLOC,scikit-learn:50924 SLOC,nipy:37078 SLOC)。[7]
dot
行(标记为 ******
)统计了每个代码库中矩阵乘法操作的常见程度。
==== ====== ============ ==== ========
op stdlib scikit-learn nipy combined
==== ====== ============ ==== ========
= 2969 5536 4932 3376 / 10,000 SLOC
- 218 444 496 261
+ 224 201 348 231
== 177 248 334 196
* 156 284 465 192
% 121 114 107 119
** 59 111 118 68
!= 40 56 74 44
/ 18 121 183 41
> 29 70 110 39
+= 34 61 67 39
< 32 62 76 38
>= 19 17 17 18
<= 18 27 12 18
dot ***** 0 ********** 99 ** 74 ****** 16
| 18 1 2 15
& 14 0 6 12
<< 10 1 1 8
// 9 9 1 8
-= 5 21 14 8
*= 2 19 22 5
/= 0 23 16 4
>> 4 0 0 3
^ 3 0 0 3
~ 2 4 5 2
|= 3 0 0 2
&= 1 0 0 1
//= 1 0 0 1
^= 1 0 0 0
**= 0 2 0 0
%= 0 0 0 0
<<= 0 0 0 0
>>= 0 0 0 0
==== ====== ============ ==== ========
仅这两个数值包就包含了约 780 次矩阵乘法的使用。在这些包中,矩阵乘法的使用频率高于大多数比较运算符(<
!=
<=
>=
)。即使我们将这些计数扩展到包含标准库进行比较,矩阵乘法的总使用次数仍然高于任何位运算符,并且是 //
使用次数的两倍。即使标准库(包含大量整数运算且没有矩阵运算)占据了超过 80% 的组合代码库,情况依然如此。
巧合的是,数值库在“组合”代码库中所占的比例大约与数值教程在 PyCon 2014 教程安排中所占的比例相同,这表明“组合”列可能并没有严重偏离一般新的 Python 代码。虽然无法确定,但从这些数据来看,在目前正在编写的全部 Python 代码中,矩阵乘法的使用频率可能已经超过了 //
和位运算符。
但是,添加一个没有标准库使用的运算符是不是很奇怪?
这当然是不寻常的(尽管扩展切片在一段时间内存在于内置类型中,并获得了对它的支持,Ellipsis
仍然在标准库中未使用,等等)。但重要的是更改是否会使用户受益,而不是软件从哪里下载。从上面可以清楚地看出,@
将被使用,而且会被大量使用。并且本 PEP 提供了至关重要的部分,这将使 Python 数值社区最终就所有类似数组对象的标准鸭子类型达成共识,这是在标准库中添加数值数组类型的必要前提。
兼容性考虑
目前,Python 代码中 @
标记的唯一合法用法是在装饰器中的语句开头。新运算符都是中缀运算符;它们永远无法出现的位置是在语句开头。因此,添加这些运算符不会破坏任何现有代码,并且在装饰器-@ 和新运算符之间不存在任何可能的解析歧义。
另一种重要的兼容性是用户在更改后更新他们对 Python 语言的理解所付出的心理成本,特别是对于不使用矩阵因此没有从中受益的用户而言。在这里,@
的影响最小:即使是全面教程和参考,也只需要添加一两句话就能完整地记录本 PEP 对非数值受众的更改。
预期用法细节
本节提供信息,而不是规范——它记录了多个提供类似数组或矩阵对象的库关于如何实现 @
的共识。
本节使用 numpy 术语来描述任意多维数据数组,因为它包含所有其他常用模型。在此模型中,任何数组的形状都由一个整数元组表示。因为矩阵是二维的,所以它们具有 len(shape) == 2,而一维向量具有 len(shape) == 1,标量具有 shape == (),即它们是“0 维”的。任何数组都包含 prod(shape) 个总条目。请注意,prod(()) == 1(与 sum(()) == 0 的原因相同);标量只是一种普通的数组,而不是特殊情况。还要注意,我们区分单个标量值(shape == (),类似于 1
)、仅包含一个条目的向量(shape == (1,),类似于 [1]
)、仅包含一个条目的矩阵(shape == (1, 1),类似于 [[1]]
)等,因此任何数组的维度始终是明确定义的。其他具有更受限表示的库(例如,仅支持二维数组的库)可能只实现此处描述的功能的子集。
语义
对于不同输入,@
的推荐语义是
- 二维输入是常规矩阵,因此语义是显而易见的:我们应用常规矩阵乘法。如果我们使用
arr(2, 3)
来表示任意 2x3 数组,那么arr(2, 3) @ arr(3, 4)
返回一个形状为 (2, 4) 的数组。 - 一维向量输入通过在形状前面或后面添加“1”提升到二维,执行操作,然后从输出中删除添加的维度。1 始终添加到形状的“外部”:对于左侧参数,添加在前面;对于右侧参数,添加在后面。结果是矩阵 @ 向量和向量 @ 矩阵都是合法的(假设形状兼容),并且都返回一维向量;向量 @ 向量返回一个标量。通过示例可以更清楚地说明这一点。
arr(2, 3) @ arr(3, 1)
是一个常规矩阵乘积,并返回一个形状为 (2, 1) 的数组,即列向量。arr(2, 3) @ arr(3)
执行与前者相同的计算(即,将一维向量视为包含单个列的矩阵,形状 = (3, 1)),但返回形状为 (2,) 的结果,即一维向量。arr(1, 3) @ arr(3, 2)
是一个常规矩阵乘积,并返回一个形状为 (1, 2) 的数组,即行向量。arr(3) @ arr(3, 2)
执行与前者相同的计算(即,将一维向量视为包含单个行的矩阵,形状 = (1, 3)),但返回形状为 (2,) 的结果,即一维向量。arr(1, 3) @ arr(3, 1)
是一个常规矩阵乘积,并返回一个形状为 (1, 1) 的数组,即矩阵形式的单个值。arr(3) @ arr(3)
执行与前者相同的计算,但返回形状为 () 的结果,即单个标量值,而不是矩阵形式。因此,这是向量上的标准内积。
此一维向量定义的一个缺陷是,它在某些情况下使
@
不满足结合律((Mat1 @ vec) @ Mat2
!=Mat1 @ (vec @ Mat2)
)。但这似乎是一个实用性胜过纯度的案例:不满足结合律只会出现在实践中永远不会编写的奇怪表达式中;如果确实编写了这些表达式,那么就有一条一致的规则来理解将会发生什么(Mat1 @ vec @ Mat2
被解析为(Mat1 @ vec) @ Mat2
,就像a - b - c
一样);而且,不支持一维向量将排除在实践中非常常见的重要用例。没有人希望向新用户解释为什么为了以显而易见的方式解决最简单的线性系统,他们必须键入(inv(A) @ b[:, np.newaxis]).flatten()
而不是inv(A) @ b
,或者通过键入solve(X.T @ X, X @ y[:, np.newaxis]).flatten()
而不是solve(X.T @ X, X @ y)
来执行普通的最小二乘回归。没有人希望每次计算内积时都键入(a[np.newaxis, :] @ b[:, np.newaxis])[0, 0]
而不是a @ b
,或者对于一般二次形式,键入(a[np.newaxis, :] @ Mat @ b[:, np.newaxis])[0, 0]
而不是a @ Mat @ b
。此外,sage 和 sympy(见下文)使用这些不满足结合律的语义以及中缀矩阵乘法运算符(它们使用*
),并且他们报告说他们没有遇到由此引起的问题。 - 对于具有两个以上维度的输入,我们将最后两个维度视为要乘法的矩阵的维度,并在其他维度上“广播”。这提供了一种便捷的方法来快速在单个操作中计算许多矩阵乘积。例如,
arr(10, 2, 3) @ arr(10, 3, 4)
执行 10 次单独的矩阵乘法,每次乘法都将一个 2x3 矩阵和一个 3x4 矩阵相乘以生成一个 2x4 矩阵,然后将 10 个生成的矩阵一起返回到一个形状为 (10, 2, 4) 的数组中。这里的直觉是我们把这些 3 维数字数组视为 1 维矩阵数组,然后以元素方式应用矩阵乘法,现在每个“元素”都是一个完整的矩阵。请注意,广播不仅限于完美对齐的数组;在更复杂的情况下,它允许使用一些简单但强大的技巧来控制数组如何相互对齐;有关详细信息,请参阅 [10]。(特别是,事实证明,当考虑广播时,标准标量 * 矩阵乘积是元素乘法运算符*
的特例。)如果一个操作数的维度大于 2d,另一个操作数的维度为 1d,则上述规则不变,并在广播之前执行 1d 到 2d 的提升。例如,
arr(10, 2, 3) @ arr(3)
首先提升到arr(10, 2, 3) @ arr(3, 1)
,然后广播右操作数以创建对齐的操作arr(10, 2, 3) @ arr(10, 3, 1)
,相乘得到形状为 (10, 2, 1) 的数组,最后移除添加的维度,返回形状为 (10, 2) 的数组。类似地,arr(2) @ arr(10, 2, 3)
生成一个形状为 (10, 1, 3) 的中间数组,以及一个形状为 (10, 3) 的最终数组。 - 0d(标量)输入会引发错误。标量乘以矩阵的运算在数学和算法上与矩阵乘以矩阵的运算不同,并且已经被逐元素的
*
运算符涵盖。允许标量 @ 矩阵操作将需要不必要的特殊情况,并违反 TOOWTDI 原则。
采用
我们将现有的提供数组或矩阵类型(基于它们当前用于逐元素和矩阵乘法的 API)的 Python 项目进行分组。
当前使用 * 进行逐元素乘法,并使用函数/方法调用进行矩阵乘法的项目
以下项目的开发者已表示有意在其类似数组的类型上实现 @
,并使用上述语义。
- numpy
- pandas
- blaze
- theano
以下项目已收到 PEP 的存在通知,但尚不清楚如果 PEP 被接受,它们打算采取什么措施。不过,我们预计它们不会有任何异议,因为这里提出的所有内容都与它们目前的操作方式一致。
- pycuda
- panda3d
当前使用 * 进行矩阵乘法,并使用函数/方法调用进行逐元素乘法的项目
如果 PEP 被接受,以下项目已表示有意从其当前 API 迁移到逐元素-*
、matmul-@
约定(即,如果 PEP 被接受,此列表包含 API 分裂可能会消除的项目)。
- numpy (
numpy.matrix
) - scipy.sparse
- pyoperators
- pyviennacl
以下项目已收到 PEP 的存在通知,但尚不清楚如果 PEP 被接受,它们打算采取什么措施(即,此列表包含如果 PEP 被接受,API 分裂可能消除也可能不会消除的项目)。
- cvxopt
当前使用 * 进行矩阵乘法,并且不太关心矩阵的逐元素乘法的项目
有几个项目实现了矩阵类型,但从与上面讨论的数值库完全不同的角度出发。这些项目专注于分析作为抽象数学对象(即环上自由模上的线性映射)的矩阵的计算方法,而不是作为需要处理的大量数字的集合。事实证明,从抽象数学的角度来看,逐元素操作本身并没有多大用处;如上文“背景”部分所述,逐元素操作是由数字集合的方法驱动的。因此,这些项目不会遇到此 PEP 存在的根本问题,使其与它们几乎无关;虽然它们在表面上看起来类似于 numpy 等项目,但它们实际上是在做一些完全不同的事情。它们使用 *
进行矩阵乘法(以及群作用等),如果 PEP 被接受,它们表示打算继续这样做,同时可能添加 @
作为别名。这些项目包括
- sympy
- sage
实现细节
新的函数 operator.matmul
和 operator.__matmul__
添加到标准库中,并具有通常的语义。
相应的函数 PyObject* PyObject_MatrixMultiply(PyObject *o1, PyObject *o2)
添加到 C API 中。
添加了一个名为 MatMult
的新 AST 节点,以及一个新的标记 ATEQUAL
和新的字节码操作码 BINARY_MATRIX_MULTIPLY
和 INPLACE_MATRIX_MULTIPLY
。
添加了两个新的类型插槽;是否添加到 PyNumberMethods
或新的 PyMatrixMethods
结构还有待确定。
规范细节的理由
运算符的选择
为什么使用 @
而不是其他表示法?其他编程语言之间对这个运算符的命名没有达成共识 [11];在这里,我们讨论了各种选项。
仅限于美国英语键盘上存在的符号,在 Python 表达式上下文中还没有意义的标点符号是:@
、反引号、$
、!
和 ?
。在这些选项中,@
显然是最好的;!
和 ?
在编程上下文中已经带有大量不适用的含义,反引号已被 BDFL 宣布禁止使用在 Python 中(参见 PEP 3099),而 $
则更丑陋,与 *
和 ⋅ 更不相似,并且带有 Perl/PHP 的包袱。$
可能是这些选项中第二好的选项。
美国英语键盘上不存在的符号一开始就处于明显的劣势(在每个数字 Python 教程开始时花费 5 分钟来讲解键盘布局并不是任何人真正想要的麻烦)。此外,即使我们以某种方式克服了打字问题,也不清楚是否真的有任何符号比 @
更好。一些建议的选项包括
- U+00D7 乘法符号:
A × B
- U+22C5 点运算符:
A ⋅ B
- U+2297 圆圈乘号:
A ⊗ B
- U+00B0 度数:
A ° B
然而,我们需要的是一个表示“矩阵乘法,而不是标量/逐元素乘法”的运算符。在编程或数学中,都没有使用这种含义的常规符号,在这些领域中,这些运算通常由上下文来区分。(并且 U+2297 圆圈乘号实际上通常用于表示完全错误的事物:逐元素乘法——“哈达玛积”——或外积,而不是像我们的运算符那样的矩阵/内积)。@
至少具有看起来像一个有趣的非交换运算符的优点;一个了解数学但不了解编程的菜鸟用户无法查看 A * B
与 A × B
,或 A * B
与 A ⋅ B
,或 A * B
与 A ° B
并猜测哪个是通常的乘法,哪个是特殊情况。
最后,可以选择使用多字符标记。一些选项
- Matlab 和 Julia 使用
.*
运算符。除了在视觉上容易与*
混淆之外,这对我们来说是一个糟糕的选择,因为在 Matlab 和 Julia 中,*
表示矩阵乘法,.*
表示逐元素乘法,因此使用.*
表示矩阵乘法将使我们与 Matlab 和 Julia 用户的预期完全相反。 - APL 显然使用了
+.×
,它通过结合多字符标记、令人困惑的类似属性访问的 . 语法和 Unicode 字符,在我们的候选列表中的排名低于 U+2603 雪人。如果我们喜欢将加法和乘法运算符结合起来的想法,因为这让人联想到矩阵乘法实际上是如何工作的,那么可以使用类似+*
的东西——尽管这可能太容易与*+
混淆,后者只是乘法与一元+
运算符的组合。 - PEP 211 建议使用
~*
。这有一个缺点,即它有点暗示存在一个一元*
运算符,它与一元~
结合使用,但它可以使用。 - R 使用
%*%
进行矩阵乘法。在 R 中,这构成了一个通用的可扩展中缀系统的一部分,其中所有形式为%foo%
的标记都是用户定义的二元运算符。我们可以窃取标记而不窃取系统。 - 一些其他合理的候选者已被提出:
><
(= 乘法符号 × 的 ASCII 绘制);脚注运算符[*]
或|*|
(但在上下文中使用时,使用垂直分组符号往往会重新创建嵌套括号的视觉混乱,这被认为是我们试图摆脱的函数语法的主要缺点之一);^*
。
所以,这并不重要,但@
似乎与任何替代方案一样好,甚至更好。
- 它是一个友好的字符,Python 开发者已经习惯在装饰器中输入,但装饰器用法和数学表达式用法差异足够大,以至于在实践中很难混淆它们。
- 它在各种键盘布局中都可以广泛访问(并且由于它在电子邮件地址中的使用,即使是手机上那些奇怪的键盘也是如此)。
- 它像
*
和⋅一样是圆形的。 - mATrices 助记符很可爱。
- 螺旋形让人联想到定义矩阵乘法的行和列上的同步扫描。
- 它的不对称性唤起了其非交换性。
- 不管怎样,我们必须选择一些东西。
优先级和结合性
关于@
应该右结合还是左结合(甚至更奇特的东西[18])有过长时间的讨论[15]。几乎所有 Python 运算符都是左结合的,因此遵循此约定将是最简单的方法,但有两个论点表明矩阵乘法可能值得作为特例将其设为右结合。
首先,矩阵乘法与函数应用/组合有紧密的概念关联,因此许多数学上成熟的用户有一种直觉,即像RSx这样的表达式从右到左进行,首先S转换向量x,然后R转换结果。这并非普遍认同(并非所有数字计算人员都沉浸在激发这种直觉的纯数学概念框架中[16]),但至少这种直觉比其他运算(如2⋅3⋅4,每个人都将其解读为从左到右)更常见。
其次,如果表达式如Mat @ Mat @ vec
经常出现在代码中,那么如果将其评估为Mat @ (Mat @ vec)
,而不是将其评估为(Mat @ Mat) @ vec
,程序将运行得更快(并且注重效率的程序员将能够使用更少的括号)。
然而,以下论点与这些论点相抵触。
关于效率论点,根据经验,我们无法找到任何证据表明Mat @ Mat @ vec
类型的表达式实际上在现实生活中的代码中占主导地位。通过分析一些使用 numpy 的大型项目,我们发现,当 numpy 当前的 funcall 语法迫使人们为嵌套调用 dot
选择运算顺序时,人们实际上更频繁地使用左结合嵌套而不是右结合嵌套[17]。无论如何,编写括号并不是那么糟糕——如果一个注重效率的程序员要费心考虑评估某个表达式的最佳方式,那么他们可能应该写下括号,无论它们是否需要,只是为了让下一个读者清楚地知道运算顺序很重要。
此外,事实证明,其他语言,包括那些更专注于线性代数的语言,绝大多数都将其 matmul 运算符设为左结合。具体来说,在 R、Matlab、Julia、IDL 和 Gauss 中,@
等价物是左结合的。我们发现的唯一例外是 Mathematica,其中a @ b @ c
将被非关联地解析为dot(a, b, c)
,以及 APL,其中所有运算符都是右结合的。似乎不存在任何语言将@
设为右结合并将*
设为左结合的语言。这些决定似乎没有争议——我从未见过任何人抱怨这些其他语言的这个特定方面,并且*
的左结合性似乎并没有困扰使用*
进行矩阵乘法的现有 Python 库的用户。因此,至少我们可以由此得出结论,将@
设为左结合肯定不会造成任何灾难。另一方面,将@
设为右结合将是在探索新的和不确定的领域。
左结合的另一个优点是,学习和记住@
的行为类似于*
比首先记住@
与其他 Python 运算符不同,因为它右结合,然后在此基础上,还要记住它比*
结合更紧密还是更松散要容易得多。(右结合迫使我们选择一个优先级,并且直觉在哪个优先级更有意义方面大致相同。因此,这表明无论我们做出什么选择,没有人能够猜测或记住它。)
因此,总的来说,数值界的普遍共识是,虽然矩阵乘法在某种程度上是一个特殊情况,但它并不特殊到足以打破规则,并且@
应该像*
一样解析。
内置类型的(不)定义
对于内置数值类型(float
、int
等)或numbers.Number
层次结构,没有定义__matmul__
或__matpow__
,因为这些类型表示标量,并且@
的共识语义是它应该在标量上引发错误。
我们目前没有在标准的memoryview
或array.array
对象上定义__matmul__
方法,原因有几个。当然,如果有人需要,可以添加它,但这些类型在用于数值工作之前需要比__matmul__
多做很多额外的工作——例如,它们也没有办法进行加法或标量乘法!——并且添加此类功能超出了本 PEP 的范围。此外,提供高质量的矩阵乘法实现非常不容易。朴素的嵌套循环实现非常慢,并且在 CPython 中提供此类实现只会为用户设置陷阱。但另一种选择——提供现代且有竞争力的矩阵乘法——将需要 CPython 链接到 BLAS 库,这带来了一系列新的复杂性。特别是,几个流行的 BLAS 库(包括 OS X 上默认提供的库)目前破坏了multiprocessing
的使用[8]。综合考虑这些因素,这意味着在这些类型上添加__matmul__
的成本/效益并不存在,因此目前我们将继续将这些问题委托给 numpy 及其朋友,并将更系统的解决方案推迟到未来的提案中。
还有一些非数值 Python 内置函数定义了__mul__
(str
、list
等)。我们也没有为这些类型定义__matmul__
,因为我们为什么要这样做呢。
矩阵幂的未定义
本 PEP 的早期版本还提出了一个矩阵幂运算符@@
,类似于**
。但经过进一步考虑,人们认为它的效用不够明确,因此最好暂时将其排除在外,并且仅在——一旦我们对@
有了更多经验——发现@@
确实被遗漏时才重新审视这个问题。[14]
添加新运算符的替代方案被拒绝
在过去的几十年里,Python 数值社区探索了多种方法来解决矩阵乘法和逐元素乘法运算之间的矛盾。PEP 211和PEP 225,这两个提案都提出于 2000 年,最后一次认真讨论是在 2008 年[9],是早期尝试添加新运算符来解决此问题的尝试,但存在严重缺陷;特别是,当时 Python 数值社区尚未就数组对象的正确 API 或可能需要或有用的运算符达成共识(例如,PEP 225提出了 6 个具有未指定语义的新运算符)。此后的经验现在已导致人们达成共识,即对于数值 Python 和核心 Python,最佳解决方案是添加一个用于矩阵乘法的单个中缀运算符(以及它隐含的其他新运算符,如@=
)。
我们在这里回顾了一些被拒绝的替代方案。
使用定义了 __mul__ 为矩阵乘法的第二种类型: 如上所述(背景:现状有什么问题?),多年来一直通过 numpy.matrix
类型(及其在 Numeric 和 numarray 中的前身)尝试过这种方法。结果是 numpy 开发人员和下游软件包的开发人员之间达成了强烈共识,即 numpy.matrix
基本上不应该使用,因为拥有冲突的数组鸭子类型会导致问题。(当然,有人可能会争辩说我们应该*只*定义 __mul__
为矩阵乘法,但那样的话,我们就将在元素级乘法中遇到同样的问题。)曾多次推动完全删除 numpy.matrix
;唯一的反驳意见来自教育工作者,他们认为其问题被提供给新手一种简单明了的数学符号与代码映射的需求所抵消(参见 透明语法对于非专业程序员尤其重要)。但是,当然,让新手从一开始就使用不受欢迎的语法,然后期望他们以后再过渡,本身就会造成问题。两种类型的解决方案弊大于利。
添加大量新的运算符,或添加一种新的通用语法来定义中缀运算符: 除了总体上不符合 Python 风格并且反复被 BDFL 否决之外,这将是杀鸡用牛刀。科学 Python 社区一致认为,添加一个用于矩阵乘法的运算符足以解决一个无法解决的痛点。(事后看来,我们都认为 PEP 225 也是一个糟糕的想法——或者至少比它需要的复杂得多。)
添加一个新的 @(或其他任何)运算符,该运算符在通用 Python 中具有其他含义,然后在数字代码中重载它: 这是 PEP 211 所采用的方法,该方法提议将 @
定义为等效于 itertools.product
。这样做的问题在于,从其自身角度来看,很明显 itertools.product
实际上并不需要专用的运算符。它甚至没有被认为值得成为内置函数。(在讨论此 PEP 期间,有人提出了类似的建议,即定义 @
作为通用的函数组合运算符,并且它也存在同样的问题;functools.compose
甚至没有实用到存在的程度。)矩阵乘法作为中缀运算符包含在内的理由非常充分。几乎肯定不存在任何其他二元运算,将来会证明将任何其他中缀运算符添加到 Python 中是合理的。
向数组类型添加 .dot 方法,以便允许“伪中缀”A.dot(B) 语法: numpy 中已经存在这种方法多年了,在许多情况下,它比 dot(A, B) 更好。但它仍然不如真正的中缀表示法易读,尤其是在括号过多方面。请参见上面 为什么矩阵乘法应该是中缀的?。
使用 ‘with’ 块在单个代码块中切换 * 的含义:例如,numpy 可以定义一个特殊的上下文对象,以便我们有
c = a * b # element-wise multiplication
with numpy.mul_as_dot:
c = a * b # matrix multiplication
但是,这有两个严重的问题:首先,它要求每个类数组类型的 __mul__
方法知道如何检查某些全局状态(numpy.mul_is_currently_dot
或其他什么)。如果 a
和 b
是 numpy 对象,那么这很好,但世界上还有许多非 numpy 类数组对象。因此,这要么需要非局部耦合——每个 numpy 竞争库都必须导入 numpy,然后在每次操作时检查 numpy.mul_is_currently_dot
——要么破坏鸭子类型,上述代码根据 a
和 b
是 numpy 对象还是其他类型的对象执行截然不同的操作。其次,更糟糕的是,with
块是动态作用域的,而不是词法作用域的;也就是说,在 with
块内部调用的任何函数都会突然发现自己正在 mul_as_dot 世界中执行,并且会崩溃并严重出错——如果你幸运的话。因此,这是一种只能在相当有限的情况下(无函数调用)安全使用的结构,并且很容易在没有警告的情况下误伤自己。
使用语言预处理器添加额外的面向数值的运算符,也许还有其他语法:(根据最近 BDFL 的建议:[1])此建议似乎基于数值代码需要各种语法添加的想法。事实上,考虑到 @
,大多数数值用户不需要任何其他运算符或语法;它解决了唯一一个真正痛苦的问题,而其他方法无法解决,并且会导致整个生态系统产生痛苦的连锁反应。仅仅为了支持一个二元运算符而定义一种新的语言(可能具有自己的解析器,该解析器必须与 Python 的解析器保持同步等),既不实用也不可取。在数值上下文中,Python 的竞争对手是专门的数值语言(Matlab、R、IDL 等)。与之相比,Python 的杀手级特性恰恰在于可以将专门的数值代码与用于 XML 解析、网页生成、数据库访问、网络编程、GUI 库等的代码混合在一起,并且我们还可以从各种教程、参考材料、入门课程等中获得巨大好处,这些都使用 Python。将“数值 Python”与“真正的 Python”分离将是造成混淆的主要来源。此 PEP 的主要动机是*减少*分离。不得不设置预处理器将对不熟悉的用户造成特别大的阻碍。而且我们使用 Python 是因为我们喜欢 Python!我们不想要几乎但不是真正的 Python。
使用重载技巧定义“新的中缀运算符”,如 *dot*,如在著名的 Python 技巧中:(参见:[2])美胜于丑。这……不美观。也不符合 Python 风格。尤其对初学者不友好,他们只是试图理解他们正在学习的这些神奇咒语背后存在一个连贯的底层系统,而这时出现了像这样违反该系统的恶意技巧,在意外误用时会产生奇怪的错误消息,并且其底层机制如果不深入了解面向对象系统的工作原理就无法理解。
使用特殊的“外观”类型来支持类似 arr.M * arr 的语法: 这与前面的提议非常相似,因为 .M
属性基本上会返回与 arr *dot
相同的对象,因此在“魔幻性”方面也存在同样的异议。这种方法也有一些不明显的复杂性:例如,虽然 arr.M * arr
必须返回一个数组,但 arr.M * arr.M
和 arr * arr.M
必须返回外观对象,否则 arr.M * arr.M * arr
和 arr * arr.M * arr
将无法工作。但这意味着外观对象必须能够识别其他数组对象和其他外观对象(这为编写来自不同库的交互数组类型增加了额外的复杂性,这些库现在必须识别彼此的数组类型及其外观类型)。它还为用户设置了陷阱,用户可能会很容易地键入 arr * arr.M
或 arr.M * arr.M
并期望返回一个数组对象;相反,他们会得到一个神秘的对象,当他们试图使用它时会抛出错误。基本上,使用这种方法,用户必须小心地将 .M*
视为一个不可分割的单元,充当中缀运算符——就中缀运算符状的标记字符串而言,至少 *dot*
看起来更漂亮(看看它那可爱的耳朵!)。
关于此 PEP 的讨论
收集此处以供参考
- 包含大部分原始讨论和草案的 Github 拉取请求:https://github.com/numpy/numpy/pull/4351
- 早期草案的 sympy 邮件列表讨论
- 早期草案的 sage-devel 邮件列表讨论:https://groups.google.com/forum/#!topic/sage-devel/YxEktGu8DeM
- 2014 年 3 月 13 日 python-ideas 线程:https://mail.python.org/pipermail/python-ideas/2014-March/027053.html
- 关于是否保留
@@
的 numpy-discussion 线程:http://mail.scipy.org/pipermail/numpy-discussion/2014-March/069448.html - 关于
@
的优先级/结合性的 numpy-discussion 线程:* http://mail.scipy.org/pipermail/numpy-discussion/2014-March/069444.html * http://mail.scipy.org/pipermail/numpy-discussion/2014-March/069605.html
参考文献
版权
本文档已进入公有领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0465.rst
上次修改时间:2023-09-09 17:39:29 GMT