Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 465 – 矩阵乘法的专用中缀运算符

作者:
Nathaniel J. Smith <njs at pobox.com>
状态:
最终版
类型:
标准跟踪
创建日期:
2014年2月20日
Python 版本:
3.5
发布历史:
2014年3月13日
决议:
Python-Dev 消息

目录

摘要

本PEP提议为矩阵乘法引入一个新的二元运算符,称为@。(助记:@是mATrices的*。)

规范

Python语言中新增了一个二元运算符,以及对应的原地版本。

运算符 优先级/结合性 方法
@ *相同 __matmul__, __rmatmul__
@= 不适用 __imatmul__

内置类型或标准库类型中未添加这些方法的实现。然而,许多项目已就这些操作的推荐语义达成共识;有关详情,请参阅下面的预期用途详情

有关此运算符如何在CPython中实现的详细信息,请参阅实现详情

动机

执行摘要

在数值代码中,有两个重要的操作争夺Python的*运算符:元素级乘法和矩阵乘法。自Numeric库首次提出以来的近二十年间,人们曾多次尝试解决这一矛盾[13];但均未取得令人满意的结果。目前,大多数数值Python代码使用*进行元素级乘法,并使用函数/方法语法进行矩阵乘法;然而,这在常见情况下会导致代码冗长且难以阅读。问题之严重,以至于大量代码继续使用相反的约定(这种约定在*不同*情况下会产生冗长且难以阅读的代码),而这种跨代码库的API碎片化又产生了更多问题。在当前的Python语法限制下,似乎没有任何*好*的解决方案来设计数值API——只有各种各样以不同方式糟糕的选项。解决这些问题的最小Python语法更改就是添加一个用于矩阵乘法的新中缀运算符。

矩阵乘法具有一种独特的特征组合,使其与其他二元操作区分开来,这些特征共同为添加专用中缀运算符提供了独特的 compelling 理由。

  • 与现有数值运算符一样,大量先行实践支持在所有数学、科学和工程领域使用中缀表示法进行矩阵乘法;@和谐地填补了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这样直观的代码,将变量视为简单的值,然后立即使用此函数高效地对大量值执行此计算,同时通过适合当前问题的任意复杂数组布局来组织它们。

矩阵乘法更像是一个特殊情况。它只在二维数组(也称为“矩阵”)上定义,并且乘法是唯一一个具有重要“矩阵”版本的操作——“矩阵加法”与元素级加法相同;没有“矩阵位或”或“矩阵地板除”这样的东西;“矩阵除法”和“矩阵幂”可以定义但不是很有用,等等。然而,矩阵乘法仍然在所有数值应用领域中大量使用;在数学上,它是最基本的操作之一。

由于Python语法目前只允许一个乘法运算符*,提供类数组对象的库必须做出决定:要么将*用于元素级乘法,要么将其用于矩阵乘法。不幸的是,在进行通用数值计算时,两种操作都经常使用,并且在两种情况下,使用中缀而不是函数调用语法都具有显著优势。因此,目前尚不清楚哪种约定是最佳的,甚至是可接受的;通常这因情况而异。

尽管如此,网络效应意味着我们选择*一个*约定非常重要。例如,在numpy中,技术上可以在不同约定之间切换,因为numpy提供了两种具有不同__mul__方法的类型。对于numpy.ndarray对象,*执行元素级乘法,而矩阵乘法必须使用函数调用(numpy.dot)。对于numpy.matrix对象,*执行矩阵乘法,而元素级乘法需要函数语法。使用numpy.ndarray编写代码运行良好。使用numpy.matrix编写代码也运行良好。但一旦我们尝试将这两部分代码集成在一起,麻烦就开始了。期望ndarray却得到matrix,或反之,可能会崩溃或返回不正确的结果。跟踪哪些函数期望哪些类型作为输入,返回哪些类型作为输出,然后不断进行来回转换,这是极其繁琐且不可能大规模做对的。那些试图防御性地处理两种类型作为输入并DTRT的函数,发现自己陷入了isinstanceif语句的泥潭。

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回归模型的线性假设检验。我刚才所说的这些词的含义并不重要;如果我们发现自己必须实现这个东西,我们会查阅一些教科书或论文,并遇到许多看起来像这样的数学公式:

S = (Hβ − r)T(HVHT) − 1(Hβ − r)

这里各种变量都是向量或矩阵(好奇者可参阅详情:[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)

可读性很重要!使用@的语句更短,包含更多空白,可以直接轻松地相互比较,并与教科书公式进行比较,并且只包含有意义的括号。最后一点对于可读性尤为重要:使用函数调用语法时,每个操作所需的括号会造成视觉混乱,即使对于像这样相对简单的公式,也使得眼睛很难解析出公式的整体结构。眼睛在解析非正则语言方面表现糟糕。我在编写上面的“点”公式时犯了很多错误,并纠正了它们。我知道它们仍然至少包含一个错误,也许更多。(练习:找出它。或它们。)相比之下,@的例子不仅正确,而且一目了然地正确。

如果我们是更复杂的程序员,并且编写的代码期望被重用,那么出于速度或数值精度的考虑,我们可能更倾向于某种特定的求值顺序。因为@使得省略不相关的括号成为可能,所以我们可以确定,如果我们*确实*写了诸如(H @ V) @ H.T之类的代码,那么我们的读者就会知道这些括号一定是故意添加的,以达到某种有意义的目的。在dot的例子中,无法知道哪些嵌套决策是重要的,哪些是任意的。

中缀@极大地提升了矩阵代码在程序员交互所有阶段的可用性。

透明语法对于非专业程序员尤为重要

很大一部分科学代码是由其领域专家而非编程专家编写的。每年都有许多大学课程,如“社会科学家数据分析”,这些课程不要求编程背景,并在10-15周内教授数学技术、编程入门以及如何使用编程实现这些数学技术的组合。这些课程越来越多地使用Python而不是R或Matlab等专用语言进行教学。

对于这类编程知识脆弱的用户来说,公式与代码之间存在透明映射通常意味着能否成功编写代码的区别。这一点如此重要,以至于这类课程通常使用numpy.matrix类型,该类型将*定义为矩阵乘法,尽管该类型存在错误,并且因其造成的碎片化而被numpy社区的其余部分强烈不推荐。这种教学用例,事实上,是numpy.matrix仍然是numpy支持部分*唯一*的原因。添加@将通过更好的语法使初学者和高级用户都受益;此外,它将允许两类用户从一开始就标准化相同的符号,为专业知识的掌握提供更顺畅的途径。

但是矩阵乘法不是一个很小众的需求吗?

世界充满了连续数据,计算机越来越多地被要求以复杂的方式处理这些数据。数组是金融、机器学习、3D图形、计算机视觉、机器人、运筹学、计量经济学、气象学、计算语言学、推荐系统、神经科学、天文学、生物信息学(包括遗传学、癌症研究、药物发现等)、物理引擎、量子力学、地球物理学、网络分析以及许多其他应用领域的通用语言。在这些领域中的大多数或全部,Python正在迅速成为主导者,很大程度上因为它能够优雅地将传统的离散数据结构(哈希表、字符串等)与现代数值数据类型和算法平等地结合起来。

我们都生活在各自的小圈子里,因此一些Python用户可能会惊讶地发现Python在数值计算方面的使用程度之广——尤其考虑到这个子社区的许多活动发生在传统Python/FOSS渠道之外。因此,为了大致了解实际有多少数值Python程序员,这里有两个数字:2013年,有7场专门针对数值Python的国际会议[3] [4]。在PyCon 2014上,约20%的教程似乎涉及矩阵的使用[6]

为了进一步量化这一点,我们使用Github的“搜索”功能查看了各种实际代码(即Github上的所有代码)中实际导入了哪些模块。我们检查了几个流行的标准库模块、各种数值导向模块以及其他一些非常引人注目的模块,如django和lxml(后者是PyPI上下载量排名第一的包)。星号行表示如果本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生态系统中导入量最大的非标准库模块;它甚至比subprocessmathpicklethreading等标准库中坚更常被导入。而且numpy用户仅代表更广泛的数值社区的一部分,这些社区都将受益于@运算符。矩阵可能曾经是一种仅限于在大学实验室和军事集群中运行Fortran程序的利基数据类型,但那些日子早已过去。数值计算是现代Python用法的主流部分。

此外,添加中缀运算符以处理更专业的算术运算也有先例:地板除法运算符//,像位运算符一样,在对离散值执行精确计算的某些情况下非常有用。但很可能许多Python程序员从未有理由使用//(或者,就此而言,位运算符)。@并不比//更小众。

那么@对于矩阵公式很有用,但它们到底有多常见呢?

我们已经看到,@使得矩阵公式对专家和非专家都更容易使用,矩阵公式出现在许多重要应用中,并且像numpy这样的数值库被Python用户群的很大一部分使用。但是数值库不仅仅是关于矩阵公式,而且重要性不一定意味着占用大量代码:如果矩阵公式在平均数值导向项目中只出现一两次,那么仍然不值得添加新运算符。那么矩阵乘法到底有多常见呢?

逆境之时,强者则求实证。为了粗略估计@运算符的实用性,下表显示了在stdlib以及两个知名数值包——scikit-learn机器学习库和nipy神经影像库——中,不同Python运算符的实际使用频率,并按源代码行数(SLOC)进行了标准化。行按“合并”列排序,该列汇集了所有三个代码库。因此,合并列强烈倾向于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(见下文)使用这些非结合律语义和中缀矩阵乘法运算符(它们使用*),并且它们报告称没有因此遇到任何问题。

  • 对于维度超过2的输入,我们将最后两个维度视为要相乘的矩阵的维度,并对其他维度进行“广播”。这提供了一种方便的方法,可以在单个操作中快速计算许多矩阵乘积。例如,arr(10, 2, 3) @ arr(10, 3, 4)执行10次独立的矩阵乘法,每次都将一个2x3矩阵和一个3x4矩阵相乘,生成一个2x4矩阵,然后将这10个结果矩阵一起返回到一个形状为(10, 2, 4)的数组中。这里的直觉是,我们将这些3d数字数组视为1d*矩阵数组*,然后以元素级方式应用矩阵乘法,其中现在每个“元素”都是一个完整的矩阵。请注意,广播不限于完美对齐的数组;在更复杂的情况下,它允许使用一些简单但强大的技巧来控制数组彼此对齐的方式;有关详细信息,请参见[10]。(特别是,事实证明,在考虑广播时,标准标量*矩阵乘积是元素级乘法运算符*的特例。)

    如果一个操作数是大于2维的,而另一个操作数是1维的,那么上述规则保持不变,在广播之前执行1维到2维的提升。例如,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原则。

采纳

我们根据现有Python项目目前用于元素级乘法和矩阵乘法的API,对其提供类数组或类矩阵类型的项目进行分组。

目前使用*进行元素级乘法,并使用函数/方法调用进行矩阵乘法的项目

以下项目的开发人员已表示打算在其类数组类型上使用上述语义实现@

  • numpy
  • pandas
  • blaze
  • theano

以下项目已收到PEP的通知,但尚未知晓如果PEP获得批准,他们打算怎么做。不过,我们预计他们不会有任何异议,因为这里提出的所有内容都与他们已有的做法一致。

  • pycuda
  • panda3d

目前使用*进行矩阵乘法,并使用函数/方法调用进行元素级乘法的项目

如果本PEP被接受,以下项目已表示打算将其当前的API迁移到元素级*,矩阵乘法@的约定(即,这是一个如果本PEP被接受,其API碎片化可能会被消除的项目列表)

  • numpy (numpy.matrix)
  • scipy.sparse
  • pyoperators
  • pyviennacl

以下项目已收到本PEP的存在通知,但尚未知晓如果本PEP被接受,它们计划如何处理(即,如果本PEP被接受,其API碎片化可能或不可能被消除的项目列表)

  • cvxopt

目前使用*进行矩阵乘法,且不关心矩阵元素级乘法的项目

有几个项目实现了矩阵类型,但与上面讨论的数值库的视角大相径庭。这些项目侧重于分析作为抽象数学对象(即,环上自由模上的线性映射)的矩阵的计算方法,而不是将其视为一堆需要处理的数字。事实证明,从抽象数学的角度来看,元素级操作本身用处不大;正如上面背景部分所讨论的,元素级操作是由“一堆数字”的方法驱动的。因此,这些项目没有遇到本PEP旨在解决的基本问题,使其与它们大多无关;虽然它们表面上与numpy等项目相似,但它们实际上做的是完全不同的事情。它们使用*进行矩阵乘法(以及群作用等),如果本PEP被接受,它们的明确意图是继续这样做,同时可能添加@作为别名。这些项目包括

  • sympy
  • sage

实现细节

新的函数operator.matmuloperator.__matmul__以常用语义添加到标准库中。

C API 中添加了一个相应的函数 PyObject* PyObject_MatrixMultiply(PyObject *o1, PyObject *o2)

新增了一个名为MatMult的AST节点,以及一个新的ATEQUAL令牌和新的字节码操作码BINARY_MATRIX_MULTIPLYINPLACE_MATRIX_MULTIPLY

增加了两个新的类型槽;是加入PyNumberMethods还是新的PyMatrixMethods结构体仍有待确定。

规范详情的理由

运算符的选择

为什么选择@而不是其他拼写?其他编程语言对于这个运算符的命名没有达成共识[11];这里我们讨论各种选项。

仅限于美国英语键盘上的符号,在Python表达式上下文中没有特定含义的标点符号包括:@、反引号、$!?。在这些选项中,@显然是最好的;!?在编程语境中已经承载了许多不适用的含义,反引号已被BDFL宣布禁止使用(参见PEP 3099),而$更丑陋,与*更不相似,并且带有Perl/PHP的包袱。$可能是这些选项中第二好的。

US英语键盘上不存在的符号一开始就处于显著劣势(在每个数值Python教程开始时花5分钟讲解键盘布局不是任何人都想遇到的麻烦)。此外,即使我们设法克服了输入问题,也不清楚是否有任何符号实际上比@更好。一些建议的选项包括

  • U+00D7 乘号:A × B
  • U+22C5 点运算符:A B
  • U+2297 圆圈叉:A B
  • U+00B0 度:A ° B

然而,我们需要的运算符意思是“矩阵乘法,而不是标量/元素级乘法”。在编程或数学中,没有具有此含义的常规符号,这些操作通常通过上下文区分。(而U+2297 CIRCLED TIMES实际上通常用来表示完全错误的东西:元素级乘法——“Hadamard乘积”——或外积,而不是像我们运算符那样的矩阵/内积)。@至少具有它*看起来*像一个有趣的非交换运算符的优点;一个懂数学但不懂编程的初学者,无法通过查看A * BA × B,或A * BA B,或A * BA ° B来猜测哪个是常规乘法,哪个是特殊情况。

最后,还有使用多字符令牌的选项。一些选项包括

  • Matlab 和 Julia 使用 .* 运算符。除了在视觉上容易与 * 混淆外,这对我们来说是一个糟糕的选择,因为在 Matlab 和 Julia 中,* 表示矩阵乘法,而 .* 表示元素级乘法,所以使用 .* 进行矩阵乘法将使我们与 Matlab 和 Julia 用户的期望完全相反。
  • APL显然使用+.×,它结合了多字符令牌,令人困惑的类似属性访问的点语法,以及一个Unicode字符,在我们的候选列表中排名低于U+2603 SNOWMAN。如果我们喜欢将加法和乘法运算符结合起来的想法,因为它能唤起矩阵乘法的实际工作方式,那么可以使用像+*这样的东西——尽管这可能太容易与*+混淆,后者只是乘法与一元+运算符的结合。
  • PEP 211 建议使用 ~*。这有一个缺点,它似乎暗示存在一个与一元 ~ 结合的一元 * 运算符,但它可能奏效。
  • R语言使用%*%进行矩阵乘法。在R中,这构成了通用可扩展中缀系统的一部分,其中所有形如%foo%的令牌都是用户定义的二元运算符。我们可以在不窃取系统的情况下窃取令牌。
  • 一些其他 plausible 候选者已被提出:>< (= 乘号 × 的 ASCII 绘图);脚注运算符 [*]|*| (但在上下文中,使用垂直分组符号往往会重新产生嵌套括号的视觉混乱,这被认为是函数语法的主要缺点之一,我们正试图摆脱这种混乱);^*

所以,差别不大,但@似乎和任何替代方案一样好,甚至更好。

  • 这是一个友好的字符,Python程序员已经习惯在装饰器中输入它,但装饰器的用法和数学表达式的用法足够不同,以至于在实践中很难混淆它们。
  • 它在各种键盘布局中都广泛可用(由于它在电子邮件地址中的使用,即使是手机等奇怪的键盘也如此)。
  • 它像*一样是圆形的。
  • mATrices 助记符很可爱。
  • 其漩涡状的形状令人联想到定义矩阵乘法的行和列同时遍历。
  • 它的不对称性唤起其非交换性质。
  • 随便吧,我们总得选一个。

优先级和结合性

关于@应该是右结合还是左结合(甚至更异国情调的[18])有过漫长的讨论[15]。几乎所有Python运算符都是左结合的,因此遵循这一约定将是最简单的方法,但有两个论点表明矩阵乘法可能值得作为特殊情况使其右结合。

首先,矩阵乘法与函数应用/组合有着紧密的概念关联,因此许多数学上复杂的 C++ 用户直觉上认为像 RSx 这样的表达式是从右到左进行的,首先 S 转换向量 x,然后 R 转换结果。这并非普遍认同(并非所有数值计算者都沉浸在激发这种直觉的纯数学概念框架中 [16]),但至少这种直觉比其他运算(如 2⋅3⋅4,所有人都认为是从左到右)更常见。

其次,如果像Mat @ Mat @ vec这样的表达式经常出现在代码中,那么如果将其评估为Mat @ (Mat @ vec),程序将运行得更快(并且注重效率的程序员将能够使用更少的括号),而不是将其评估为(Mat @ Mat) @ vec

然而,与这些论点相悖的是以下几点:

关于效率论点,从经验上看,我们未能找到任何证据表明Mat @ Mat @ vec类型的表达式在实际代码中占据主导地位。解析了一些使用numpy的大型项目后,我们发现当numpy当前函数调用语法强制选择嵌套调用dot的运算顺序时,人们实际使用左结合嵌套的频率略*多于*右结合嵌套[17]。而且,无论如何,编写括号也不是那么糟糕——如果一个注重效率的程序员愿意花精力思考评估某个表达式的最佳方式,他们可能*应该*写下括号,无论是否需要,只是为了让下一个读者清楚运算顺序很重要。

此外,事实证明,其他语言,包括那些更注重线性代数的语言,其矩阵乘法运算符绝大多数都是左结合的。具体来说,R、Matlab、Julia、IDL和Gauss中的@等效运算符都是左结合的。我们发现的唯一例外是Mathematica,其中a @ b @ c将被解析为非结合的dot(a, b, c),以及APL,其中所有运算符都是右结合的。似乎不存在任何语言将@设置为右结合,将*设置为左结合。而且这些决定似乎没有争议——我从未见过有人抱怨这些其他语言的特定方面,而且*的左结合性似乎没有困扰使用*进行矩阵乘法的现有Python库的用户。因此,至少我们可以得出结论,将@设置为左结合肯定不会造成任何灾难。而将@设置为右结合,则是在探索新的不确定领域。

左结合的另一个优点是,学习和记住@的行为像*要容易得多,而不是先记住@与其他Python运算符不同,它是右结合的,然后在此基础上,还要记住它与*相比是更紧密还是更松散地绑定。(右结合性迫使我们选择一个优先级,而关于哪个优先级更有意义的直觉大致相同。因此这表明,无论我们做出何种选择,都没有人能够猜测或记住它。)

因此,总的来说,数值计算社区的普遍共识是,尽管矩阵乘法有些特殊,但其特殊性不足以打破规则,@的解析方式应与*相同。

内置类型的(非)定义

内置数值类型(floatint等)或numbers.Number层次结构中没有定义__matmul____matpow__,因为这些类型代表标量,而@的共识语义是它应该在标量上引发错误。

我们目前没有在标准memoryviewarray.array对象上定义__matmul__方法,原因有几点。当然,如果有人需要,可以添加,但这些类型在用于数值工作之前需要比__matmul__更多的额外工作——例如,它们也无法进行加法或标量乘法!——而添加此类功能超出了本PEP的范围。此外,提供高质量的矩阵乘法实现非常复杂。朴素的嵌套循环实现速度非常慢,在CPython中发布这样的实现只会给用户制造陷阱。但替代方案——提供一个现代的、有竞争力的矩阵乘法——将要求CPython链接到一个BLAS库,这带来了一系列新的复杂问题。特别是,一些流行的BLAS库(包括OS X上默认附带的库)目前会破坏multiprocessing的使用[8]。综上所述,这些考虑因素意味着在这些类型上添加__matmul__的成本/效益并不存在,因此目前我们将继续将这些问题委托给numpy和相关库,并将更系统的解决方案推迟到未来的提案。

还有一些非数值的Python内置类型定义了__mul__str, list, …)。我们也不为这些类型定义__matmul__,因为我们为什么要这样做呢?

矩阵幂的非定义

本PEP的早期版本还提出了一个矩阵幂运算符@@,类似于**。但经过进一步考虑,认为其效用尚不明确,最好暂时搁置,待对@有更多经验后,如果确实非常需要@@,再重新讨论此问题。[14]

添加新运算符的替代方案被拒绝

在过去的几十年中,Python 数值社区探索了多种方法来解决矩阵乘法和逐元素乘法操作之间的矛盾。PEP 211PEP 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 或其他)。如果 ab 是 numpy 对象,这没有问题,但世界包含许多非 numpy 的类似数组对象。因此,这要么需要非局部耦合——每个 numpy 竞争库都必须导入 numpy,然后对每个操作检查 numpy.mul_is_currently_dot——要么它会破坏鸭子类型,上述代码会根据 ab 是 numpy 对象还是其他类型的对象而执行截然不同的操作。第二,更糟糕的是,with 块是动态作用域的,而不是词法作用域的;也就是说,任何在 with 块内被调用的函数都会突然发现自己在 mul_as_dot 世界中执行,然后 horribly 地崩溃——如果你足够幸运。因此,这种构造只能在相当有限的情况下(没有函数调用)安全使用,并且会让你非常容易地在没有警告的情况下自食其果。

使用语言预处理器添加额外的面向数值的运算符以及可能其他的语法:(根据 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.Marr * arr.M 必须返回门面对象,否则 arr.M * arr.M * arrarr * arr.M * arr 将无法工作。但这意味门面对象必须能够识别其他数组对象和其他门面对象(这为编写来自不同库的互操作数组类型带来了额外的复杂性,这些库现在必须识别彼此的数组类型和它们的门面类型)。它还为用户制造了陷阱,他们可能很容易键入 arr * arr.Marr.M * arr.M,并期望返回一个数组对象;相反,他们将得到一个神秘的对象,在尝试使用它时会抛出错误。基本上,使用这种方法,用户必须小心地将 .M* 视为一个不可分割的单元,它作为一个中缀运算符——而就中缀运算符类标记字符串而言,至少 *dot* 更漂亮(看看它可爱的小耳朵!)。

关于此PEP的讨论

此处收集以供参考

参考资料


来源:https://github.com/python/peps/blob/main/peps/pep-0465.rst

最后修改:2025-02-01 08:59:27 GMT