机器学习:爱因斯坦的小贡献

怎么,爱因斯坦(Albert Einstein)那会儿就有数据科学了吗?

倒不是这个意思,爱因斯坦也没有提出什么数学理论,但他提出了一个针对数学公式的符号简化办法,即爱因斯坦求和约定(Einstein Summation Convention)或者叫爱因斯坦标记法(Einstein Notation)。

他这套方法不仅方便了相关理论的书写,而且意外地给如今数据科学中编程实现相关计算带来了方便,让我们来看看到底怎么回事。

1引言

先瞄一眼爱因斯坦当年在论文广义相对论的基础里涉及的一些数学公式。

看着比较繁琐吧,如果学过微分几何的应该熟悉这套的。

意大利数学家雷戈里奥·里奇(Gregorio Ricci-Curbastro)在研究黎曼、克里斯托费尔等人的黎曼几何微分不变量时提出了绝对微分学,然后与他学生勒维·季维塔(Levi-Givita)创立了张量分析。而黎曼几何和张量分析正是爱因斯坦广义相对论的数学基础。

要注意的是,张量分析中的张量指一种特殊的多重线性映射,在相关基底给定的情况下可以用一个多维数组表示,而 NumPyPyTorch 等软件包中的张量特指多维数组。

在张量分析中,为了分别处理张量随坐标变换的协变和逆变,引入了上下标,上标表示逆变张量的分量,下标表示协变张量的分量,它们根据基底的变化分别进行逆变或协变。

通常,当处理协变张量和逆变张量时,其中上下标的位置也指示了张量类型以及缩并方式。

但是,爱因斯坦求和约定也可以有不同应用方式。例如,更一般的情况是坐标基底固定,或者不考虑坐标的情况时,则可以选择仅使用下标。在 NumPyPyTorch 等程序中涉及的更多情况就是这个样子。

像向量内积,矩阵-向量乘法,以及矩阵乘法都可以用这套标记法来简化书写。

例如向量内积,

矩阵 乘以向量 ,

可以记为,

而矩阵 和 相乘,

可以记为,

2NumPyeinsum

本文主要介绍 NumPy 中实现的爱因斯坦求和约定,而像 PyTorchTensorFlow 等情况基本类似。

这里主要拿来处理高维数组的相关计算,并不需要上下标那一套,而是统一针对下标来简化求和连加符。使用时需要明确的是下面这几点,

  • 哪几个输入数组参与运算
  • 沿输入的哪些轴(维度)计算乘积
  • 输出数组需要保留哪些轴

具体反映在函数 np.einsum 的参数上,比如下图所示的例子,三个颜色对应三个输入张量,分别是 2 维数组,3 维数组和 2 维数组,而输出张量是 2 维数组。

看个例子

为了快速了解 einsum,我们直接来看一个向量乘以矩阵的例子。

import numpy as np
A = np.array([0,1,2])A
array([0, 1, 2])
B = np.arange(12).reshape(3, 4)B
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])

将 A 看成行向量,然后乘以(矩阵乘法)B,得到一个 1x4 的向量。

# 这样吗?A*B

我们知道,NumPy 这里的运算是元素级别的运算,因此是通过广播机制将 A 扩展为与 B 同样大小的二维数组。但是,很可惜,此处 Ashape(3),而 Bshape(3,4),它们并不符合广播的条件,因此出错。

ValueError: operands could not be broadcast together with shapes (3,) (3,4)

增加一个新轴,让 Ashape 变成 (3,1),然后就可以广播了。如果不清楚 NumPy 的广播机制,可以出门左拐看另一篇哦。

C = A[:, np.newaxis]*B 
C
array([[ 0,  0,  0,  0],       [ 4,  5,  6,  7],       [16, 18, 20, 22]])

然后将每一列的数字加起来,得到一个有 4 个元素的向量。

C.sum(axis=0)
array([20, 23, 26, 29])
# 组合在一起
(A[:, np.newaxis]* B).sum(axis=0)
array([20, 23, 26, 29])

使用爱因斯坦求和约定来实现,将变得更加简洁高效。

D = np.einsum('i,ij->j', A, B)
D
array([20, 23, 26, 29])

字符串 'i,ij->j''->' 分成了两部分,它左边的 'i,ij' 对应两个输入,而右边的 'j' 对应输出。输出中没有下标 'i',说明对两个输入沿着这个下标求和,而 j 所在的轴仍然保留。而 j 下标有 034 个值,因此最终得到一个有 4 个元素的向量。对应如下公式,

对比一下性能,

%timeit (A[:, np.newaxis]* B).sum(axis=0)
3.46 µs ± 429 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit np.einsum('i,ij->j', A, B)
1.18 µs ± 34.1 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

当然,这个简单例子我们自然可以用内积 dot 来计算。这也是为数不多的性能比爱因斯坦求和约定更好的情况,而大多数情况下,爱因斯坦求和约定的性能更优。

%timeit A.dot(B)
655 ns ± 12.5 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
# 或者
np.dot(B.T,A)
array([20, 23, 26, 29])

但是,einsum 可以很方便地实现如下计算,

E = np.einsum('i,ij->i', A, B)
E
array([ 0, 22, 76])

此时,输出中没有下标 'j',说明对两个输入沿着这个下标求和,而 i 所在的轴仍然保留。而 i 下标有 023 个值,因此最终得到一个有 3 个元素的向量。对应如下公式,

注意,连加符里的下标会消失,但没有出现在连加符里的下标以及相应的轴会保留。

使用说明

一般来说,使用 einsum 时是为了对输入的一些数组沿着某些轴作乘积运算,那对这些轴当然有一定要求,比如沿着相同标号的轴的元素个数一样多。

具体操作时,不妨先写出你要计算的数学公式,然后把连加符去掉,再根据输入输出的下标确定 einsum 中的参数。

下图给出一个例子,先写出计算公式,再转化为 np.einsum 里的字符串。

关键是为输入数组的轴和我们想要输出的数组选择正确的标签。可以选择两种方式之一执行此操作,使用字符串或者使用整数列表。这里使用前者,即使用字符串来表达数学公式。

一个很好的例子是矩阵乘法,它将行与列相乘,然后对乘积结果求和。

  • 对于两个二维数组 AB,矩阵乘法操作可以用 np.einsum('ij,jk->ik', A, B) 完成。
  • 字符串 'ij,jk->ik' 是什么意思?
  • 它被箭头 -> 分成两部分。左侧部分标记输入数组的轴:'ij' 标记 A 以及 'jk' 标记 B。字符串的右侧部分用字母 'ik' 标记单个输出数组的轴。
  • 即输入两个二维数组,输出一个新的二维数组。
A = np.array([[1,1,1],
              [2,2,2],
              [5,5,5]])

B = np.array([[0,1,0],
              [1,1,0],
              [1,1,1]])

np.einsum('ij,jk->ik', A, B)
array([[ 2, 3, 1],
[ 4, 6, 2],
[10, 15, 5]])

看下图,注意轴的颜色,j 所在的轴被缩并掉了。

真相,'ij,jk->ik' 背后对应的数学公式

np.einsum 可以替代如下常用的运算

  • 矩阵求迹: trace
  • 求矩阵对角线: diag
  • 张量(沿轴)求和: sum
  • 张量转置: transopose
  • 矩阵乘法: dot
  • 张量乘法: tensordot
  • 向量内积: inner
  • 外积: outer
a = np.array([1,1,1])b = np.array([2,3,4])
%%timeit 
np.inner(a, b)
765 ns ± 13.2 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%%timeit 
np.einsum('i,i->', a, b)
1.06 µs ± 2.13 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%%time
np.outer(a,b)
CPU times: user 44 µs, sys: 0 ns, total: 44 µsWall time: 46.7 µs

array([[2, 3, 4],       [2, 3, 4],       [2, 3, 4]])
# 用 einsum 计算外积
np.einsum('i,j->ij',a,b)
array([[2, 3, 4],       [2, 3, 4],       [2, 3, 4]])
%%timeit 
np.einsum('i,j->ij',a,b)
1.18 µs ± 2.12 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

求迹

a = np.arange(9).reshape((3,3))
a
array([[0, 1, 2],       [3, 4, 5],       [6, 7, 8]])
np.trace(a)
12
np.einsum('ii->',a)
12
# 或者
np.einsum('ii',a)
12

矩阵乘法 dot

# 1d
a = np.array([1,1,1])
b = np.array([2,3,4])
%%time# dota.dot(b)
CPU times: user 20 µs, sys: 0 ns, total: 20 µs
Wall time: 23.4 µs

9

%%timenp.einsum('i,i->', a,b)
CPU times: user 47 µs, sys: 1 µs, total: 48 µs
Wall time: 53.4 µs

9

# 2da = np.arange(20).reshape(4,5)b = np.arange(15).reshape(5,3)
%%time
# dot
a.dot(b)
CPU times: user 33 µs, sys: 0 ns, total: 33 µsWall time: 38.6 µs

array([[ 90, 100, 110],       [240, 275, 310],       [390, 450, 510],       [540, 625, 710]])
%%time
a@b
CPU times: user 48 µs, sys: 1e+03 ns, total: 49 µsWall time: 53.9 µs

array([[ 90, 100, 110],       [240, 275, 310],       [390, 450, 510],       [540, 625, 710]])
%%time
np.einsum('ij,jk->ik', a,b)
CPU times: user 30 µs, sys: 0 ns, total: 30 µsWall time: 32.4 µs

array([[ 90, 100, 110],       [240, 275, 310],       [390, 450, 510],       [540, 625, 710]])

张量 dot

  • 方法 np.tensordot 是沿着轴指定的子数组做点乘操作,即沿着 axes 指出的轴求和,最终这些轴就消失了。从这个例子可以看出,这个方法虽然使用方便,但性能远不如上面几个。
%%time
np.tensordot(a, b, axes=([1,],[0,]))
CPU times: user 209 µs, sys: 4 µs, total: 213 µsWall time: 211 µs

array([[ 90, 100, 110],       [240, 275, 310],       [390, 450, 510],       [540, 625, 710]])

思考,'ij,jk->ijk' 是在干嘛?

r = np.einsum('ij,jk->ijk', a, b)
r
array([[[  0,   0,   0],        [  3,   4,   5],        [ 12,  14,  16],        [ 27,  30,  33],        [ 48,  52,  56]],

       [[  0,   5,  10],        [ 18,  24,  30],        [ 42,  49,  56],        [ 72,  80,  88],        [108, 117, 126]],

       [[  0,  10,  20],        [ 33,  44,  55],        [ 72,  84,  96],        [117, 130, 143],        [168, 182, 196]],

       [[  0,  15,  30],        [ 48,  64,  80],        [102, 119, 136],        [162, 180, 198],        [228, 247, 266]]])
常用 einsum 操作

1 维数组

举例

a = np.arange(9)
np.einsum('i', a)
array([0, 1, 2, 3, 4, 5, 6, 7, 8])
np.einsum('i->', a)
36
np.einsum('i,i', a, a)
204
np.einsum('i,i->', a, a)
204
r = np.einsum('i,j->ij', a, a)
r
array([[ 0,  0,  0,  0,  0,  0,  0,  0,  0],       [ 0,  1,  2,  3,  4,  5,  6,  7,  8],       [ 0,  2,  4,  6,  8, 10, 12, 14, 16],       [ 0,  3,  6,  9, 12, 15, 18, 21, 24],       [ 0,  4,  8, 12, 16, 20, 24, 28, 32],       [ 0,  5, 10, 15, 20, 25, 30, 35, 40],       [ 0,  6, 12, 18, 24, 30, 36, 42, 48],       [ 0,  7, 14, 21, 28, 35, 42, 49, 56],       [ 0,  8, 16, 24, 32, 40, 48, 56, 64]])
np.sum(r)
1296
np.einsum('i,j->', a, a)
1296

2 维数组

举例

# 2d
A = np.arange(12).reshape(4,3)
B = np.arange(12).reshape(4,3)
np.einsum('ij,kj->ik', A, B)
array([[ 5, 14, 23, 32],
[ 14, 50, 86, 122],
[ 23, 86, 149, 212],
[ 32, 122, 212, 302]])
np.einsum('ij,kj->ikj', A, B).shape
(4, 4, 3)
np.einsum('ij,kl->ijkl', A, B).shape
(4, 3, 4, 3)

3不同方法 PK

张量运算看着挺复杂的,但也有迹可循,而且有很多方法来实现同一个运算。最后再来一个例子,比较一下张量运算的不同实现及其性能。

  • 创建一维数组 a,[0,1,...,59],将其转化为 shape(3,4,5)`` 的三维数组A`
  • 创建一维数组 b,[0,1,...,23],将其转化为 shape(4,3,2) 的三维数组 B
  • A 转置成 shape(4,3,5) 的数组 D
  • 即用两到三种方法计算如下公式:
A = np.arange(60.).reshape(3,4,5)B = np.arange(24.).reshape(4,3,2)

方法一

直接使用 np.tensordot

%%timeit
C = np.tensordot(A, B, axes=([1,0],[0,1]))
12.6 µs ± 409 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

方法二

利用 NumPy 的广播机制。

D = np.transpose(A,[1,0,2])
D.shape
(4, 3, 5)
%%timeit
np.sum(D[...,None]*B[:,:,None,:],(0,1))
5.4 µs ± 43.7 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

方法三

使用 np.einsum

%%timeit 
np.einsum('jik,ijl->kl', A, B)
2.66 µs ± 10.2 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

方法四

实际上,这个例子比较特殊,我们可以想一个办法来进一步提高计算性能,那就是通过变形数组来使得可以用 dot 来代替该运算。而我们知道 dot 得到专门优化,所以性能杠杠的。但并不是所有运算都这么幸运,不一定能这么转化哦。

%%timeit 
A.T.reshape(A.shape[-1], -1).dot(B.reshape(-1, B.shape[-1]))
1.94 µs ± 13.9 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

注意,计算结果都是一样的,

array([[4400., 4730.],
       [4532., 4874.],
       [4664., 5018.],
       [4796., 5162.],
       [4928., 5306.]])

最后看下本文使用的 numpy 版本,不同版本会有不同表现哦。

np.version.version
'1.15.2'

本文通过一些例子展示了爱因斯坦求和约定的强大功能,那或许你会问: 在实际写算法时有人用吗?自然是有大量使用的,毕竟这个工具可以完成各种复杂计算,可谓张量(高维数组)计算大杀器,完全值得花点时间学习一番。

(0)

相关推荐