把一次处理一个数字的指令改为一次处理一堆数字,就是向量化编程,这’一堆数字’就是我们所说的向量。现在的CPU一般都提供了这种能力,比如SIMD(Single Instruction Multiple Data)。
向量化编程的表现形式是以数组(array)作为基本元素进行操作,从而避免使用for循环对数组中的元素一个一个地操作。所以尽可能少地使用for循环是向量化编程的一个重要表现特征。
请注意:该文档所有代码均认为已经导入了numpy库:import numpy as np
,如果有一些示例代码中不包含这一句,请自行添加。
简单来讲就是为了效率
。
像python这种更接近脚本式和解释性的高级编程语言,for循环的执行效率非常非常非常低(MATLAB也是如此)。
而numpy底层是用C语言写的,并且使用了一些经过优化的高性能计算库,比如BLAS,所以其速度非常快。numpy的基本数据类型名称叫ndarray,以ndarray作为基本元素,使用numpy的函数或者操作符进行运算就是numpy的向量化编程。
矩阵乘法非常能体现出这种差异。我们在线性代数里学过,根据矩阵乘法的概念,需要三个嵌套的for循环才能对其进行实现(存在一些改良的矩阵乘法策略,比如Strassen方法,但是这种过于底层的东西就不深入讨论了,不是本文档的目的)。python的for循环本来就慢,还要嵌套三个的话那就真的是慢上加慢。
下列代码对比了python的for循环实现的矩阵乘法与np.matmul的效率。各位测试的时候注意调节SIZE
变量,至少使t_cost1
不等于0才能做倍数比较(如果时间太短以至于t_cost1没有超过有效数字最后一位的话就可能等于0)。SIZE
越大,np.matmul的优势就越明显,我在自己电脑上将SIZE
设为300时,np.matmul要比for循环方法快3万倍以上,此时np.matmul仍然只需不到1ms就可完成运算,而for循环的方法则需30多秒。
# -*- coding: utf-8 -*-
import time
import numpy as np
def matmul(mat1, mat2):
assert mat1.ndim == 2
assert mat2.ndim == 2
assert mat1.shape[1] == mat2.shape[0]
m, k = mat1.shape
n = mat2.shape[1]
out = np.zeros([m, n])
for row in range(m):
for col in range(n):
for i in range(k):
out[row, col] += mat1[row, i] * mat2[i, col]
return out
SIZE = 300
a = np.random.random([SIZE, SIZE])
b = np.random.random([SIZE, SIZE])
t0 = time.time()
c1 = np.matmul(a, b)
t_cost1 = time.time() - t0
t0 = time.time()
c2 = matmul(a, b)
t_cost2 = time.time() - t0
print(t_cost2 / t_cost1)
为了更友好地使用向量化编程,numpy使用了一种非常重要的特性:广播机制(Broadcasting)。
在解释broadcasting之前,我们先了解numpy数组(ndarray)的两个基本概念:维度(ndim)
和shape
。比如我们现在有一个变量,它存放了一张1080*1920的3通道图片数据,那么我们就说它的shape = [1080, 1920, 3]
,它的ndim = 3
,也就是说它有3个维度,第1个维度代表了height,第二个维度代表了width,第三个维度代表了channels。
所谓broadcasting是这样一种能力,它可以解决具有不同ndim或shape(或两者兼有)的ndarray之间的运算。当然这里需提前讲明一点,所谓的不同也是有限制的,并不允许任意不同。
下面先看几个例子
例1是最基础的运算,参与运算的两个ndarray均具有相同的shape,此时加减乘除和乘方(+, -, *, /, **)等等符号连接的运算都是逐元素(element-wise)进行,请特别注意以*连接的2D矩阵的运算也是element-wise乘法,而不是我们在线性代数里学的矩阵乘(前者行数等于后者列数)。比如例1的第二段代码的计算过程和结果:
[ 1 2 3 2 3 4 ] ∗ [ 0 1 2 1 2 3 ] = [ 1 ∗ 0 2 ∗ 1 3 ∗ 2 2 ∗ 1 3 ∗ 2 4 ∗ 3 ] = [ 0 2 6 2 6 12 ] \begin{bmatrix} 1 & 2 & 3\\ 2 & 3 & 4 \end{bmatrix} * \begin{bmatrix} 0 & 1 & 2\\ 1 & 2 & 3 \end{bmatrix} = \begin{bmatrix} 1*0 & 2*1 & 3*2\\ 2*1 & 3*2 & 4*3 \end{bmatrix} = \begin{bmatrix} 0 & 2 & 6\\ 2 & 6 & 12 \end{bmatrix} [122334]∗[011223]=[1∗02∗12∗13∗23∗24∗3]=[0226612]
# example 1
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])
print(a + b) # ==> [11 22 33 44 55]
print(a * b) # ==> [ 10 40 90 160 250]
a = np.array([[1, 2, 3], [2, 3, 4]])
b = np.array([[0, 1, 2], [1, 2, 3]])
print(a + b) # ==>
# [[1 3 5]
# [3 5 7]]
print(a * b) # ==> element-wise multiplication
# [[ 0 2 6]
# [ 2 6 12]]
例2开始出现Broadcasting,该例子主要表示ndarray与标量数字的运算。在常规的线性代数中没有定义向量或者矩阵与一个标量数字的加法运算(定义有相应的乘法运算),但是在下面例子中,向量(矩阵)与标量的加法运算也可以被执行,加法运算的实际过程为:
[ 1 2 3 4 5 ] + 10 = [ 1 2 3 4 5 ] + [ 10 10 10 10 10 ] = [ 11 12 13 14 15 ] \begin{aligned} &\begin{bmatrix} 1 & 2 & 3 &4 &5 \end{bmatrix}+ 10 \\[2ex] &= \begin{bmatrix} 1 & 2 & 3 & 4 & 5 \end{bmatrix}+ \begin{bmatrix} 10 & 10 & 10 & 10 & 10 \end{bmatrix}\\[2ex] &= \begin{bmatrix} 11 & 12 & 13 & 14 & 15 \end{bmatrix} \end{aligned} [12345]+10=[12345]+[1010101010]=[1112131415]
也就是说标量数字b先通过复制被扩展成与a相同shape的array(此过程就是所谓的Broadcasting),而后两者执行了element-wise加法。乘法运算的实际发生过程也是如此(减法,除法等也一样)。
# example 2
a = np.array([1, 2, 3, 4, 5])
b = 10
print(a + b) # ==> [11 12 13 14 15]
print(a * b) # ==> [10 20 30 40 50]
a = np.array([[1, 2, 3], [4, 5, 6]])
b = 10
print(a + b)
# [[11 12 13]
# [14 15 16]]
例3中参与运算的两个元素都是向量或者矩阵。
首先介绍rank-1 ndarray的概念。在例3中,像b,d这样的shape是一个数字加一个逗号的ndarray是rank-1 ndarray,或者说它们的ndim=1,这类ndarray不等同于行向量或者列向量。行向量的shape是(1, n),列向量的shape是(ndim, 1),它们的ndim都等于2。
对于rank-1 ndarray的Broadcasting,如果有需要对其ndim进行扩展的话,只能在其shape的最前方增加新的axis。比如对于例3中的 a + b a+b a+b操作,由于a.ndim = 2,b.ndim = 1,所以需要对b做axis扩展,首先扩展为(也只能扩展为)shape = (1, 5),而后在新的axis上复制元素,使之与a的的shape相同,最后进行element-wise加法,最终实现的效果就是给a的每一列加上了一个数字。具体过程如下:
[ 0 1 2 3 4 5 6 7 8 9 ] + [ 0 1 2 3 4 ] = [ 0 1 2 3 4 5 6 7 8 9 ] + [ 0 1 2 3 4 0 1 2 3 4 ] = [ 0 2 4 6 8 5 7 9 11 13 ] \begin{aligned} &\begin{bmatrix} 0 & 1 & 2 & 3 & 4\\ 5 & 6 & 7 & 8 & 9 \end{bmatrix}+ \begin{bmatrix} 0 & 1 & 2 & 3 & 4 \end{bmatrix} \\[4ex] &= \begin{bmatrix} 0 & 1 & 2 & 3 & 4\\ 5 & 6 & 7 & 8 & 9 \end{bmatrix}+ \begin{bmatrix} 0 & 1 & 2 & 3 & 4\\ 0 & 1 & 2 & 3 & 4 \end{bmatrix}\\[4ex] &= \begin{bmatrix} 0 & 2 & 4 & 6 & 8\\ 5 & 7 & 9 & 11 & 13 \end{bmatrix} \end{aligned} [0516273849]+[01234]=[0516273849]+[0011223344]=[052749611813]
如果我们想要给a的每一行加上一个数字,那么首先来看一下 a + d a+d a+d的运算,与 a + b a+b a+b同理,d首先在前面被增加一个axis变为(1, 2),但是这样的shape不能够通过在第一个axis进行扩展而使之与a的shape相同,所以无法完成计算,因此会报错。
由于rank-1 ndarray会增加新的axis,并且只在旧有axis的前面增加新的axis,所以一不小心就会出错,并且这种错误很隐晦,不容易被发现,会降低程序的可读性和可维护性。所以在需要Broadcasting时,不建议使用rank-1 ndarray,建议勤快一点,多打几个方括号,将ndarray直接写成一个容易理解的shape。比如我们想要给a的每一行分别加一个数字,那么被加的ndarray的shape应当是(2, 1),也就是 a + c a+c a+c这种情况,c的这种写法就很容易理解,并且不容易引起错误。
请注意,例3中的a是非方阵,所以 a + d a+d a+d这里会报错,这已经是非常理想的状况了,报错能让我们意识到错误并进行改正,做出正确的运算。但如果我们现在仍然想要给a的每一行加上一个数字,并且a本身是方阵,d是一个如b一般的rank-1且长度为5的ndarray,那么程序将不会报错,但是实际上却给a的每一列加上了一个数字,导致最终结果错误。这种错误非常隐晦,不易被察觉和发现,但是可以在写代码的时候勤快一点提前避免掉。
# example 3
a = np.arange(0, 10).reshape([2, 5]) # ==>
# [[0 1 2 3 4]
# [5 6 7 8 9]]
b = np.array([0, 1, 2, 3, 4]) # ==> b.shape = (5,)
print(a + b) # ==>
# [[ 0 2 4 6 8]
# [ 5 7 9 11 13]]
c = np.array([[5], [10]]) # ==> c.shape = (2, 1)
print(a + c) # ==>
# [[ 5 6 7 8 9]
# [15 16 17 18 19]]
d = np.array([5, 10]) # ==>(2,)
print(a + d) # ==> ValueError: operands could not be broadcast together with shapes (2,5) (2,)
例4这种Broadcasting非常有意思,它的计算过程是a在第一个axis将自己复制5次,shape变为(5, 5),b在第二个axis将自己复制5次,shape同样变为(5, 5),然后进行element-wise加法,最终结果是一个shape为(5, 5)的ndarray。详细过程可以表示为:
[ 0 1 2 3 4 ] + [ 1 2 3 4 5 ] = [ 0 1 2 3 4 0 1 2 3 4 0 1 2 3 4 0 1 2 3 4 0 1 2 3 4 ] + [ 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 5 ] = [ 1 2 3 4 5 2 3 4 5 6 3 4 5 6 7 4 5 6 7 8 5 6 7 8 9 ] \begin{aligned} &\begin{bmatrix} 0 & 1 & 2 & 3 & 4 \end{bmatrix} + \begin{bmatrix} 1\\ 2\\ 3\\ 4\\ 5 \end{bmatrix} \\[8ex]&= \begin{bmatrix} 0 & 1 & 2 & 3 & 4\\ 0 & 1 & 2 & 3 & 4\\ 0 & 1 & 2 & 3 & 4\\ 0 & 1 & 2 & 3 & 4\\ 0 & 1 & 2 & 3 & 4\\ \end{bmatrix} + \begin{bmatrix} 1 & 1 & 1 & 1 & 1\\ 2 & 2 & 2 & 2 & 2\\ 3 & 3 & 3 & 3 & 3\\ 4 & 4 & 4 & 4 & 4\\ 5 & 5 & 5 & 5 & 5 \end{bmatrix} \\[8ex] &= \begin{bmatrix} 1 & 2 & 3 & 4 & 5\\ 2 & 3 & 4 & 5 & 6\\ 3 & 4 & 5 & 6 & 7\\ 4 & 5 & 6 & 7 & 8\\ 5 & 6 & 7 & 8 & 9\\ \end{bmatrix} \end{aligned} [01234]+⎣⎢⎢⎢⎢⎡12345⎦⎥⎥⎥⎥⎤=⎣⎢⎢⎢⎢⎡0000011111222223333344444⎦⎥⎥⎥⎥⎤+⎣⎢⎢⎢⎢⎡1234512345123451234512345⎦⎥⎥⎥⎥⎤=⎣⎢⎢⎢⎢⎡1234523456345674567856789⎦⎥⎥⎥⎥⎤
这种Broadcasting也挺隐晦的,容易出错,相应代码不易理解和维护。
# example 4
a = np.array([[0, 1, 2, 3, 4]]) # ==> a.shape = (1, 5)
b = np.array([[1, 2, 3, 4, 5]]).T # ==> b.shape = (5, 1)
print(a + b) # ==>
# [[1 2 3 4 5]
# [2 3 4 5 6]
# [3 4 5 6 7]
# [4 5 6 7 8]
# [5 6 7 8 9]]
有了以上例子,现在我们来总结一下Broadcasting的机制是什么,以及什么情况下可以进行有效的Broadcasting。
参考了:
https://www.tutorialspoint.com/numpy/numpy_broadcasting.htm
Broadcasting的机制为:
这个机制略有一些复杂,大家写程序时尽可能使用简单点的Broadcasting(像例2或3那种,有时可以通过reshape来压缩ndim,从而使得Broadcasting变得简单一些),如果需要使用如上面例4或者例(a)这种高维度的,比较复杂的Broadcasting机制,那么建议一定写好注释,不然代码真的很难读又很难维护。
numpy的函数几乎都支持向量化编程,也就是说函数的输入可以是一个ndarray,不必非得是一个标量数字。
比如当我们使用math库来计算一系列数字的sin值时,如果我们给math.sin()
的输入是一个list,那么该函数将报错。
import math
a = [0, 1, 2, 3]
b = math.sin(a) # ==> TypeError: must be real number, not list
如果换成了numpy,那么将可以成功执行,且即便输入不是ndarray而是一个list,也可以执行无误,numpy会自动转换list为ndarray:
a = np.array([0, 1, 2, 3])
b = [0, 1, 2, 3]
print(np.sin(a)) # ==> [0. 0.84147098 0.90929743 0.14112001]
print(np.sin(b)) # ==> [0. 0.84147098 0.90929743 0.14112001]
如果我们要对一个ndarray执行ReLU(Rectified Linear Unit)函数该怎么写,该函数的数学表达式为: y = m a x ( 0 , x ) y=max(0,x) y=max(0,x)。
一个简单的办法是写for循环,但是我们前面讲过了,for循环效率极其低下,所以真的在python里写for循环来计算ReLU的话,大概一个稍微复杂点的网络得训一辈子才能训出来。
正确的方法是使用向量化索引机制。先举例,再解释:
np.random.seed(1001)
a = np.random.randint(low=-10, high=10, size=[5, 5])
print(a)
# [[ 3 -1 4 -7 2]
# [ -2 4 -7 -6 3]
# [ -5 2 -4 -9 -10]
# [ 8 -6 -5 2 7]
# [ -2 4 -9 -1 8]]
index = a < 0
a[index] = 0
print(a)
# [[3 0 4 0 2]
# [0 4 0 0 3]
# [0 2 0 0 0]
# [8 0 0 2 7]
# [0 4 0 0 8]]
print(index)
# [[False True False True False]
# [ True False True True False]
# [ True False True True True]
# [False True True False False]
# [ True False True True False]]
在上述例子中,a是一个5 x 5的随机整数矩阵,随机的范围是[-10, 10),我们现在的目的是将其中小于0的数字置为0。
首先我们使用判断语句index = a < 0
得到一个向量化的条件索引,这个索引的shape与a.shape相同,元素是bool型,以index
为索引对a进行运算时,只有index
等于True
的位置上的数值才会参与运算(即a < 0
的位置),所以当执行了a[index] = 0
后,所有a < 0
的数字一起被置为0,避免了写循环。
index还可以使用多个条件来共同获取,条件之间用 &,|(与,或)来连接,比如下面例子中可以将a中数值小于-5或者大于5的元素置为0,index的各个条件注意加括号。
np.random.seed(1001)
a = np.random.randint(low=-10, high=10, size=[5, 5])
index = (a < -5) | (a > 5)
a[index] = 0
print(a)
# [[ 3 -1 4 0 2]
# [-2 4 0 0 3]
# [-5 2 -4 0 0]
# [ 0 0 -5 2 0]
# [-2 4 0 -1 0]]
关于如何使用条件索引进行编程实践,各位可以参考下面的文档:
图像插值:最邻近(nearest)与双线性(bilinear)
该文档中使用条件索引来判断坐标是否超出图像边界,通过使用向量化编程,数倍甚至数十倍提升图像插值的速度。
差分就是计算相邻元素的差值,也可以间隔一些位置,如果间隔了一些位置的话那么相减后还要除以元素之间的距离。
np.random.seed(1001)
a = np.random.randint(0, 10, [10])
diff = a[1:] - a[0:-1]
print(a) # ==> [9 3 8 3 5 8 4 5 7 6]
print(diff) # ==> [-6 5 -5 2 3 -4 1 2 -1]
这个例子中需注意,序列中的第一个值和最后一个值不能是极值。所以需要使用[1:-1]
来对中间部分进行切片。
np.random.seed(1001)
a = np.random.randint(0, 10, [10])
print(a) # ==> [9 3 8 3 5 8 4 5 7 6]
# maxima
index = (a[0:-2] <= a[1:-1]) & (a[1:-1] > a[2:])
print(index) # ==> [False True False False True False False True]
print(a[1:-1][index]) # ==> [8 8 7]
# minima
index = (a[0:-2] >= a[1:-1]) & (a[1:-1] < a[2:])
print(index) # ==> [True False True False False True False False]
print(a[1:-1][index]) # ==> [3 3 4]
在图形图像的相关处理中,我们经常碰到这样的需求:既想要引入一点点随机性,又想要对随机数的覆盖范围进行一些控制,比如使其在空间上均匀分布而又不至于越界。
这里有两个例子:
# example: random samples for pixel grids
np.random.seed(1001)
width = 5
height = 3
x, y = np.meshgrid(np.arange(0, width), np.arange(0, height))
x = np.expand_dims(x, axis=-1)
y = np.expand_dims(y, axis=-1)
coords = np.concatenate((x, y), axis=2)
jitter = np.random.random(coords.shape)
coords_jitter = coords + jitter
print(coords)
# [[[0 0]
# [1 0]
# [2 0]
# [3 0]
# [4 0]]
#
# [[0 1]
# [1 1]
# [2 1]
# [3 1]
# [4 1]]
#
# [[0 2]
# [1 2]
# [2 2]
# [3 2]
# [4 2]]]
print(coords_jitter)
# [[[0.30623218 0.26506357]
# [1.19606006 0.43052148]
# [2.02311355 0.19578192]
# [3.35280529 0.22324202]
# [4.61352186 0.58045711]]
#
# [[0.85356768 1.04113054]
# [1.48817444 1.92082616]
# [2.10910188 1.41105662]
# [3.47012682 1.12729655]
# [4.982355 1.02031597]]
#
# [[0.70548605 2.96550914]
# [1.35274539 2.60589476]
# [2.21738034 2.99196955]
# [3.66917956 2.69303974]
# [4.17547093 2.83044881]]]
# example: random patch boxes for cutting images
import cv2
import numpy as np
PATCH_HEIGHT = 256
PATCH_WIDTH = 256
STEP_HEIGHT = 200
STEP_WIDTH = 200
JITTER_HEIGHT = 50
JITTER_WIDTH = 50
image = cv2.imread('1.bmp')
image_height, image_width = image.shape[0:2]
# generate uniformly distributed top left points
x = np.arange(0, image_width - PATCH_WIDTH, STEP_WIDTH)
y = np.arange(0, image_height - PATCH_HEIGHT, STEP_HEIGHT)
xx, yy = np.meshgrid(x, y)
xx = np.reshape(xx, [-1, 1])
yy = np.reshape(yy, [-1, 1])
tl = np.concatenate((xx, yy), axis=1) # tl is for top left
# random jitter
jitter = np.random.randint(
[-JITTER_HEIGHT, -JITTER_WIDTH], [JITTER_HEIGHT, JITTER_WIDTH], tl.shape)
tl += jitter
# shrink tl and br to avoid crossing image border
tl[tl < 0] = 0
patch_shape = np.array([[PATCH_WIDTH, PATCH_HEIGHT]])
br = tl + patch_shape # br is for bottom right
br[br[:, 0] > image_width, 0] = image_width
br[br[:, 1] > image_height, 1] = image_height
tl = br - patch_shape
# draw rectangles
for i in range(len(tl)):
random_color = (int(np.random.randint(0, 255)),
int(np.random.randint(0, 255)),
int(np.random.randint(0, 255)))
cv2.rectangle(image, tuple(tl[i]), tuple(br[i]), random_color, 2)
cv2.namedWindow('image', 0)
cv2.imshow('image', image)
cv2.waitKey(0)
cv2.destroyAllWindows()
第二段代码执行的结果为:
https://www.tutorialspoint.com/numpy/numpy_broadcasting.htm