不同 shape numpy ndarray 广播运算

不同 shape numpy ndarray 广播运算

numpy 是做数值计算的标配工具。我们写模型、清洗数据的时候,经常要拿两个形状不一样的数组直接做加减乘除。新手碰到这种情况,多半会被一句 operands could not be broadcast together 卡住,盯着报错半天,不知道到底是哪一维对不上。

接下来,我们把 ndarray 的运算分成三类来讲,分别是数组和数字、同形数组之间、异形数组之间,重点放在最后一类的广播规则。我们会拿多组具体的数组一对一对着算,把形状从右往左对齐一遍,再把结果数值算出来。学完之后,再看到广播报错,我们就能自己判断两个数组到底能不能算,结果会变成什么形状。读这一节之前,只要知道 numpy 数组怎么建,shape 这个属性看的是每一维的长度就够了。

1. 标量运算

我们先从最简单的一种说起,也就是数组和单个数字做四则运算。这个数字在 numpy 里叫标量,它自己没有形状。你大概不会想手写一个循环,把数组里每个元素都加一遍 100,numpy 也不这么要求,它直接把这个数字作用到数组的每一个元素上。

import numpy as np


def test():
    nd = np.arange(9).reshape((3, 3))
    print(nd)
    print("-" * 10)

    ret1 = nd + 100
    ret2 = nd * 10
    ret3 = nd / 10
    ret4 = nd - 5

    print(ret1)


if __name__ == "__main__":
    test()

我们看输出结果:

[[0 1 2]
 [3 4 5]
 [6 7 8]]
----------
[[100 101 102]
 [103 104 105]
 [106 107 108]]

原来的 3 乘 3 数组,每个元素都加了 100。第 0 行的 0、1、2 变成 100、101、102,其余两行同理。这就是标量运算的规则:数组和数字运算,等价于数组里每一个元素都和这个数字单独算一遍,结果形状和原数组一模一样。

2. 同形运算

数组和数字讲完,我们再看两个数组形状完全相同的情况。这时候规则更直接,numpy 不做任何扩展,就是两个数组同位置的元素两两做运算。

def test():
    nd1 = np.arange(1, 10).reshape((3, 3))
    nd2 = np.arange(11, 20).reshape(3, 3)

    print(nd1)
    print("-" * 10)
    print(nd2)
    print("-" * 10)

    ret1 = nd1 + nd2
    ret2 = nd1 - nd2
    ret3 = nd1 * nd2
    ret4 = nd1 / nd2

    print(ret1)


if __name__ == "__main__":
    test()

输出结果如下:

[[1 2 3]
 [4 5 6]
 [7 8 9]]
----------
[[11 12 13]
 [14 15 16]
 [17 18 19]]
----------
[[12 14 16]
 [18 20 22]
 [24 26 28]]

两个数组都是 3 乘 3,相加结果还是 3 乘 3。第一行 1 加 11 得 12,2 加 12 得 14,3 加 13 得 16,第二行第三行按同样的方式对位相加。同形状数组之间运算,就是相同位置的元素做相应运算,没有任何悬念。

3. 广播规则

真正麻烦、也最能体现 numpy 方便之处的,是两个形状不同的数组直接运算。它不会报错,而是按一套自动对齐的规则,把小一点的数组拉成大数组的形状再算,这套机制就叫广播。

规则听一句话就够了。两个数组的维度从右往左对齐,逐位比较。每一位上,要么两边长度相等,要么其中一边长度是 1,要么某一边干脆没有这一维,这样就合法。结果在这一位取较长的那个长度,长度为 1 的那一边会被复制拉长。任何一位对不上,既不相等也没有一边是 1,numpy 就直接报错。

这里说的复制,只是逻辑上的概念。numpy 并不会真的把数据在内存里多复制几份,只是在计算时按拉长后的方式去取元素。这样既写起来省事,又不额外占内存。我们理解规则的时候,脑子里把它画成复制拉长就行。

4. 配对演算

规则背下来还是抽象,我们拿几组具体的数组一对一对着算最清楚。先用那个经典例子,nd1 是一维数组 [1, 2, 3],形状 (3,),nd2 是 3 行 1 列的数组,形状 (3, 1)。这两个能不能加,对一下形状就知道。

def test():

    nd1 = np.array([1, 2, 3])
    nd2 = np.arange(3).reshape((3, 1))

    print(nd1)
    print('-' * 20)
    print(nd2)

    # 列数必须相同
    ret1 = nd1 + nd2
    # ret2 = nd1 * nd2
    print('-' * 20)
    print(ret1)


if __name__ == "__main__":
    test()

nd1 形状 (3,) 是一维,nd2 形状 (3, 1) 是二维,维度数不一样。我们给 nd1 左边补一个 1,变成 (1, 3)。从右往左逐位比:最右边 nd1 是 3,nd2 是 1,有一边是 1,合法,这一位取 3。再往左,nd1 补出来的 1 对 nd2 的 3,又有一边是 1,合法,这一位也取 3。结果形状就是 (3, 3),对照如下:

对齐位置右维(列)左维(行)
nd1 原形状 (3,)补成后为 1 列对 3补出来的 1
nd2 原形状 (3, 1)13
逐位判定有一边是 1,取 3有一边是 1,取 3
结果形状 (3, 3)3 列3 行

合法之后,两个数组都被拉成 (3, 3) 再对位相加。nd1 本来是一行 [1, 2, 3],行方向只有 1 个长度,被复制成三行,得到 [[1,2,3],[1,2,3],[1,2,3]]。nd2 本来是一列 [[0],[1],[2]],列方向只有 1 个长度,被复制成三列,得到 [[0,0,0],[1,1,1],[2,2,2]]。两个拉好的 3 乘 3 数组按同形规则相加,输出就是:

[1 2 3]
--------------------
[[0]
 [1]
 [2]]
--------------------
[[1 2 3]
 [2 3 4]
 [3 4 5]]

我们数一下也对得上。第一行 [1,2,3] 加 [0,0,0] 得 [1,2,3],第二行加 [1,1,1] 得 [2,3,4],第三行加 [2,2,2] 得 [3,4,5]。

接下来,我们把更多常见的形状配对列在一起,照着右对齐的方法自己判断一遍,比死记规则管用:

数组 A 形状数组 B 形状右对齐逐位比较结果形状能否运算
(3, 3)标量 100标量在所有维上广播(3, 3)能
(3,)(3, 3)补成 (1, 3) 对 (3, 3)(3, 3)能
(3, 1)(3, 3)3 对 3,1 对 3(3, 3)能
(2, 3)(3,)补成 (2, 3) 对 (1, 3),2 对 1,3 对 3(2, 3)能
(2, 3)(2,)补成 (2, 3) 对 (1, 2),末位 3 对 2无报错

最后一行就是报错的典型。(2, 3) 和 (2,) 对齐后,末位是 3 对 2,既不相等,两边也都不是 1,没有任何一边可以拉长去凑另一边,numpy 只能抛出 operands could not be broadcast together。

5. 常见误区

规则清楚之后,我们再点几个新手常踩的坑。

第一个误区,以为广播是把小数组空缺的位置补 0。其实长度为 1 的那一维,是把那个元素原样复制拉长,不是填 0。前面 nd2 那列 [[0],[1],[2]] 拉成 (3,3) 后,第二行变成 [1,1,1],是把 1 重复三遍,不是补别的值。这个区别直接影响结果数值,按补 0 去心算一定会算错。

第二个误区,把 (3,) 和 (3,1) 当成一回事。形状 (3,) 是一维数组,它到底当行还是当列,要等和另一个数组配对、右对齐时才知道。(3,1) 则是明确的二维列向量。两者和 (3,3) 相加结果都是 (3,3),但和别的数组配对时行为不同。比如 (3,) 和 (3,) 相加是逐元素得到 (3,),而 (3,1) 和 (3,) 相加,按上面的规则却是 (3,3),差别就来自维数。

第三个误区,看报错时不顺着右对齐找问题。numpy 报错里会把两个操作数的形状原样列出来,我们按从右往左逐位比,哪一位既不等又都不是 1,问题就出在哪一位。先补维度少的那个,再逐位对,比盯着报错文字猜快得多。

我们回到开头那个报错。数组和数字运算,就是每个元素都和这个数字算一遍。同形数组是对位元素两两算。异形数组则走广播,维度右对齐,每一位要么相等要么有一边是 1,结果取较长者,为 1 的那一边逻辑复制拉长。把上面那组形状配对在纸上对齐一遍,再把三段代码跑一遍,形状和数值都对得上,广播就成直觉了。