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) | 1 | 3 |
| 逐位判定 | 有一边是 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 的那一边逻辑复制拉长。把上面那组形状配对在纸上对齐一遍,再把三段代码跑一遍,形状和数值都对得上,广播就成直觉了。




冀公网安备13050302001966号