数据科学和机器学习¶
本文辑录了笔者在学习科学计算和机器学习过程中的总结,归纳,从基础数学和模型理论到实际编码和问题解决以及优化过程。它也很好的呈现了学习数据处理和机器学习从理论到解决实际问题再到依据实际情况进行优化细调的阶梯过程。
放在这里方便笔者和他人学习和参考。文档中大部分示例使用 Anaconda 集成科学计算环境,并基于 Python3.4 版本完成,当示例结果与系统平台相关时,通常会提供 Linux 和 Windows 两个版本的输出结果。
该文档还在不断增加完善中……,如果您对本文档有任何疑问,见解或者指导,请 Email 到 lli_njupt@163.com。
通过点击 在readthedocs中显示当前文档 可以浏览该文档的最新版本。
关于Python 基础学习移步这里 Python 从入门到深入 。
numpy¶
NumPy(Numerical Python 的简称),是 Python 语言的一个扩展程序库,提供了高效存储和操作密集数据缓存的接口。 支持大量的维度数组与矩阵运算,此外也针对数组运算提供大量的数学函数库。
NumPy 的前身 Numeric 最早是由 Jim Hugunin 与其它协作者共同开发,2005 年,Travis Oliphant 在 Numeric 中结合了另一个同性质的程序库 Numarray 的特色,并加入了其它扩展而开发了 NumPy。NumPy 为开放源代码并且由许多协作者共同维护开发。
在某些方面, NumPy 数组与Python 内置的列表类型非常相似。 但是随着数组在维度上变大, NumPy 数组提供了更加高效的存储和数据操作。 NumPy 数组几乎是整个Python 数据科学工具生态系统的核心。
NumPy 是一个运行速度非常快的数学库,主要用于多维数组计算,包含:
- 一个强大的N维核心数组对象 ndarray,对大量矩阵运算提供支撑
- 广播功能函数
- 线性代数、傅里叶变换、随机数生成等功能
NumPy 通常与 SciPy(Scientific Python)和 Matplotlib(绘图库)一起使用, 这种组合广泛用于替代 MatLab,是一个强大的科学计算环境,有助于通过 Python 学习数据科学或者机器学习。
SciPy 是一个开源的 Python 算法库和数学工具包,包含的模块有最优化、线性代数、积分、插值、特殊函数、快速傅里叶变换、信号处理和图像处理、常微分方程求解和其他科学与工程中常用的计算。
Matplotlib 是 Python 编程语言及其数值数学扩展包 NumPy 的可视化操作界面。它为利用通用的图形用户界面工具包,如 Tkinter, wxPython, Qt 或 GTK+ 向应用程序嵌入式绘图提供了应用程序接口(API)。
0 1 2 3 4 5 6 | # pip install NumPy
import NumPy as np # 通常的导入方式
print(np.__version__)
>>>
1.13.1
|
这里不得不提到 Octave(模仿 Matlab 的 GNU 开源软件),它是另一个在科学计算领域应用广泛的开发环境,作者 John W. Eaton。实际上 Octave 之所以易于使用,从而也被广泛应用在教学领域,在于它沿用 Matlab 在各个轴上的定义与笛卡尔坐标系(Cartesian coordinates)保持一致,从而避免了学习梯度的陡升。掌握 NumPy 的关键就是理解不同维度数组的轴,以及建立在轴上的复杂变换(高维数组上轴的定义与 Octave 不同,这让 NumPy 看起来有些混乱)。
这里使用 2D 数组在 Octave 和 NumPy 上做一对比:
0 1 2 3 4 | octave:2> A = [1 2 3; 4 5 6]
A =
1 2 3
4 5 6;
|
上下比较,哪一个更简洁更易理解是非常明显的,NumPy 在数组显示上要糟糕得多,这还是在只有 2D 的情况下:
0 1 2 3 4 5 | In [1]: A = np.array([[1, 2, 3], [4, 5, 6]])
In [2]: A
Out[2]:
array([[1, 2, 3],
[4, 5, 6]])
|
我们再看一个 3D 的例子,以说明各个轴和笛卡尔坐标系的关系:
0 1 2 3 4 5 6 7 8 9 10 11 | octave:1> A = reshape ([0, 1, 2, 3, 4, 5, 6, 7], 2, 2, 2)
A =
ans(:,:,1) =
0 2
1 3
ans(:,:,2) =
4 6
5 7
|
octave 的下标总是从 1 开始,常规思维 [1,1,2](对应 Numpy 的索引为 [0,0,1])的元素是什么?
0 1 2 | # octave 通过圆括号 '()' 进行索引
octave:3> A(1,1,2)
ans = 4
|
这里不对 octave 如何进行 reshape 进行深入分析。这里的重点在于 [1,1,1] 对应了 x, y 和 z,通过直观思考就可以得出值为 4。而 Numpy 却不同:
0 1 2 3 4 5 6 7 8 9 | In [1]: a
Out[1]:
array([[[0, 1],
[2, 3]],
[[4, 5],
[6, 7]]])
In [2]: a[0,0,1]
Out[2]: 1
|
你可能会认为结果是 4,而事实并非如此,在数组坐标轴这一节会分析这一令人迷惑的问题。
NumPy 另一个令人诟病的地方就是不支持列向量,也即只能使用 Nx1 的 2D 数组来模拟,而行向量却是 1D 的,这看起来非常不合理(Stupid!),所以没有任何经验的人使用 Octave 并基于正常思维掌握它是非常迅速的,而要掌握 NumPy,使用直觉思维是不现实的,你在尝试解读代码时必须要经过一个短暂的转换思考过程。
数组属性和类型¶
从打印的 nparray 结果看,ndarray 类型的数组与Python 列表类似, 但是它是一个 ndarray 对象,它为高效地存储和操作大型数组提供了数据存储的支撑。
0 1 2 3 4 5 6 7 | list0 = [1,2,3]
nparray = np.array(list0)
print(nparray)
print(type(list0).__name__, type(nparray).__name__)
>>>
[1 2 3]
list ndarray
|
ndarray(n dimention array,多维数组)对象是 NumPy 的数据承载核心。
数组属性¶
首先使用 zeros() 生成1-3不同维度的全0数组:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | X1 = np.zeros(2) # 生成 1 维数组
X2 = np.zeros((2,2)) # 生成 2 维数组
X3 = np.zeros((2,2,2)) # 生成 3 维数组
print("{}{}{}".format(X1, X2, X3),sep='\n')
>>>
[ 0. 0.] # 1 维数组
[[ 0. 0.] # 2 维数组
[ 0. 0.]]
[[[ 0. 0.] # 3 维数组
[ 0. 0.]]
[[ 0. 0.]
[ 0. 0.]]]
|
我们可以通过数第一行开始连续左中括号 “[” 的个数来判断数组的维数。而判断数组的各个维的长度,则可以从内向外进行,也即从最内层向最外层数:
- 如下所示的数组,首先查看最内层元素 [ 0. 0.],元素个数为 2 个,所以最后一维的维数为 2
- 接着把 [ 0. 0.] 看做一个整体单元,查看外层括号包含多少个此单元,显然为 2 个
- 然后再把 [[ 0. 0.] [ 0. 0.]],看做一个整体单元,继续查看外层包含多少个此单元,显然只有 1 个
- 以此类推,直至遍历完所有中括号,显然下面代码中的数组的 shape 为 (1,2,2)。
0 1 | [[[ 0. 0.]
[ 0. 0.]]]
|
描述数组大小的属性有三个:
- nidm:描述数组的维度(dimensions),也被称为轴数(axes),为整数,对于三维数组来说它有 3 个轴,通常记为 x,y,z,这与真实世界的三维空间坐标轴是一致的。
- shape:由每个轴(axis,也即每个维)的长度大小组成的元组类型,一个轴上的元素数称为这个轴的长度,注意和数组维度区别。
- size:数组的元素总个数,整数,size 等于 shape 中所有元素相乘: size = np.prod(a.shape)。
0 1 2 3 4 5 6 7 | for i in range(1,4,1):
x = eval('X' + str(i))
print('X' + str(i), "ndim: {} shape: {} size: {}".format(x.ndim, x.shape, x.size))
>>>
X1 ndim: 1 shape: (2,) size: 2
X2 ndim: 2 shape: (2, 2) size: 4
X3 ndim: 3 shape: (2, 2, 2) size: 8
|
用于描述数组元素存储的属性有:
- dtype:数组元素类型,决定了每个元素的大小,例如 int32,float64。
- itemsize:表示每个元素占用字节大小。
- nbytes:表示数组中数据部分所占的字节大小,通常 nbytes = itemsize * size。
0 1 2 3 4 5 6 7 | for i in range(1,4,1):
x = eval('X' + str(i))
print('X' + str(i), "itemsize: {} nbytes: {}".format(x.itemsize, x.nbytes))
>>>
X1 itemsize: 8 nbytes: 16
X2 itemsize: 8 nbytes: 32
X3 itemsize: 8 nbytes: 64
|
可以看到每个元素的大小为 8 个字节,zeros() 默认使用 float64 类型。可以通过 dtype 属性获取:
0 1 2 3 4 | print(X1.dtype)
X1 = np.zeros(2, dtype='int32') # 指定元素类型
>>>
float64
|
关于元素类型要注意以下几点:
- 转换数组类型不可以直接更改 dtype,它用于对数据存储区域的解读方式,例如 float64 对应 8 个 bytes,int32 对应 4 个 bytes,直接更改 dtype 会让 float64 类型的数组元素个数翻倍,并未实际改变数组类型。正确的方法应该通过 np.astype 方法进行。
- 更新数组元素时,会强制把新元素的数据类型转换为数组的 dtype。
不同维度的数组¶
上例中我们分别生成了 1,2,3 维的数组,一些常用的不同维度的数组在数学科学领域有专门的术语:
- 单个数值,输出不被包含在 [] 中,例如 1,0.1等被称为标量(scalar),它们自身不是数组,但可以与数组进行数学运算。np.array 可以创建只包含标量的数组,shape 为 ()。
- 1维数组,如 [1,2,3],被称为向量(vector),只有一个轴。
- 2维数组,可以看作是向量组成的数组叫作矩阵(matrix),有两个轴,第一个轴称为行(row),第二个轴称为列(column)。
- 3维数组,多个矩阵组合成一个新的数组,可以得到一个 3D 矩阵。
以上各类量有一个专门的名词,统称为张量(Tensor)。张量的维(dimension)也称为轴(axis),轴的个数叫作秩(rank),因为它和矩阵的秩含义不同,为了防止混淆, 通常很少使用术语 rank,而是称张量的维度。与此同时 np.rank 函数也不再被推荐使用,而是被 ndarray.ndim 替代。
dimension 或 axis 的个数(rank)在 NumPy 用 ndim 属性表示。每个维的大小(长度)在 NumPy 中用 shape 属性表示。
标量不是数组,而是数值,维度为 0,它在 NumPy 不用 ndarray 对象表示(实际上可以通过 array(scaler) 获得 0D 的 ndarray 对象,但是没有必要,直接使用标量即可),它没有 ndim 和 shape 属性。
0 1 2 3 4 5 6 7 | # 创建 0D 的 ndarray 对象
In [1]: a = np.array(1)
In [2]: type(a)
Out[2]: numpy.ndarray
In [3]: a.ndim
Out[3]: 0
|
为了区分向量和 1xN 的矩阵,向量使用平面方式绘制,矩阵使用 3D 效果绘制。
图中可以看出:
- 1D 向量只有 0 轴,也即只有一个方向,所以不存在转置操作,在 numpy 没有行向量和列向量之分,向量的转置还是自身。
- 2D 矩阵具有 0 轴和 1 轴,注意 0 轴的方向和 1D 向量 0 轴方向的区别,0 轴上的每个元素构成一行(row),1 轴上的每个元素构成一列(column)
- 每个轴均具有索引属性,从 0 开始。
理解轴的概念是理解 numpy 提供的很多操作,如聚合,拼接等的基础。
数组坐标轴¶
我们已经知道数组可以是多维的,这很容易联想到笛卡尔坐标系,2D 使用 x,y 描述平面上的任一点,3D 使用 x,y,z 描述空间中的任一点。是否我们可以借助笛卡尔坐标系来理解 2D 和 3D 数组呢?回答是肯定的,只是不同的软件环境对坐标系的描述不同,有的直接借用了笛卡尔坐标系,有的则进行了变换。
平面中的一点和空间中的一点均可使用轴坐标 x,y 或者 x,y,z 来得到,显然它们起到了索引的作用。对数组元素的访问就是通过索引进行的。
Octave 和 numpy 在 2D 数组处理上保持了一致,也即把数组分为行和列,只是和常见的笛卡尔 2D 坐标系比较,交换了 x 和 y 轴:在水平方向表示列,垂直方向表示行,例如:
0 1 2 3 4 5 6 7 8 9 | octave:2> a = reshape ([0, 1, 2, 3], 2, 2)
a =
0 2
1 3
octave:3> a(1,1)
ans = 0
octave:4> a(1,2)
ans = 2
|
不难想象,左上角成了坐标原点,而通过 a(1,1) 可以索引到元素 0,a(1,2) 可以索引到元素 2,注意 Octave 的下标总是从 1 开始,并使用圆括号进行索引。
0 1 2 3 4 5 | 水平方向,对应列
+--------> y
| 0 2
| 1 3
V
x
|
Numpy 与此类似,下标总是从 0 开始,使用方括号进行索引,这与 C 语言保持了一致:
0 1 2 3 4 5 6 7 8 9 10 11 | In [651]: a = np.arange(4).reshape(2,2,order='F')
In [652]: a
Out[652]:
array([[0, 2],
[1, 3]])
In [653]: a[0,0]
Out[653]: 0
In [654]: a[0,1]
Out[654]: 2
|
这里暂时不追究 order 的作用,为了和 Octave 保持结果一致,我们使用了 ‘F’。显然针对 2D 数组,稍微有些编程基础(例如 C 语言)的人就很容易和笛卡尔坐标系结合起来,形成行列的直观思维,在给定索引后很容易指出对应的元素位置。
然而在 3D 甚至更高维数组的时候,就没那么简单了。观察上图中的 3D 坐标系,Octave 与笛卡尔坐标系保持了一致,也即 0,1,2 轴分别对应笛卡尔坐标系的索引 x,y,z,所以在给定如下视图和索引时我们很容易指出对应的元素值。
0 1 2 3 4 5 6 7 8 9 10 11 | octave:5> a = reshape ([0, 1, 2, 3, 4, 5, 6, 7], 2, 2, 2)
a =
ans(:,:,1) =
0 2
1 3
ans(:,:,2) =
4 6
5 7
|
例如 [0,0,1](实际命令为 a(1,1,2))索引对应的元素是 4。
0 1 | octave:6> a(1,1,2)
ans = 4
|
以上示例对应如下坐标描述:
octave 每新增一个轴,原来的轴保持不变,新增的轴变为新的维度,看起来轴是在尾部新增的。numpy 与此不同,新增的轴总是添加在最前面,变成 0 轴,原来的轴依次加 1。
0 1 2 3 4 5 6 7 8 9 10 11 | In [665]: a = np.arange(8).reshape(2,2,2,order='F')
In [666]: a
Out[666]:
array([[[0, 4],
[2, 6]],
[[1, 5],
[3, 7]]])
In [667]: a[0,0,1]
Out[667]: 4
|
理解了数组坐标轴的区别,那么就真正理解索引机制了。我们会发现使用直角坐标系表示 2D 数组时可以把第一维称为行(row),第二维称为列(column),第三维并没有固定的称谓,可以认为是深度(depth),高度(height)或者片/层(slice/layer)。但是一旦超过 3 维,这种表示方法就将无能为力了,实际上索引只是一个树形结构,后面将会展示这种更具弹性的表示方法。
另一个问题是 order 参数的作用,在 2D 数组中,如果指定了 order = ‘F’,那么 Numpy 和 Octave 的打印结果就是一致的,我们也已经指出它们的行列规定是一致的。 然而如果生成的是 3D 数组,那么 np.array 参数即便指定了 order = ‘F’ 参数,生成的 3D 数组打印出来依然是不同的,这是因为坐标系规定不同。
我们可以惊奇的发现,上面两幅图只要进行适当的旋转,数据部分就会重合,也即使用相同的索引它们都会得到相同的值(例如 a(1,1,2) 和 a[0,0,1] 结果均为 4)。
如果不指定 order = ‘F’ 呢,结果会怎样,order 参数到底起到什么作用?这关系到另一个更深层次的问题:内存布局。
内存布局¶
理解内存布局对理解坐标系规定至关重要,实际上就是它决定了坐标系的变换规则。Memory layout of multi-dimensional arrays 是一篇很好的文章。
行与列¶
通常计算机的内存均是线性编址的,可以看做是一维的大数组,通过系统调用分配到一块内存后,虚地址也是连续的,那么当要实现多维数组时,如何读取这一连续的内存数据到多维数组中,同样地如何把数组的行列等存储到这一连续的内存块中呢?这就涉及到内存布局(memory layout)策略。
对于 2D 数组来说通常有两种策略:行优先(Row-major)和列优先(column-major)。
- 行优先:逐行按序读取(存储),行内的元素均是连续读取(存储),然后第二行接着第一行,依次类推。
- 列优先:逐列按序读取(存储),列内的元素均是连续读取(存储),然后第二列接着第一列,依次类推。
比较以上两图,很容易理解行优先和列优先的区别,这有些像大小端字节序。内存布局用来指示何读取一块内存到多维数组,以及如何存储数据到一块连续内存。
Numpy 同时支持两种内存布局,可以通过 order 参数指定。在大部分函数(例如创建,变形等)中均接受 order 参数,用于指定行优先或者列优先:
- ‘C’ 表示行优先(row major),numpy 的默认参数,在 C 语言(C++,Python, Pascal,Mathematica 等)中使用。也被称为 C-like 索引顺序。
- ‘F’ 表示列优先(column major),Fortran 语言(Matlab, R, Julia 等为了使用 Fortran 的 LAPACK 计算库也同样遵循该规则)默认使用列优先,Fortran-like 索引顺序。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # Numpy 默认 order 为 C-like
In [684]: a = np.arange(9).reshape(3,3,order='C')
In [685]: a
Out[685]:
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
In [686]: b = np.arange(9).reshape(3,3,order='F')
In [687]: b
Out[687]:
array([[0, 3, 6],
[1, 4, 7],
[2, 5, 8]])
|
以上示例均是从相同的一块连续的内存中(9 个连续的 int32 类型的 0-8 数字)读取元素并生成 3x3 的 2D 数组,由于内存布局不同,它们读取到数据后的处理就不同:一个把前三个元素作为行,一个把前三个元素作为列。
同样对于数据写入,不同的内存布局也会影响索引对应的实际内存偏移地址,结合上图中的地址变化规律,不难得出 2D 数组的偏移公式如下:
其中 \(i_{row}\) 和 \(i_{col}\) 分别表示行索引和列索引,例如 a[1,2] 中的 1 代表行索引,2 代表列索引。
- a[1,2] 行优先,可以计算得到它在内存中相对于首个元素的偏移个数为 1 * 3 + 2 = 5,因为第一个元素为 0,所以 a[1,2] 就是元素 5。
- b[1,2] 列优先,偏移个数为 2 * 3 + 1,所以 b[1,2] 将访问到元素 7。
0 1 2 3 4 | In [722]: a[1,2]
Out[722]: 5
In [723]: b[1,2]
Out[723]: 7
|
索引的本质¶
如果尝试把行优先和列优先内存布局策略推广到 3D 甚至任意维,就不可行了,不可能为每一维都进行命名并映射到我们可以直观理解的空间进行形象描述。无论行还是列,本质上它们都是用于构成索引的一组数字,我们的目的是通过索引唯一定位到连续内存中的某个元素。
我们知道 1 个 bit 可以表示 0 和 1, 而 2 个 bits 可以表示 0-3,所以使用十进制数左右排列在一起具有相同的本质 [i1,i2,…in],可以表示 0 到 offset = (i1 + 1)(i2 + 1)…(in + 1) 所有数字,可以索引内存块的 0 偏移到最大 offset 处,这一索引关系是一一对应的。
只要确定了数组的维数(轴数)和每个维的大小(长度),那么它占用内存块长度就是确定的,与此同时索引的一一对应关系就是确定的,整个索引空间就是各维大小的全排列。
所谓行优先和列优先只是进行全排列时,哪一维在前哪一维在后的区别,在后的那一维它的索引变化最快。所以使用低维最快变化(first index change) 和高维最快变化(last index change)对描述任意维数组都是贴切的。
观察上图,行优先时最后一维的索引变化最快,它就是 last index change 机制,相应地,列优先属于 first index change 机制。当然我们也可以选择任意一维最快更新,并依次定义其他维的变化优先级,只是通常没有必要。
这看起来内存布局的区别和大小端字节序区别非常类似,也即没有好坏之分,然而并非如此,内存布局对数据处理速度影响很大:
- CPU 非常善于处理连续数据,它的多级流水线机制使得在处理当前数据时,可以预取紧随其后的其他数据。
- CPU 设有多级 cache,处理连续数据让靠近 CPU 核的 cache 命中率更高。
我们应该让内存布局符合这一特征:如果处理集中在行数据上,那么就应该使用高维最快变化(order=’C’),集中在列上,就应该使用低维最快变化(order=’F’),这就保证了读取行或列时是数据是被顺序读取的。
0 1 2 3 4 5 6 7 8 9 | # 默认为行优先
In [108]: a = np.empty((10,10))
In [109]: a[0,:] *= 100
In [110]: %timeit a[0,:] *= 100
2.24 µs ± 79.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
In [111]: %timeit a[:,0] *= 100
3.14 µs ± 147 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
|
在小数组上,性能相差不大,但是在处理大数组时差距尤为明显:
0 1 2 3 4 5 6 7 8 9 | # 默认为行优先
In [123]: a = np.empty((10000,10000))
# 处理行数据速度很快
In [124]: %timeit a[0,:] *= 100
5.84 µs ± 399 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
# 处理列数据速度很慢
In [125]: %timeit a[:,0] *= 100
155 µs ± 4.27 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
|
如果我们在创建数组时指定列优先,那么结果就会相反:
0 1 2 3 4 5 6 7 8 9 | # 指定列优先
In [132]: a = np.empty((10000,10000), order='F')
# 处理行数据速度很慢
In [133]: %timeit a[0,:] *= 100
149 µs ± 1.41 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
# 处理列数据速度很快
In [134]: %timeit a[:,0] *= 100
5.55 µs ± 184 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
|
示例中数据元素达到了 1 亿个,此时性能相差 30 倍,所以在进行大数据处理时一定要正确设置内存布局。
树形坐标¶
无论是笛卡尔坐标系,还是其他坐标系,本身就是一个索引系统。通过坐标系只能很好地描述 1D-3D 数组的索引,超过 3D 就无能为力了。
从自然的角度使用维度有限的 3D 空间来模拟可以有无限维的索引本身就是不合理的,那么就让索引回归索引,思考邮政系统是如何投递信笺的:地址就是索引最自然的应用。 一个地址可以由:国家,省份,城市,街道,小区,楼号,层号,房号构成,显然这就是一个树形图,采用树形坐标可以完美地描述任意维数组。
上图描述了一个 shape(2,2,2) 的 3D 数组,为了作图简便,最后两维保留了行列的2维形状,这样树形图就少了一次分支,不会显得太臃肿。虚线部分为扩展为 4D 数组时的最后一维的示意图,可以想见这个虚轴左侧对应 3 轴,右侧对应 0 轴,其余轴增加 1。
尽管我们极力想把最后两维想象成行或列,正如前所述,我们可以把任意两维作为行或列。我们可能认为一幅彩色图片在 Numpy 应该以 [RGB,W,H] 的索引方式来处理,这样行就对应了高度,列就对应了宽度,第一维描述各个通道。实际上并非如此,而是以 [W,H,RGB] 的方式处理的,这样 Numpy 的树形坐标就和 Octave/Matlab 保持一致了(0, 1轴描述高和宽,类似行和列)。
0 1 2 3 4 5 6 7 | from matplotlib import pyplot as plt
img = plt.imread("lena.png")
print(type(img).__name__)
print(img.shape)
>>>
ndarray
(256, 256, 3)
|
实际应用中应该根据具体数据,来变换索引(坐标)以最形象直观的方式描述,而不要拘泥于总是把最后两维看做行和列。理解了这一点,就明白了 np.swapaxes() 等轴变换函数的作用了。
数组视图¶
NumPy 中提供了大量的对数组进行处理的函数,这些函数返回的新数组中的元素和原数组元素具有两种关系:
- 引用,也即不对原数组中元素复制,修改元素会相互影响。
- 复制,拷贝副本,修改不会互相影响。包含简单索引(例如简单索引和切片组合使用)的引用方式,均会进行复制。
一个数组被称为数组包含的数据的一个视图(view),所以如果是引用返回的数组,则称为数据的另一个视图。不同视图是对数据的不同观察方式,体现在数组上就是形式的变形,不会拷贝任何东西。视图也被称为视窗。例如同样是 4 个元素,可以是 2x2 的 2 维数组,也可以组成 1x4 的向量或者 4x1 的 2 维数组,它们均是同一组数据的不同视图。
如何查看一个对象是视图,还是拥有 data 的原数组呢? ndarray.base 记录引用的原数组,所以如果 ndarray.base 不是 None,那么它就是视图,且原数组对象就是 ndarray.base。
0 1 2 3 4 5 6 7 8 9 10 | In [280]: a = np.array([1,2])
In [281]: a.base
In [282]: b = a.reshape(2,1)
In [283]: b.base
Out[283]: array([1, 2])
In [284]: b.base is a
Out[284]: True
|
不要使用 id(a) == id(b) 判断是否为视图,它们可能相等,也不要使用 id(a.data) == id(b.data) 判断视图,因为 data 是 memoryview 对象,不同的 memoryview 对象可能引用同一块内存区域,但是 memoryview 自身的地址是不同的。
0 1 2 3 | In [289]: a = memoryview(b'123')
In [290]: a # 此地址是 memoryview 自身的地址,不是它引用的对象地址
Out[290]: <memory at 0x0000029C61D18708>
|
步长 strides 是另一个 ndarray 对象成员,它对于理解数组视图至关重要。
0 1 2 3 4 5 6 7 | x = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]], dtype=np.int8)
t = x.T
print(t.base is x)
>>>
True
|
转置不会复制数据,所以 t 和 x 的 data 地址是相同的。但是它们的 stides 是不同的:
0 1 2 3 4 5 6 7 8 | print(x.strides)
>>>
(3, 1)
print(t.strides)
>>>
(1, 3)
|
strides 是一个元组,它的元素个数与 shape 元素个数相同,它记录了查找对应轴下一个元素需要偏移的字节数。为了加速访问数据,ndarray 对象的 data 数据在内存中均是连续成块存储的,所以如何解读这一块数据,就需要 strides 来指示。
通过 np.nditer 可以直接顺序访问这一连续内存,并打印各个元素以观察它们的在内存中的存储情况:
0 1 2 3 | In [1]: for i in np.nditer(x):
...: print(i, end=' ')
...:
1 2 3 4 5 6 7 8 9
|
由于转置操作不会对数组进行复制,所以这里的参数替换为 x.T 结果也是一样的。
这里的 x 类型定义为 int8,所以每个元素占用 1 个字节,x 的 strides 为 (3, 1) 表示:
- 需要偏移 3 个字节找到下一行的开始数据。
- 需要偏移 1 个字节找到下一列的开始数据。
有了 shape 和 strides 就构成了一个视图,可以对元素进行不同视图的解读。这种机制在 NumPy 被称为索引策略(indexing scheme),这些成员均存储在每个 ndarray 实例的管理字段中。
不同的 order 参数创建的数组的 strides 是不同的,例如:
0 1 2 3 4 5 6 | y = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]], dtype=np.int8, order='F')
print(y.strides)
>>>
(1, 3)
|
如果数组元素索引为 i[0], i[1], …, i[n],通过 strides 可以计算出元素在数组中的偏移字节数:
0 | offset = sum(np.array(i) * a.strides)
|
不同内存布局下,strides 的计算函数如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def f_contiguous_strides(itemsize, shape):
if shape:
strides = [itemsize]
for s in shape[:-1]:
strides.append(strides[-1]*s)
return tuple(strides)
else:
return ()
def c_contiguous_strides(itemsize, shape):
if shape:
strides = [itemsize]
for s in shape[:0:-1]:
strides.append(strides[-1]*s)
return tuple(strides[::-1])
else:
return ()
|
下面的示例构造一个从 0 开始的,差为 1 的等差数列,这样保证元素的偏移 = 数组元素 * itemsize:
0 1 2 3 4 5 6 7 8 9 10 11 12 | In [0]: x = np.reshape(np.arange(5*6*7*8), (5,6,7,8)).transpose(2,3,1,0)
In [1]: x.strides
Out[1]: (32, 4, 224, 1344)
# 计算[3,5,2,2]索引处的元素偏移字节数
In [2]: offset = sum(np.array([3,5,2,2]) * x.strides)
In [3]: x[3,5,2,2]
Out[3]: 813
In [4]: offset / x.itemsize
Out[4]: 813.0
|
尽管通过 np.isfortran(a) 接口可以判断数组元素的索引策略,该接口不再被推荐使用,而是直接查看 flags 字段:
0 1 2 3 4 5 6 7 | In [517]: x.flags
Out[517]:
C_CONTIGUOUS : True # C-like index order
F_CONTIGUOUS : False
OWNDATA : False
WRITEABLE : True
ALIGNED : True
UPDATEIFCOPY : False
|
对于 1D 向量来说,C_CONTIGUOUS 和 F_CONTIGUOUS 是没有区别的,strides 均为 (itemsize,),此时这两个标志均为 True。
数据复制¶
有些函数的帮助文件中会有如此描述,例如 np.ravel():
0 1 2 3 4 | ravel(a, order='C')
Return a contiguous flattened array.
A 1-D array, containing the elements of the input, is returned. A copy is
made only if needed.
|
np.ravel() 用于将数组展平为 1D 向量,它通常返回的是视图,但是注意 “A copy is made only if needed”,这说明有些情况返回的不是视图,而会进行数据复制。
何时需要复制数据呢?ravel() 返回的数组总是会指向一串连续的内存,如果展平的数组不能满足连续内存,那么只能进行数据复制:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | In [373]: a = np.arange(4).reshape(2,2)
In [374]: b = a[:,:1]
In [375]: b
Out[375]:
array([[0],
[2]])
In [376]: b.base
Out[376]: array([0, 1, 2, 3])
# 如果对 b 进行 ravel(),由于元素 0,2 内存不连续,会进行复制
In [377]: c = b.ravel()
In [378]: c.base
|
元素类型¶
NumPy标准数据类型:
数据类型 描述 bool 布尔值 bool_ 别名 bool_ 布尔值(真、 True 或假、 False) , 用一个字节存储 int int_ 别名 int_ 默认整型(类似于 C 语言中的 long, 通常情况下是 int64 或 int32) intc 同 C 语言的 int 相同(通常是 int32 或 int64) intp 用作索引的整型(和 C 语言的 ssize_t 相同, 通常情况下是 int32 或int64) int8 字节(byte, 范围从–128 到 127) int16 整型(范围从–32768 到 32767) int32 整型(范围从–2147483648 到 2147483647) int64 整型(范围从–9223372036854775808 到 9223372036854775807) uint8 无符号整型(范围从 0 到 255)uint16 无符号整型(范围从 0 到 65535) uint32 无符号整型(范围从 0 到 4294967295) uint64 无符号整型(范围从 0 到 18446744073709551615) float float64 的简化形式 float_ float64 的简化形式 float16 半精度浮点型:1 符号位,5 比特位指数(exponent),10 比特位尾数(mantissa) float32 单精度浮点型:1 符号位,8 比特位指数,23 比特位尾数 float64 双精度浮点型:1 符号位,11 比特位指数,52 比特位尾数 complex_ complex128 的简化形式 complex64 复数, 由两个 32 位浮点数表示 complex128 复数, 由两个 64 位浮点数表示
更多的信息可以在 NumPy 文档(http://NumPy.org/) 中查看。NumPy 也支持复合数据类型。创建数组时,如果不指定元素类型,元素默认类型为 float64。
浮点数据的精度可以通过 numpy.finfo 接口获取:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | # numpy.float 等价于 numpy.float64
In [53]: print (numpy.finfo(numpy.float))
Machine parameters for float64
---------------------------------------------------------------
precision = 15 resolution = 1.0000000000000001e-15
machep = -52 eps = 2.2204460492503131e-16
negep = -53 epsneg = 1.1102230246251565e-16
minexp = -1022 tiny = 2.2250738585072014e-308
maxexp = 1024 max = 1.7976931348623157e+308
nexp = 11 min = -max
---------------------------------------------------------------
In [54]: print (numpy.finfo(numpy.float32))
Machine parameters for float32
---------------------------------------------------------------
precision = 6 resolution = 1.0000000e-06
machep = -23 eps = 1.1920929e-07
negep = -24 epsneg = 5.9604645e-08
minexp = -126 tiny = 1.1754944e-38
maxexp = 128 max = 3.4028235e+38
nexp = 8 min = -max
---------------------------------------------------------------
|
np.array 会根据提供的数据自动选择 int32 或 flot64 作为数组的 dtype。对于使用切片生成数组的函数,也会根据参数类型自动选择生成数组的 dtype,例如 np.arange,不像 Python 的 range 函数,它可以接受浮点数作为参数:
0 1 2 3 4 | In [55]: np.arange(10)
Out[55]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
In [56]: np.arange(10.) # 指定 stop 为浮点数
Out[56]: array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9.])
|
注意
在整数后加一点 ‘.’,例如 10. 表示这是一个浮点数,是 10.0 的简写,常通过该简写生成浮点类型数组。
字符串类型¶
字符串类型在以数值处理见长的 numpy 中很少使用,但是不意味着它不支持。我们可以通过为 dtype 指定 ‘Sn’ 或者 ‘Un’ 来创建字符串类型数组。
- ‘Sn’ 中的 ‘S’ 表示 ascii string,它使用单个字节存储字符,n 表示 ascii 字符个数。
- ‘Un’ 中的 ‘U’ 表示 unicode string,它使用 unicode 编码存储字符,n 表示 unicode 字符个数。
注意
n 是字符的个数,不是字节数。
numpy 数组均是在创建时一次性分配连续内存的,所以一旦指定了 n,每个元素所能存储的字符长度就固定了。
np.str 是 ‘U1’ 的别名,所以使用 np.str 创建的数组只能存储一个 unicode 字符。
0 1 2 3 4 5 6 | In [8]: s = np.zeros((2,2), dtype='S1')
In [9]: s
Out[9]:
array([[b'', b''],
[b'', b'']],
dtype='|S1')
|
‘|’ 表示该类型不区分大小端。如果赋值的字符串超过元素所能存储的长度大小,并不会报错,而直接进行截断处理。 ‘Sn’ 类型的数组不支持宽字符赋值,否则进行报错处理。
0 1 2 3 4 5 6 | In [10]: s[0,0] = '123'
In [11]: s
Out[11]:
array([['1', ''],
['', '']],
dtype='|S1')
|
Numpy 推荐使用 unicode 类型来创建字符串数组,这样就可以兼容所有可用字符,注意 unicode 是大小端敏感的,所以考虑到程序的移植性,应该明确指定大小端。
0 1 2 3 4 5 6 7 8 9 | In [33]: s = np.zeros((2,2), dtype='<U2')
# 超过长度将进行截断处理
In [34]: s[0,0] = '你好吗'
In [35]: s
Out[35]:
array([['你好', ''],
['', '']],
dtype='<U2')
|
如何将字符串数组转换为一个字符串,很简单:
0 1 | In [38]: ''.join(s.ravel().tolist())
Out[38]: '你好'
|
如果想创建支持任意长度字符的数组,可以指定 object 类型,当然这一维这数组的每个元素可以支持任意类型,付出的代价是:数组无法一次性为所有元素分配连续内存,访问效率不高。
0 1 2 3 4 5 6 7 | In [48]: s = np.zeros((2,2), dtype=object)
In [49]: s[0,0] = 'hello'
In [50]: s
Out[50]:
array([['hello', 0],
[0, 0]], dtype=object)
|
同样字符串数组支持一系列的处理函数:字符串数组处理函数 ,借助它们可以实现一些有趣的操作,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 | ar = np.arange(9).astype('<U16')
commas = ar.copy()
commas.fill(',')
#将两个 shape 相同的数组按元素拼接起来,中间插入逗号
indices = np.core.defchararray.add(ar, commas)
indices = np.core.defchararray.add(indices, ar)
print(indices)
>>>
['0,0' '1,1' '2,2' '3,3' '4,4' '5,5' '6,6' '7,7' '8,8']
|
类型转换¶
数组的类型转换,不可直接修改 dtype,dtype 只是用于对内存进行解读的方式,但是内存空间的内容不会有任何改变,类似 C 语言中的指针类型转换:
0 1 2 3 4 5 6 7 8 9 10 11 12 | np.random.seed(0)
a = np.random.random(2)
print(a.dtype)
print(a)
a.dtype = 'int32'
print(a.dtype)
print(a)
>>>
float64 # 默认类型为 float64
[ 0.5488135 0.71518937]
int32
[1449071272 1071747041 -815757517 1072095956]
|
示例随机生成包含 2 个默认的 float64 元素的数组,直接修改类型为 ‘int32’,发现数组元素个数增加,这不是我们期待的结果。显然 dtype 用于对内存块的解读。
类型转换需要使用 numpy 提供的 astype 方法:
0 1 2 3 4 5 6 | a = a.astype(np.int32)
print(a.dtype)
print(a)
>>>
int32
[0 0]
|
创建数组¶
除了以下介绍的几种创建数组的方法外,也可以从迭代对象创建数组,参考 迭代对象创建数组。
全新创建¶
zeros 和 empty¶
zeros(shape, dtype=float, order='C')
empty(shape, dtype=float, order='C')
数组创建函数,通常具有类似的参数,例如 shape 指定各轴元素个数:
- 单个整数指定 1 维数组的大小。
- 一个元组或者序列类型来生成多维数组。
dtype 指定元素类型,默认 float64。order 指定存储类型,默认即可。
zeros() 生成全 0 数组, empty 生成未初始化值的数组。
0 1 2 3 4 5 6 | print(np.zeros(2, dtype='bool')) # 全 0 数组
print(np.empty((2, 5), dtype=int)) # 值未初始化的数组,不是随机元素
>>>
[False False]
[[ 0 0 0 1070596096 0]
[1071644672 0 1072168960 0 1072693248]]
|
like 生成函数¶
有些用于创建数组的函数名后缀为 _like,它与原函数功能类似,只是第一个参数是一个现成的数组,参考它的 shape 来生成特定数组。类似的函数有:
Like 函数 描述 empty_like 元素未初始化的数组 zeros_like 全 0 数组 ones_like 全 1 数组 full_like 填充给定的数字
0 1 2 3 4 | print(np.zeros_like([[1,1],[2,2]]))
>>>
[[0 0]
[0 0]]
|
全1数组¶
ones() 与 zeros() 恰恰相反,创建全 1 数组。
ones(shape, dtype=None, order='C')
0 1 2 3 | print(np.ones(2, dtype='int')) # 全 1 数组
>>>
[1 1]
|
单位矩阵¶
eye(N, M=None, k=0, dtype='float')
创建 N*M 的 2 维度单位矩阵,如果不提供 M,则 M=N,k 为全1的对角线索引:
0 1 2 3 4 5 6 7 8 | print(np.eye(2, dtype=int))
print(np.eye(3, k=1))
>>>
[[1 0]
[0 1]]
[[ 0. 1. 0.]
[ 0. 0. 1.]
[ 0. 0. 0.]]
|
填充特定值¶
full(shape, fill_value, dtype=None, order='C')
full() 根据 shape 生成特定维度的数组,所有元素默认值为 fill_value。
0 1 2 3 4 5 6 7 8 9 | print(np.full((2, 2), np.inf))
print(np.full((2, 2), 2))
print(np.full((), 1)) # 返回标量 1
>>>
[[ inf inf]
[ inf inf]]
[[2 2]
[2 2]]
1
|
fill 是 ndarray 对象方法,可以将数组填充为特定标量,注意会进行强制类型转换:
0 1 2 3 4 5 6 7 8 | In [65]: a = np.arange(5)
In [66]: a
Out[66]: array([0, 1, 2, 3, 4])
In [67]: a.fill(2.1) # 强制转换为 int 型
In [68]: a
Out[68]: array([2, 2, 2, 2, 2])
|
随机数数组¶
均匀分布¶
rand 返回离散均匀分布(discrete uniform)的 [0, 1] 取值填充的数组。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # 单个随机值
print(np.random.rand())
>>>
0.8257044198690662
# 1 维数组
print(np.random.rand(2))
>>>
[ 0.89012233 0.98822365]
# 指定 shape 的数组
print(np.random.rand(2,3))
>>>
[[ 0.58724409 0.17262095 0.29256442]
[ 0.89758811 0.00469506 0.00793409]]
|
整型均匀分布¶
randint 返回离散均匀分布(discrete uniform)的整型随机值填充的数组。
randint(low, high=None, size=None, dtype='l')
如果提供 high 从 [low, high) 中取随机数,否则从 [0, low) 中取随机数。size 指定 shape,dtype 指定元素类型,默认 int32。low 必须提供。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # size=None 时默认返回单个随机数
print(np.random.randint(10))
>>>
5
# 从 [0-2) 中取随机数,含 10 个元素的一维数组
print(np.random.randint(2, size=10))
>>>
[0 0 1 1 1 0 0 1 0 1]
# 从 [1-5) 中取随机数,指定 shape 的数组
print(np.random.randint(1, 5, size=(2, 4)))
>>>
[[1 3 4 1]
[3 2 4 2]]
|
random() 返回连续型均匀分布(continuous uniform)的 [0, 1) 随机值填充的数组。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # 单个随机数
print(np.random.random())
>>>
0.7490899812919358
# 1 维数组
print(np.random.random(1))
>>>
[ 0.08542616]
# 指定 shape 的数组
print(np.random.random((1,2)))
>>>
[[ 0.78634523 0.66910924]]
|
正态分布¶
正态分布(Normal distribution)又名高斯分布(Gaussian distribution)。
randn 返回符合标准正态分布的随机值填充的数组。
0 1 2 3 4 5 6 7 8 | print(np.random.randn()) # 返回一个随机值
print(np.random.randn(1)) # 返回一维数组
print(np.random.randn(2, 2)) # 返回二维数组
>>>
0.48496737321135236 # float 类型
[-0.54254042] # ndarray 类型
[[-0.21879005 0.47782525]
[-0.59249748 0.39013432]]
|
所谓标准正态分布,也即所有元素均值为 0,标准差为 1。
normal(loc=0.0, scale=1.0, size=None)
np.random.normal() 是另一个支持更详细参数的正态分布函数,loc 指定均值,默认 0,scale 指定标准差,默认 1:
0 1 2 3 4 5 6 7 | # 创建一个3×3的、 均值为0、 方差为2的正态分布随机数组
A = np.random.normal(0, 2, (3, 3))
print(A)
>>>
[[-0.04586759 -0.953187 5.27807227]
[-1.74930541 -0.95083919 -1.50893838]
[-0.15744789 -5.26709878 -3.04729709]]
|
泊松分布¶
0 1 2 3 4 5 6 | # λ 为6,指定 shape 的泊松分布
print(np.random.poisson(6, (3 ,3)))
>>>
[[4 5 1]
[6 1 8]
[3 2 8]]
|
乱序操作¶
random.shuffle 可以对序列类型,例如 list 或者一维数组进行乱序操作,操作直接作用在参数对象上:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 一维数组乱序
narray = np.arange(10)
np.random.shuffle(narray)
print(narray)
>>>
[6 4 8 3 1 9 0 5 2 7]
# 列表乱序
list0 = [0,1,2,3]
np.random.shuffle(list0)
print(list0)
>>>
[1, 3, 0, 2]
|
元素范围映射¶
有时我们希望元素分布在任意指定的 [a, b) 区间,而不是 [0, 1) 之间,可以通过如下方式映射到 [a, b) 空间:(b - a) * random() + a。
0 1 2 3 4 5 | # 映射到 [-5, 0)
print(5 * np.random.random((2, 2)) - 5)
>>>
[[-4.02260888 -1.18260402]
[-0.75450539 -1.48321213]]
|
随机种子¶
如果设置了随机种子,可以保证每次生成相同的随机值,np.random.seed(seed=None),种子是一个无符号 int32 整型。
0 1 2 3 4 5 | np.random.seed(0) # 设置随机数种子
x1 = np.random.randint(10, size=6)
print(x1)
>>>
[5 0 3 3 7 9]
|
从已有元素创建数组¶
list 转数组¶
array(object, dtype=None, copy=True, order='K', subok=False, ndmin=0)
array() 可以实现列表向数组的转换,自动提升元素类型。它还用于索引和切片。copy 指定是复制原数组还是引用。
0 1 2 3 4 5 6 7 | A = np.array([[1, 2], [3, 4]])
print(A)
print(np.array([1, 2, 3.0])) # 自动提升类型
>>>
[[1 2]
[3 4]]
[ 1. 2. 3.]
|
array() 可以生成 0D 的标量数组,它的 shape 为空的 tuple:
0 1 2 3 4 5 6 | In [229]: a = np.array(0)
In [231]: a
Out[231]: array(0)
In [230]: a.shape
Out[230]: ()
|
subok 表示是否将子类型转换为 ndarray,例如:
0 1 2 3 4 5 6 7 8 9 10 | np.array(np.mat('1 2; 3 4'), subok=True)
>>>
matrix([[1, 2], # 类型依然为 matrix,保留子类型
[3, 4]])
np.array(np.mat('1 2; 3 4'), subok=False)
>>>
array([[1, 2], # 类型转化为 ndarray
[3, 4]])
|
asarray(a, dtype=None, order=None)
asarray() 与 array 功能类似,都可以转换其他类型到数组,唯一区别是当原类型是数组时,asarray 不对数据复制,只是标签引用,array 总是进行复制。
0 1 2 3 4 5 6 7 8 9 10 11 12 | list0 = [[0,0,0]]
A0 = np.array(list0)
A1 = np.asarray(list0)
list0[0][0] = 1
print(list0)
print(A0)
print(A1)
>>>
[[1, 0, 0]]
[[0 0 0]]
[[0 0 0]]
|
上面示例对 list 转换为 ndarray 类型,所以首先会创建 ndarray,然后对元素进行复制。如果源类型为数组,则不会复制:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A0 = np.array([0,0,0])
A1 = np.array(A0)
A2 = np.asarray(A0)
A0[0] = 1
print(A0)
print(A1)
print(A2)
>>>
[1 0 0]
[0 0 0]
[1 0 0]
|
如果要对数组进行复制,一般使用 copy() 函数。array() 中的 copy 参数开关复制功能。
数组转 list¶
ndarray 类型转为list类型使用对象的 tolist 方法即可。转 list 可以进行序列化存储。
0 1 2 3 4 | A0 = np.array([[1, 2], [3, 4]])
print(A0.tolist())
>>>
[[1, 2], [3, 4]]
|
set 转数组¶
set 是 python 自带的集合类型,与 list 不同,如果需要把 set 转换为 ndarray,则首先需要转换为 list,然后再创建数组,否则创建出的数组将是 object 类型,并把 set 作为一个整体对待,这可能不是我们想要的。
0 1 2 3 4 5 | In [28]: a_set = set([1,1,2,3])
In [29]: bad_array = np.array(a_set)
In [30]: bad_array.dtype
Out[30]: dtype('O') # 默认创建的是 object 类型
|
把 set 转化为 list,然后再创建 ndarray:
0 1 2 3 4 5 6 7 8 | # 先把集合转化为 list
In [35]: b_list = list(a_set)
In [36]: b_list
Out[36]: [1, 2, 3]
# 然后再使用 list 创建数组
In [37]: np.array(b_list)
Out[37]: array([1, 2, 3])
|
字节流转数组¶
frombuffer。
数组文件¶
保存到文件¶
savetxt(fname, X, fmt='%.18e', delimiter=' ', newline='\n', header='', footer='', comments='# ')
我们可以通过 np.savetxt 将数组保存到 txt 文件,例如:
0 1 | a = np.arange(4).reshape(2,2)
np.savetxt('narray.txt', a)
|
narray.txt 文件内容如下,尽管 dtype 为 int32,数据看起来就是浮点数,这是由于默认参数为 fmt 设置成了 ‘%.18e’:
0 1 | 0.000000000000000000e+00 1.000000000000000000e+00
2.000000000000000000e+00 3.000000000000000000e+00
|
从文件加载¶
loadtxt(fname, dtype=<class 'float'>, comments='#', delimiter=None, converters=None,
skiprows=0, usecols=None, unpack=False, ndmin=0)
np.loadtxt 实现从文件加载,相当于 np.savetxt 的逆向操作,所以要保持相关参数一致,例如 delimiter。
0 1 2 3 4 5 6 7 | a = np.loadtxt('narray.txt', delimiter=' ')
print(a.dtype)
print(a)
>>>
float64
[[ 0. 1.]
[ 2. 3.]]
|
显然通过 txt 文件只能保存数组的数据部分,部分信息(数组类型)被丢失了。使用 Python 的 pickle 数据包可以轻松完成这一功能,并且支持多个数组的保存,当然缺点是无法打开文件直接查看数据。
pickle 操作¶
这里定义了两个函数,用于一次保存或者加载多个 Python 对象,显然这些对象也可以是 ndarray。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | def db_pickle_save(file, data, overwrite=False):
import pickle,gzip
'''
file:
file path, to save gize pickle
data:
with style [] or ()
'''
if overwrite== False and os.path.exists(file):
print("Can't over write {}.".formate(file))
return
with gzip.open(file, "w") as f:
pickle.dump(data, f)
def db_pickle_load(file):
import pickle,gzip
'''
file:
file path, to save gize pickle
data:
with style [] or ()
'''
if not os.path.exists(file):
print("File {} do not exist.".formate(file))
return
with gzip.open(file, 'rb') as f:
return pickle.load(f)
|
操作很简单,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 | a = np.arange(4).reshape(2,2)
b = np.arange(9).reshape(3,3)
fname = "narray.gzip"
dbload.db_pickle_save(fname, [a, b])
a, b = dbload.db_pickle_load(fname)
print(a.dtype, b.dtype)
print(a)
>>>
int32 int32
[[0 1]
[2 3]]
|
通过 pickle 可以完整保存 python 对象的所有信息。
数列数组¶
等差数列 arange¶
arange 可以生成整型或者浮点型数列,这与 Python 的 range 函数不同。
arange([start,] stop[, step,], dtype=None)
从 [start, stop) 中每隔 step 取值,生成等差数列,不含 stop。不指定 dtype 则根据数据使用最小满足类型。
0 1 2 3 | np.arange(0, 5, 2) # 生成一个线性序列
>>>
[0 2 4 6 8]
|
默认 start = 0,step = 1,下面示例生成 0-7 组成的行向量。
0 1 2 3 | np.arange(8)
>>>
[0 1 2 3 4 5 6 7]
|
尽管 arange 声称不含 stop,但是当参数为浮点数时,由于浮点数舍入误差(round-off error)的影响,可能会包含 stop,例如:
0 1 | In [1]: np.arange(1.5, 1.8, 0.3)
Out[1]: array([ 1.5, 1.8])
|
浮点数在计算机内无法精确存储,例如这里的 0.3 实际存储的不是准确的 0.3,这导致 1.5 + 2.9. 后再进行舍入操作得到了 1.8:
0 1 2 3 | In [188]: a = np.array([1.8,0.3])
In [189]: a[1]
Out[189]: 0.29999999999999999
|
注意
通常使用 np.linespace 来生成浮点型的差数列,而 np.arange 用于生成整型的等差数列以和 range 函数保持一致,并避免浮点误差问题。
等差数列 linespace¶
linspace(start, stop, num=50, endpoint=True, retstep=False, dtype=None)
linspace() 通过个数自动推断 step,均匀地从 [start, stop] 中取等差数列。
endpoint 是否包含 stop 元素,如果为 True,则差值等于 (stop - start)/(num-1),否则差值为 (stop - start)/(num),如果 num 为 1,则直接取 start。
retstep 如果为 True,返回 (‘等差数列’, ‘step’)。
0 1 2 3 4 5 6 7 8 9 10 | print(np.linspace(1, 10, 4, endpoint=True)) # 步长为 (10-1)/(4-1) = 3
print(np.linspace(1, 10, 4, endpoint=False))# 步长为 (10-1)/4 = 2.25
# 同时返回数组和步长
A,step = np.linspace(1, 10, 4, endpoint=False, retstep=True)
print(A, step)
>>>
[ 1. 4. 7. 10.]
[ 1. 3.25 5.5 7.75]
[ 1. 3.25 5.5 7.75] 2.25
|
等比数列 logspace¶
logspace(start, stop, num=50, endpoint=True, base=10.0, dtype=None)
logspace() 等价于先等差再对元素以底数 base 乘幂:
0 1 | y = np.linspace(start, stop, num=num, endpoint=endpoint)
power(base, y).astype(dtype)
|
生成比例为 2 的等比数列:
0 1 2 3 | print(np.logspace(0, 5, num=6, endpoint=True, base=2.0))
>>>
[ 1. 2. 4. 8. 16. 32.]
|
索引和切片¶
数组索引¶
简单索引¶
类似 Python 列表, 在一维数组中,可以通过中括号指定索引获取某个元素,支持正负索引:
0 1 2 3 4 | A = np.array([0,1,2])
print(A[0], A[-1])
>>>
0 2
|
简单索引会把原数组元素拿出来(复制一份),并且会改变返回数组的维度。
在多维数组中, 可以用逗号分隔的索引元组获取元素:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | A = np.arange(9).reshape(3,3)
print(A)
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
print(A[0,0], A[-1, -1]) # 逗号方式
print(A[0][0], A[-1][-1]) # 类list方式
>>>
0 8
0 8
|
列表索引¶
列表索引是花式索引(fancy indexing)的一种,使用列表索引,结合切片索引,可以选择特定的多行或多列。切片索引参考 数组切片 。
0 1 2 3 4 5 6 7 8 9 10 11 | print(A[[1,2], :]) # 选择 1,2 行
>>>
[[3 4 5]
[6 7 8]]
print(A[:, [1,2]]) # 选择 1,2 列
>>>
[[1 2]
[4 5]
[7 8]]
|
如果要同时选择 1,2 行和 1,2 列需要分步进行:
0 1 2 3 4 5 6 7 | # 此方式选择元素并组合为向量
print(A[[1,2],[1,2]])
print(A[[1,2], :][:,[1,2]])
>>>
[4 8]
[[4 5]
[7 8]]
|
第一种的方式,会选择 [1,2] 和 [1,2] 作为行列坐标,并生成向量 [A[1,1], A[2,2]],注意它们的区别。
修改元素值¶
如果可以索引到某个元素,那么也可以通过索引赋值,来更新元素:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | A = np.array([[0,1,2],[3,4,5]])
print(A)
A[0,0] = -1
print(A)
>>>
[[0 1 2]
[3 4 5]]
[[-1 1 2]
[ 3 4 5]]
A[:, [1,2]] = -1 # 列表索引把 1,2 列所有元素赋值为 -1
print(A)
>>>
[[ 0 -1 -1]
[ 3 -1 -1]]
|
注意
ndarray 对象元素必须为相同类型,所以更新元素值时会自动转换类型,也即 A[index,…] = A.dtype(newval)。
数组切片¶
类似 Python 中的列表,也可以用切片(slice) 符号获取数组的多个元素, 切片符号用冒号(:) 表示。
切片操作支持指定步长,格式为 [start:stop:step],步长可以为负数,此时如果 start 和 stop 如果没有提供默认值,则对应尾部索引和头部索引值。
如果以上 3 个参数都未指定, 那么它们会被分别设置默认值 start=0、stop= 维度的大小(size of dimension) 和 step=1。
注意
ndarray 切片操作不会复制数据,新数组是原数组的一个视图,这和 Python 切片浅拷贝有本质区别,简单索引会复制。可以使用 a.base is not None 查看对象是否为视图。
一维数组切片¶
一维数组切片和列表切片操作完全相同:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
print(A[:2]) # 前2个元素
print(A[2:]) # 索引 2 之后的元素
print(A[3:5]) # 索引 [3-5) 子数组
print(A[::2]) # 每隔一个元素
print(A[4::2]) # 每隔一个元素, 从索引4开始
>>>
[0 1]
[2 3 4 5 6 7 8 9]
[3 4]
[0 2 4 6 8]
[4 6 8]
|
指定 step 为 -1,此时 start 指向尾部索引,stop 指向头部,如果指定 start 和 stop,则 start > stop:
0 1 2 3 4 5 | print(A[::-1]) # 逆序
print(A[5:1:-2]) # 从索引 [5,1) 逆序间隔取元素
>>>
[9 8 7 6 5 4 3 2 1 0]
[5 3]
|
多维数组切片¶
多维数组切片格式与一维数组一致,只是分别对每一个维度进行切片。
0 1 2 3 4 5 6 7 8 9 10 | A = np.array([[ 0, 1, 2, 3],
[10, 11, 12, 13],
[20, 21, 22, 23]])
print(A[:2, :2]) # 取第 0,1 行和第 0,1 列
print(A[1:, 1:]) # 去掉第一行和第一列
>>>
[[ 0 1]
[10 11]]
[[11 12 13]
[21 22 23]]
|
从示例中可以看出,使用切片很容易取左上角和右下角元素。当然也可按步间隔选取特定行或者列:
0 1 2 3 4 5 6 7 8 9 | print(A[::2, :]) # 隔行选取行
print(A[:, ::2]) # 隔列选取列
>>>
[[ 0 1 2 3]
[20 21 22 23]]
[[ 0 2]
[10 12]
[20 22]]
|
对多维数组进行逆序操作:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | print(A[::-1, :]) # 逆序行
>>>
[[20 21 22 23]
[10 11 12 13]
[ 0 1 2 3]]
print(A[:, ::-1]) # 逆序列
>>>
[[ 3 2 1 0]
[13 12 11 10]
[23 22 21 20]]
print(A[:-1:, ::-1]) # 逆序行和列
>>>
[[23 22 21 20]
[13 12 11 10]
[ 3 2 1 0]]
|
对于 3 维或以上的多维数组,可以进行如下简写:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | A = np.arange(16).reshape((2, 2, 4))
print(A)
print(A[1, ...]) # 等价于 A[1,:,:]
print(A[..., 1]) # 等价于 A[:,:,1]
>>>
[[[ 0 1 2 3]
[ 4 5 6 7]]
[[ 8 9 10 11]
[12 13 14 15]]]
[[ 8 9 10 11]
[12 13 14 15]]
[[ 1 5]
[ 9 13]]
|
需要注意的是 A[1, …] 中的 1 是简单索引,返回 A[1] 对应的元素,它是一个数组形状为 (2,4) 的数组。
A[…, 1] 则是先找到最后一维的元素,然后拿出其中索引为 [1] 的元素:
0 1 2 3 4 5 6 7 | [ 0 1 2 3] # => 1
[ 4 5 6 7] # => 5
[ 8 9 10 11] # => 9
[12 13 14 15] # => 13
# 然后把拿出的元素放回原位置,替代最后一维的元素,也即 1 替代 [ 0 1 2 3]
[[ 1 5]
[ 9 13]]
|
注意:A[…, 1] 和 A[…, 1:] 不等价,A[…, 1:] 返回与原数组相同形状的数组。
取行和列¶
使用切片操作可以选取任意行和列:
0 1 2 3 4 5 6 7 8 9 | A = np.array([[ 0, 1, 2, 3],
[10, 11, 12, 13],
[20, 21, 22, 23]])
print(A[:, 0]) # 取第 0 列
print(A[1, :]) # 取第 1 行
>>>
[ 0 10 20]
[10 11 12 13]
|
需要注意的是,选取的列变成了行向量,而不是列向量,如果要返回 n*1 列向量则需要进行变形。
0 1 2 3 4 5 6 | column = A[:, 0].reshape((3, 1))
print(column)
>>>
[[ 0]
[10]
[20]]
|
在获取行时,可以省略二维索引,例如 A[1] 和 A[1, :] 是等价的。可以将行赋值给多个元素:
0 1 2 3 4 5 6 7 8 9 10 11 | A = np.arange(4).reshape(2,2)
a,b=A
print(a)
print(b)
>>>
[0 1]
[2 3]
# 以上操作等价于
a = A[0]
b = A[1]
|
为任意行列赋值¶
我们可以任意选取行或列,当然也可以为这些行或列赋值:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | A = np.arange(9).reshape(3,3)
print(A)
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
# 将行 1,2 元素赋值为 -1
A[[1,2], : ] = -1
print(A)
>>>
[[ 0 1 2]
[-1 -1 -1]
[-1 -1 -1]]
# 将列 1,2 元素赋值为 -2
A[:, [1,2]] = -2
print(A)
>>>
[[ 0 -2 -2]
[-1 -2 -2]
[-1 -2 -2]]
|
交换行和列¶
使用切片很容易交换任意行和列,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | A = np.arange(9).reshape(3,3)
print(A)
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
# 交换 1 行和 2 行
A[[1,2], : ] = A[[2,1], :]
print(A)
>>>
[[0 1 2]
[6 7 8]
[3 4 5]]
# 交换 1 列和 2 列
A[:, [1,2]] = A[:, [2,1]]
print(A)
>>>
[[0 2 1]
[6 8 7]
[3 5 4]]
|
也可以使用置换矩阵进行交换,参考 交换行或列。
复制和层叠¶
复制数组¶
ndarray 对象 copy() 方法可以方便对数组对象的复制:
0 1 2 3 4 5 6 7 | A = np.array([0, 1, 2])
row = A[:2].copy()
print(row)
print(A.copy())
>>>
[0 1]
[0 1 2]
|
此时修改新数组,原数组不受影响。
repeat¶
np.repeat 对数组进行 逐元素 重复以生成新数组,在深入介绍它之前,先看一个例子:
0 1 2 3 | print(np.repeat(3, 4))
>>>
[3 3 3 3]
|
np.repeat 生成了向量,把 3 重复了 4 次。np.repeat 可以完成更复杂的功能:
repeat(a, repeats, axis=None)
Repeat elements of an array.
- a 可以是一个数,也可以是数组。
- axis=None,时会进行 a.flatten(),实际上就是变成一向量,否则在指定的轴上重复。
- repeats 可以为一个数,也可以为一个序列或数组,它会被广播以匹配要复制的轴的形状。
我们分析上面示例的实现过程:
- 如果 a 不是数组,首先把 a 转换为 1 维数组,这里 a 为 3,转换为 [3]
- 由于 axis = None,所以对 a 展平成一维数组,a.flatten() 也即 [3]
- a.shape 为 (1,),repeats 转换为 [4],shape 为 (1,),形状相同,如果不同按照广播规则扩展为相同
- 最后元素 3 对应的重复次数为 4,也即 3 重复 4 次得到 [3 3 3 3]
再看一个稍微复杂的例子,可以看出最终重复是以单个元素为单位的:
0 1 2 3 4 5 6 7 8 9 10 | # 等价于 np.repeat(np.array([1,2]), [2])
print(np.repeat(np.array([1,2]), 2))
>>>
[1 1 2 2]
# 由于 axis = None,所以先展平为一维数组再重复
print(np.repeat(np.array([[1,2],[3,4]]), [2]))
>>>
[1 1 2 2 3 3 4 4]
|
展平后的 shape 为 (4,),而 repeat.shape 为 (1,),所以广播扩展为 [2 2 2 2],然后各元素按照对应的重复次数进行重复。
下面的示例展示 axis = n 的作用,注意 axis 参数不可以超过指定的数组维数:
0 1 2 3 4 5 6 7 8 9 10 | A = np.array([[1,2],[3,4]])
B = np.repeat(A, [2], axis = 0)
print(A.shape, B.shape)
print(B)
>>>
(2, 2) (4, 2) # 只对 0 轴重复
[[1 2]
[1 2]
[3 4]
[3 4]]
|
当指定 axis = 0 时,只对 0 轴重复。A 的 shape[0] 为 2, repeat 的 shape 为 1,广播扩展为 [2 2],然后对 0 轴各个元素重复,使得 A.shape[0] = 4。
再分析一个更复杂的例子,每个元素进行不同的重复:
0 1 2 3 4 5 6 7 | A = np.repeat(np.array([[1,2],[3,4]]), [2,3], axis=1)
print(A.shape)
print(A)
>>>
(2, 5)
[[1 1 2 2 2]
[3 3 4 4 4]]
|
这里对 1 轴进行重复,步骤如下:
- A.shape(2,2),也即 A.shape[1] = 2,repeat.shape 也等于 2,不用扩展
- 分别对 1 轴上的元素 1,2 重复 2,3 次,3,4 重复 2,3 次。
再看一个不符合广播规则的例子:
0 1 2 3 | A = np.repeat(np.array([[1,2,3],[4,5,6]]), [2,3], axis=1)
>>>
ValueError: operands could not be broadcast together with shape (3,) (2,)
|
层叠¶
tile(A, reps)
Construct an array by repeating A the number of times given by reps.
tile 英文原意为“用瓦片、瓷砖等覆盖”,这里引申为复制数组A,复制的过程很像瓦片层叠地铺开,返回一个新数组。
- A 可以是一个数,自动转换为 [A]。
- reps 是 repetitions 的缩写,描述如何进行复制,它是一个数或元组或一维数组,均会转变为一维数组。
新数组的维度大小由 max(d, A.ndim) 决定,其中 d 为元组 reps 的元素个数。由 d 和 A.ndim之间的大小关系,分三种情况讨论。
A.ndim < d¶
- A 在左侧添加新轴,以满足 A.ndim == d。
- 根据reps中的值对A在相应维度的值进行复制。
0 1 2 3 4 | print(np.tile(1, (2,3)))
>>>
[[1 1 1]
[1 1 1]]
|
- A = 1,转换为 [1],A.ndim = 1;reps 对应一维数组 [2 3],d = 2。
- 由于 A.ndim < d,所以对 A.shape=(1,) 扩充为 A.shape=(1,1)
- 此时 A 对应 [[1]],然后各 axis 按照 reps[axis] 给定的重复次数重复元素
- 首先重复 0 轴 2 次 [[1][1]],再重复 1 轴 3次 [[1 1 1] [1 1 1]]
A.ndim > d¶
将 reps 按广播规则扩充至与A相同的维度:向reps元组中左侧添加1。
0 1 2 3 | print(np.tile([[1,2]], (2)))
>>>
[[1 2 1 2]]
|
- A.ndim = 2, reps.d = 1,将 reps 扩展为 [1 2]
- 0 轴重复 1 次,1 轴重复 2 次
A.ndim = d 的情况比较简单,不用扩充,直接重复即可。
repeat 和 tile 的区别¶
- repeat 只能对特定轴重复,repeats 参数广播匹配到该轴的任何一个元素
- tile 可以同时对多个轴重复,reps 广播到各个轴。
0 1 2 3 4 5 6 | A = np.array([[1,2]])
print(np.repeat(A, [2], axis=1))
print(np.tile(A, [2]))
>>>
[[1 1 2 2]]
[[1 2 1 2]]
|
tile 示例:
0 1 2 3 | img = plt.imread("lena.png")
# 分别在行和列重复 2,2 第3维RGB数据不重复
mpl.image.imsave('tile.png', np.tile(img, [2,2,1]))
|
repeat 示例,每列均进行了插值,图像变宽:
0 1 | # 对轴 1 进行重复
mpl.image.imsave('repeat.png', np.repeat(img, [2], axis=1))
|
数组变形¶
reshape¶
reshape(a, newshape, order='C')
reshape() 函数对输入数组使用新的 newshape 进行变形,返回新数组,数组元素是原数组引用,不会复制。
使用 reshape() 必须满足原数组的大小和变形后数组大小一致。
0 1 2 3 4 5 6 7 8 9 10 | A = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8])
newA = np.reshape(A, (3, 3))
newA[0, 0] = -1
print(newA)
print(A)
>>>
[[-1 1 2]
[ 3 4 5]
[ 6 7 8]]
[-1 1 2 3 4 5 6 7 8]
|
reshape 在某一维度上可以支持 -1 参数,这样该维度将自动由元素个数来计算:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | In [51]: a
Out[51]:
array([[0, 1],
[2, 3]])
In [52]: a.reshape(-1,2) # 自动生成 2*2 数组
Out[52]:
array([[0, 1],
[2, 3]])
In [53]: a.reshape(-1,1) # 自动生成 4*1 数组
Out[53]:
array([[0],
[1],
[2],
[3]])
|
增加维度¶
np.newaxis 的值被定义为 None,它可以作为索引值传递给 ndarray 对象,并返回一个添加了维度(轴)的新数组,不复制元素。
0 1 2 3 4 5 6 7 8 9 10 | A = np.array([1, 2, 3])
B = A[np.newaxis, :] # 添加行,变成 1*n 二维数组
C = A[:, np.newaxis] # 添加列,变成 n*1 二维数组
print(B)
print(C)
>>>
[[1 2 3]]
[[1]
[2]
[3]]
|
np.newaxis 放在第几个位置,就会在 shape 中相应位置增加一个维数。
0 1 2 3 4 5 6 | A = np.arange(4).reshape(2,2)
print(A.shape)
print(A[:,np.newaxis,:].shape)
>>>
(2, 2)
(2, 1, 2)
|
通常从二维数组里面抽取一列,取出来之后维度却变成了一维,如果我们需要将其还原为二维,就可以使用上述方法。
当然,也可以使用 reshape() 来实现这类变形。
扩展维度¶
np.expand_dims 是另一个扩展维度函数,可以直接通过 axis 指定要扩展的维度的轴。
0 1 2 3 4 5 6 7 8 | A = np.arange(4).reshape(2,2)
print(A.shape)
print(np.expand_dims(A, axis=0).shape)
print(np.expand_dims(A, axis=1).shape)
>>>
(2, 2)
(1, 2, 2)
(2, 1, 2)
|
axis 大于当前维度时,在最后的轴上扩展维度
0 1 2 3 | print(np.expand_dims(A, axis=10).shape)
>>>
(2, 2, 1)
|
数组展平¶
数组展平,也即多维数组降为一维数组,np.ravel 和 ndarray.flatten 实现该功能,区别在于 ndarray.flatten 返回一份拷贝。
0 1 2 3 4 5 6 7 8 9 | A = np.arange(4).reshape((2, 2))
print(A)
print(A.ravel()) # 返回视图
print(A.flatten()) # 返回拷贝
>>>
[[0 1]
[2 3]]
[0 1 2 3]
[0 1 2 3]
|
拼接和分割¶
行列合并和扩展¶
向量拼接¶
np.r_ 拼接多个向量,标量,列表,元组或切片对象,并返回向量,与 np.concatenate 相比,它可以处理 slice 切片对象。
该方法通过类实现并重载了索引运算符 [],所以用中括号 [] 调用, 而不是 ()。[] 被称为索引表达式。
0 1 2 3 4 | c = np.r_[0.0, np.array([1,2,3,4]), 0.0]
print(c)
>>>
[ 0. 1. 2. 3. 4. 0.]
|
切片对象拼接:
0 1 2 3 4 | # 等价于 np.r_[0.0, slice(1,5), 0.0]
print(np.r_[0.0, 1:5, 0.0])
>>>
[ 0. 1. 2. 3. 4. 0.]
|
切片支持虚数,此时按照 np.linspace 扩展元素个数,包含 stop:
0 1 2 3 | print(np.r_[-1:1:5j, [0]*3, 5, 6])
>>>
[-1. -0.5 0. 0.5 1. 0. 0. 0. 5. 6. ]
|
np.r_ 的实现等价于如下代码:
0 | concatenate(map(atleast_1d,args),axis=0)
|
- 如果索引表达式 (index expression)是以逗号分割的数组,在 0 轴合并它们。
- 如果表达式包含切片索引,标量则首先使用 np.atleast_1d 把它们转换为 1D 向量。
np.r_ 可接受一个字符串,用于指定拼接的轴,例如:
0 1 2 3 4 5 | a = np.array([[0, 1, 2], [3, 4, 5]])
print(np.r_['-1', a, a]) # -1 表示在最后一轴进行拼接
>>>
[[0 1 2 0 1 2]
[3 4 5 3 4 5]]
|
np.r_ 还支持更复杂的字符串参数,例如 ‘0,2,0’:
- 其中第一字符 ‘0’ 表示在 0 轴进行拼接。
- 第二个字符 ‘2’ 表示返回的数组轴数至少为 2,如果不足则在 0 轴前部插入 1 (pre-pended,最后轴后部插入 1,称为 post-pended)。
- 第三个字符 ‘0’ 表示轴 0 与最后一轴 (axis = -1) 进行交换。
实际上拼接动作在最后进行,先对各个数组进行维度扩充,然后交换轴,最终调用 np.concatenate 进行拼接。
0 1 2 3 4 5 6 | print(np.r_['0,2,0', [1,2], [3,4]])
>>>
[[1]
[2]
[3]
[4]]
|
以上操作等价于:
- 首先使用 atleast_1d 将所有序列参数转化为 1D 向量 ndarray 类型,得到 [1 2] 和 [3 4]。
- 接着转换为 2D 数组,也即进行 pre-pended,得到 shape=(1,2) 的 2D 数组 [[1 2]] 和 [[3 4]]。
- 由于第三个字符为 ‘0’,继续交换 0 轴和 -1 轴,也即得到 2x1 两个 2D 数组 [[1] [2]] 和 [[3] [4]]。
- 最后在 axis = 0 上进行拼接得到 [[1] [2] [3] [4]]。
np.c_ 的实现等价于:
0 | np.r_['-1,2,0', index expression]
|
显然 np.c_ 总是在最后一轴进行合并,并返回至少是 2D 的数组,且交换 0 轴和最后一轴。
0 1 2 3 4 5 6 7 8 9 | import numpy as np
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
c = np.c_[a,b]
print(c)
>>>
[[1 4]
[2 5]
[3 6]]
|
以上操作步骤如下:
- 首先转换为 2D 数组,得到 1x2 数组 [[1 2 3]] 和 [[4 5 6]]。
- 接着交换 0 轴和 -1 轴,得到 2x1 的 2D 数组,[[1] [2] [3]] 和 [[4] [5] [6]]。
- 最后在 axis = -1 轴进行合并,最终得到如上结果。
通常 np.r_ 和 np.c_ 只用于切片对象的合并,由于它们通过 Python 的类实现,所以效率不高,另外字符参数比较隐晦,包含了多步操作,使得代码难于理解,更易用易读的合并操作应该通过 stack 系列函数完成。
向量合并为矩阵¶
column_stack 将 1D 向量作为列,合并为 2D 数组,参数只可以为 1D 数组。
0 1 2 3 4 5 6 7 8 9 | a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
# 按列合并为二维数组
print(np.column_stack([a, b]))
>>>
[[1 4]
[2 5]
[3 6]]
|
column_stack 代码实现等价于:
0 1 | arrays = map( transpose,map(atleast_2d,tup) )
concatenate(arrays, axis=1)
|
- 首先对所有数组通过 pre-pended 扩充,转换为 2D 数组。
- 接着进行交换行和列,也即转置操作。
- 最后在列上进行拼接。
row_stack 等价于 vstack,在行上均迭 1D 向量,合并为 2D 数组。
0 1 2 3 4 5 | # 按行合并为二维数组
print(np.row_stack([a, b]))
>>>
[[1 2 3]
[4 5 6]]
|
总结:从数字序列转换为 1D 向量可以使用 np.r_,多个 1D 向量可以使用 column_stack 和 row_stack 转换为 2D 矩阵。
扩展行或列¶
numpy.insert(arr, obj, values, axis=None)
numpy.insert 接受四个参数,axis 是可选参数。返回一个插入向量后的数组。若axis=None,则返回一个扁平(flatten)数组。
- arr:要插入元素的数组
- obj:int,指定插入的位置,在第几行/列之前
- values: 要插入的数组
- axis:要插入的的轴,插入某一行(0),列(1)
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | a = np.array([1, 2, 3])
b = np.array([0,0])
# 0 轴插入
c = np.insert(a, 1, b, axis=0)
print(c)
>>>
[1 0 0 2 3]
a = np.array([1, 2, 3, 4]).reshape(2,2)
b = np.array([0,0])
print(a)
>>>
[[1 2]
[3 4]]
|
行插入和列插入通过 axis 指定插入轴:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | # 行插入
print(np.insert(a, 1, b, axis=0))
>>>
[[1 2]
[0 0]
[3 4]]
# 列插入
print(np.insert(a, 1, b, axis=1))
>>>
[[1 0 2]
[3 0 4]]
|
数组堆叠和拼接¶
堆叠和拼接操作会复制原数组元素。
任意轴拼接¶
concatenate(tuple) 将相同轴数的数组元组进行拼接。结果数组不改变轴数。之所以首先介绍该函数,在于下面的 stack 系列函数最终都是通过它实现的(np.c_ 和 np.r_ 最终也通过它实现,实际上它是 C 语言的接口函数)。
0 1 2 3 4 5 6 | A = np.array([1, 2, 3])
B = np.array([4, 5, 6])
AB = np.concatenate((A, B))
print(AB)
>>>
[1 2 3 4 5 6]
|
拼接二维数组可以指定要拼接的轴,默认 axis = 0。
0 1 2 3 4 5 6 7 8 9 10 | A = np.array([[1, 2, 3]])
B = np.array([[4, 5, 6]])
C = np.concatenate((A, B), axis=0) # 增加行数
print(C)
D = np.concatenate((A, B), axis=1) # 增加列数
print(D)
>>>
[[1 2 3]
[4 5 6]]
[[1 2 3 4 5 6]]
|
与聚合操作比较,可以发现聚合操作默认会减少轴数,而拼接操作不会改变轴数。concatenate 要求所有数组除了拼接的轴上的 shape 值无需相同,其他的轴上的 shape 值必须相同,否则无法拼接。
垂直堆叠¶
vstack(tuple) 接受一个由数组组成的元组,每个数组在列上的元素个数必须相同:
0 1 2 3 4 5 6 7 8 | A = np.array([1, 2, 3])
B = np.array([[4, 5, 6], [7, 8, 9]])
print(np.vstack((A, B, A)))
>>>
[[1 2 3]
[4 5 6]
[7 8 9]
[1 2 3]]
|
vstack 依次处理各个数组,按第一个轴依次取数据,生成新数组。看起来像是在垂直方向上堆叠数据。等价于如下操作:
0 | concatenate( map(atleast_2d,tup), axis=0)
|
显然要进行垂直堆叠操作,数组至少是 2D 的,转换后在行上堆叠:vstack 在 1D 上堆叠会返回 2D 数组。
水平堆叠¶
hstack(tuple) 与 vstack(tuple) 类似,按第二个轴依次取数据,数组行数必须相同,看起来像是在水平方向堆叠数据。
0 1 2 3 4 5 | A = np.array([0, 1, 2])
B = np.array([30,40])
print(np.hstack((A, B, A)))
>>>
[0, 1, 2, 30, 40, 0, 1, 2]
|
hstack 等价于如下操作:
0 | concatenate( map(atleast_1d,tup), axis=1)
|
水平堆叠只需要保证数组有 1D 即可,所以结果不会增加向量的轴数。
数组分割¶
与数组拼接对应的是分割操作。垂直分割和水平分割均作用在 0 轴上,也即 axis = 0。
分割不会复制原数组元素。
垂直分割¶
vsplit(ary, indices_or_sections)
vsplit() 在垂直方向上对 ary 进行分割,indices_or_sections 有两种方式指定:
- 整数 n ,该整数在垂直方向必须可以均分各行,也即 shape[0] % n == 0。
- [indeices],逗号分割的索引值,也即行的索引值,n 个索引分割出 n + 1 个新数组。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | A = np.arange(6).reshape(6, 1)
print(A)
subs = np.vsplit(A, 2) # 垂直 2 等分
for i in subs:
print(i)
>>>
[[0]
[1]
[2]
[3]
[4]
[5]]
[[0]
[1]
[2]]
[[3]
[4]
[5]]
# 使用索引分割,各个数组对应索引范围 [0:2] [2:4] [4:]
subs = np.vsplit(A, [2,4])
for i in subs:
print(i)
>>>
[[0]
[1]]
[[2]
[3]]
[[4]
[5]]
|
水平分割¶
hsplit(ary, indices_or_sections)
hsplit() 在水平方向上对 ary 进行分割,indices_or_sections 有两种方式指定:
- 整数 n ,该整数在水平方向必须可以均分各列,也即 shape[0] % n == 0。
- [indeices],逗号分割的索引值,也即列的索引值,n 个索引分割出 n + 1 个新数组。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | A = np.arange(10)
print(A)
>>>
[0 1 2 3 4 5 6 7 8 9]
subs = np.hsplit(A, 2) # 2 等分
for i in subs:
print(i)
>>>
[0 1 2 3 4]
[5 6 7 8 9]
# 使用索引分割,各个数组对应索引范围 [0:4] [4:6] [6:]
subs = np.hsplit(A, [4,6])
for i in subs:
print(i)
[0 1 2 3]
[4 5]
[6 7 8 9]
|
任意轴分割¶
split(ary, indices_or_sections, axis=0)
split() 可以指定用于分割的轴,其余参数与 vsplit() 和 hsplit() 一致。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | A = np.arange(16).reshape(4, 4)
print(A)
>>>
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]
[12 13 14 15]]
subs = np.split(A, 2, axis=0) # 行 2 等分
for i in subs:
print(i)
>>>
[[0 1 2 3]
[4 5 6 7]]
[[ 8 9 10 11]
[12 13 14 15]]
subs = np.split(A, 2, axis=1) # 列 2 等分
for i in subs:
print(i)
>>>
[[ 0 1]
[ 4 5]
[ 8 9]
[12 13]]
[[ 2 3]
[ 6 7]
[10 11]
[14 15]]
|
非均匀分割¶
split 函数只能进行均匀分割,例如上例中 A 有 4 行,那么分为 3 个数组就会报异常,此时可以使用 array_split,它不是均分,它尝试把多余部分依次塞入子数组中。
0 1 2 3 4 5 6 7 8 | subs = np.split(A, 3, array_split=1) # 非均匀分割
for i in subs:
print(i)
>>>
[[0 1 2 3]
[4 5 6 7]] # 第一个子数组行数为 2
[[ 8 9 10 11]]
[[12 13 14 15]]
|
数组运算¶
算术运算¶
算术运算符¶
数组和标量之间的运算类似 Python 中的算术运算,支持运算符 + - * / //(地板除),** (幂) %(取余)等。
数组中所有元素均和标量发生对应运算。数组和标量运算符合交换律。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | A = np.arange(1, 5).reshape(2,2)
print(A)
>>>
[[1 2]
[3 4]]
print(A + 1) # 加
>>>
[[2 3]
[4 5]]
print(A - 1) # 减
>>>
[[0 1]
[2 3]]
print(A * 2) # 乘
>>>
[[2 4]
[6 8]]
print(A / 2) # 除
>>>
[[ 0.5 1. ]
[ 1.5 2. ]]
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | print(A // 2) # 地板除
>>>
[[0 1]
[1 2]]
print(A ** 2) # 求平方
>>>
[[ 1 4]
[ 9 16]]
print(A % 2) # 取余
>>>
[[1 0]
[1 0]]
|
我们可以将以上运算符任意组合,注意运算符的优先级,必要时需要添加小括号改变运算顺序:
0 1 2 3 4 5 6 | print(A)
print(2 + (A ** 2 - 1) * 5)
[[1 2]
[3 4]]
[[ 2 17]
[42 77]]
|
算术运算符和函数¶
所有算术运算符在 NumPy 中都有内置函数的函数实现, 例如 + 运算符对应 np.add 函数,这和 Python 中的 operator 模块类似。
运算符 对应函数 描述 + np.add 加法运算 - np.subtract 减法运算 - np.negative 负数运算 * np.multiply 星乘,表示矩阵内各对应位置相乘,注意和外积内积区分 / np.divide 除法运算 // np.floor_divide 地板除法运算(floor division,即 5 // 2 = 2) ** np.power 指数运算(即 2 ** 3 = 8) % np.mod 模 / 余数(即 5 % 2 = 1)
其他数学函数¶
数值修约¶
数值修约,又称数字修约,是指在数值进行运算前, 按照一定的规则确定一致的位数,然后舍去某些数字后面多余的尾数的过程。比如 4 舍 5 入就属于数值修约中的一种。
函数名称 描述 np.around(A,n,out) 四舍五入到指定的小数位 n,默认 0 np.round(A,n,out) 等价于 np.around np.rint(A) 圆整每个元素到最接近的整数,保留dtype np.fix(A,out) 向原点 0 舍入到最接近的整数,out可选,拷贝返回值 np.floor(A) 上取整,取数轴上右侧最接近的整数 np.ceil(A) 下取整,取数轴上左侧最接近的整数 np.trunc(A,out) 截断到整数,直接删除小数部分,与 np.fix 效果等同
由于 python2.7 以后的 round 策略使用的是 decimal.ROUND_HALF_EVEN,也即整数部分为偶数则舍去,奇数则舍入,这有利于更好地保证数据的精确性。numpy 的四舍五入同样使用此策略。
0 1 2 3 4 5 6 7 8 9 10 | print(round(2.55, 1)) # 2.5
import decimal
from decimal import Decimal
context = decimal.getcontext()
context.rounding = decimal.ROUND_05UP
print(round(Decimal(2.55), 1)) # 2.6
>>>
2.5
2.6
|
以上是 python 自带的 round 函数示例,可以通过调整 decimal 四舍五入策略,并数值转化为 Decimal 对象来获取通常意义的四舍五入数值。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 四舍五入,round 等价于 around 函数
print('np.around([1.43,-1.55]):\t', np.around([1.43,-1.55]), 1)
print('np.round(1.43,-1.55):\t\t', np.round([1.43,-1.55], 1))
# 圆整每个元素到最接近的整数
print('np.rint([0.5,1.5)):\t\t', np.around([0.5,1.5]))
# 向原点 0 舍入到最接近的整数
print('np.fix([-0.9,1.9)):\t\t', np.fix([-0.9, 1.9]))
>>>
np.around([1.43,-1.55]): [ 1. -2.] 1
np.round(1.43,-1.55): [ 1.4 -1.6]
np.rint([0.5,1.5)): [ 0. 2.]
np.fix([-0.9,1.9)): [-0. 1.]
|
上下取整示例:
0 1 2 3 4 5 | print('np.ceil([-0.9,1.9)):\t\t', np.ceil([-0.1, 1.9]))
print('np.floor([-0.9,1.9)):\t\t', np.floor([-0.1, 1.9]))
>>>
np.ceil([-0.9,1.9)): [-0. 2.]
np.floor([-0.9,1.9)): [-1. 1.]
|
截断到整数,直接删除小数部分,与 np.fix 效果等同:
0 1 2 3 | print('np.trunc([-0.9,1.9)):\t\t', np.trunc([-0.1, 1.9]))
>>>
np.trunc([-0.9,1.9)): [-0. 1.]
|
三角函数¶
函数名称 描述 np.sin(A) 正弦函数 np.cos(A) 余弦函数 np.tan(A) 正切函数 np.arcsin(A) 反正弦函数 np.arccos(A) 反余弦函数 np.arctan(A) 反正切函数 np.hypot(A1,A2) 直角三角形求斜边 np.degrees(A) 弧度转换为度 np.rad2deg(A) 弧度转换为度 np.radians(A) 度转换为弧度 np.deg2rad(A) 度转换为弧度
示例中使用的均是数值,不要忘记,在 numpy 中这些函数自然是支持数组的。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | print('np.sin(np.pi):\t', np.sin(np.pi/2))
print('np.cos(np.pi/2):\t', np.cos(np.pi/2))
print('np.tan(np.pi/4):\t', np.tan(np.pi/4))
>>>
np.sin(np.pi): 1.0
np.cos(np.pi/2): 6.12323399574e-17
np.tan(np.pi/4): 1.0
print('np.arcsin(1):\t', np.sin(1))
print('np.arccos(-1):\t', np.cos(-1))
print('np.arctan(1):\t', np.tan(1))
>>>
np.arcsin(1): 0.841470984808
np.arccos(-1): 0.540302305868
np.arctan(1): 1.55740772465
# 直角三角形求斜边
print('np.hypot(3,4):\t', np.hypot(3,4))
>>>
np.hypot(3,4): 5.0
# 弧度转换为度,两函数等价
print('np.rad2deg(np.pi/2):\t', np.rad2deg(np.pi/2))
print('np.degrees(np.pi/2):\t', np.degrees(np.pi/2))
# 度转换为弧度,两函数等价
print('np.radians(180):\t', np.radians(180))
print('np.deg2rad(180):\t', np.deg2rad(180))
>>>
np.rad2deg(np.pi/2): 90.0
np.degrees(np.pi/2): 90.0
np.radians(180): 3.14159265359
np.deg2rad(180): 3.14159265359
|
双曲函数¶
函数名称 描述 np.sinh(A) 双曲正弦 np.cosh(A) 双曲余弦 np.tanh(A) 双曲正切 ny.arcsinh(A) 反双曲正弦 np.arccosh(A) 反双曲余弦 np.arctanh(A) 反双曲正切
其他数学函数¶
有些数学函数没有对应的运算符,例如:
数学函数 描述 np.abs(A) 绝对值,np.absolute() 的缩写 np.reciprocal(A) 求倒数,和 1/A 有区别,默认不做类型转换,也即 1/2 = 0 np.exp(A) 以 e 为底的指数运算 e**A np.exp2(A) 以 2 为底的指数运算 2**A np.power(2, A) 通用指数函数 np.log(A) 以 e 为底的对数运算 ln(A) np.log2(A) 以 2 为底的对数运算 log2(A) np.log10(A) 以 2 为底的对数运算 log10(A)
np.reciprocal(A) 和 1/A 并不等同,它默认的结果数组和原数组类型相同:
0 1 2 3 4 5 6 7 8 9 10 | print(1/A) # 浮点数组
print(np.reciprocal(A)) # 整数数组
print(np.reciprocal(A * 1.0)) # 对原数组浮点转换
>>>
[[ 1. 0.5 ]
[ 0.33333333 0.25 ]]
[[1 0]
[0 0]]
[[ 1. 0.5 ]
[ 0.33333333 0.25 ]]
|
如果对任意底数求对数,则需用到换底公式,例如以 3 为底的 4 的对数求法: np.log(4)/np.log(3)。
0 1 2 3 4 | print(np.log(A)/np.log(3))
>>>
[[ 0. 0.63092975]
[ 1. 1.26185951]]
|
NumPy 还提供了很多通用函数, 包括比特位运算、 比较运算符等等。
通用函数特性¶
通用函数有两种存在形式: 一元通用函数(unary ufunc) 对单个输入操作, 例如 np.abs(A)。 二元通用函数(binary ufunc) 对两个输入操作,例如 add(A, B)。
指定输出数组¶
在进行大量运算时,将结果输出到特定的用于存放运算结果的数组是非常有用的。 不同于创建临时数组, 可以用这个特性将计算结果直接写入到你期望的存储位置。 所有的通用函数都可以通过 out 参数来指定计算结果的存放位置:
0 1 2 3 4 5 6 7 | A = np.arange(3)
B = np.empty(3)
np.multiply(A, 2, out=B)
print('{}\n{}'.format(A, B))
>>>
[0 1 2]
[ 0. 2. 4.]
|
通过为 out 指定输出数组的切片可以将计算结果写入指定数组的特定位置:
0 1 2 3 4 5 | A = np.zeros(10)
np.add(2, np.arange(5), out=A[::2])
print(A)
>>>
[ 2. 0. 3. 0. 4. 0. 5. 0. 6. 0.]
|
聚合 Reduce¶
二元通用函数具有聚合功能,这些聚合可以直接在对象上计算。 如果我们希望用一个特定的运算 reduce 一个数组, 那么可以用任何通用函数的 reduce 方法。
例如对 add 通用函数调用 reduce 方法会返回数组中所有元素的和:
0 1 2 3 4 | A = np.arange(1, 5)
np.add.reduce(A)
>>>
10
|
如果需要存储每次计算的中间累积结果,可以使用 accumulate,以累乘为例:
0 1 2 3 4 5 6 | A = np.arange(1, 5)
B = np.multiply.accumulate(A)
print('{}\n{}'.format(A, B))
>>>
[1 2 3 4]
[ 1 2 6 24]
|
NumPy 也提供了专用的统计函数(np.sum、 np.prod、 np.cumsum、 np.cumprod )来实现这类聚合。
外积¶
任何通用函数都可以用 outer 方法获得两个不同输入数组所有元素对的函数运算结果。 这意味着你可以用一行代码实现一个乘法表:
0 1 2 3 4 5 6 7 | A = np.arange(1, 4)
B = np.multiply.outer(A, [2,3])
print(B)
>>>
[[2 3]
[4 6]
[6 9]]
|
一个列向量乘以一个行向量称作向量的外积(Outer product),外积是一种特殊的克罗内克积,结果是一个矩阵,任意矩阵之间均可进行外积运算。A * B 实现步骤如下:
- 依次使用 A[i,j…] 元素与 B 乘得到和B形状相同的矩阵 C,使用 C 替换 A 中的 [i,j…] 元素
- 生成的矩阵维数为 A.ndim + B.ndim
分析上面例子中的计算步骤:
- A 为 [1 2 3],B 为 [2 3],首先使用 A[0,0] 1 乘以 B,得到 C = [2 3]
- C 替换 A 中的 A[0,0],得到 [[2 3] 2 3]
- 依次重复以上步骤,直至所有 A 中元素被替换完毕
np.multiply.outer(A, 2) 等同于 A * 2,不会改变维度。
更规范的方法是使用 np.outer 求外积,np.outer 和 np.multiply.outer 有区别,它会把标量 b 转换为向量 [b],这一点说明 NumPy 实现上有些混乱,不如 octave 简明:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | A = np.arange(1, 4)
# 等价于print(np.outer(A, 2))
print(np.outer(A, [2]))
>>>
[[2]
[4]
[6]]
print(np.outer(A, [2,3]))
>>>
[[2 3]
[4 6]
[6 9]]
|
一个行向量乘以一个列向量称作向量的内积,又叫作点积,结果是一个标量,矩阵间点积需要满足 A 的列等于 B 的行,结果为矩阵。参考 点积 。
聚合统计¶
聚合在信息科学中是指对有关的数据进行内容挑选、分析、归类,最后分析得到人们想要的结果,主要是指任何能够从数组产生标量值的数据转换过程。
常用统计方法由下表列出,它们也被称为聚合。
方法名称 NaN安全版本 描述 np.sum np.nansum 计算元素的和 np.prod np.nanprod 计算元素的积 np.cumsum np.nancumsum 从 0 元素开始的累计和。 np.cumprod np.nancumprod 从 1 元素开始的累计乘。 np.mean np.nanmean 计算元素的平均值 np.average N/A 计算加权平均数 np.std np.nanstd 计算元素的标准差 np.var np.nanvar 计算元素的方差 np.min np.nanmin 求最小值 np.max np.nanmax 求最大值 np.argmin np.nanargmin 找出最小值的索引 np.argmax np.nanargmax 找出最大值的索引 np.median np.nanmedian 计算元素的中位数 np.percentile np.nanpercentile 计算基于元素排序的统计值,百分位数 np.any N/A 验证任何一个元素是否为真 np.all N/A 验证所有元素是否为真
这些方法通常支持 axis 参数指定需要聚合(统计)的轴,默认对整个数组进行聚合。对某个轴进行聚合操作后,这个轴就会被移除(collapsed)。
使用聚合函数时通常直接通过对象引用,可以让代码更简洁。某些函数,例如 average 和 NaN 安全版本不可使用对象引用,只能通过 np. 调用,它们在聚合时忽略 NaN 元素。
0 1 2 3 4 5 | a = np.arange(16).reshape(4,4)
sum = np.sum(a, axis=0)
print(sum.shape, sum)
>>>
(4,) [24 28 32 36]
|
可以看到当指定 axis = 0 时,会在 0 轴方向进行聚合,聚合后的结果数组中 0 轴就消失了。
0 1 2 3 4 5 | a = np.arange(16).reshape(4,4)
sum = np.sum(a, axis=1)
print(sum.shape, sum)
>>>
(4,) [ 6 22 38 54]
|
上图中尽管画成了列向量,实际上在 numpy 中就是向量,这只是为了方便理解聚合如何作用在 1 轴上。当指定 axis = 1 时,会在 1 轴方向进行聚合,聚合后的结果数组中 1 轴就消失了,成为了 1D 向量。
聚合函数均支持 keepdims 布尔开关选项,指明是否保留结果数组的维度不变:
0 1 2 3 4 5 6 7 8 9 10 11 | a = np.arange(16).reshape(4,4)
sum = np.sum(a, axis=1, keepdims=True)
print(sum.shape)
print(sum)
>>>
(4, 1)
[[ 6]
[22]
[38]
[54]]
|
求和与积¶
sum() 方法默认求所有元素和,可以指定求和的轴:
0 1 2 3 4 5 6 7 8 9 | A = np.arange(1,7).reshape(2,3)
print(A)
print(A.sum())
print(A.sum(axis=0))
>>>
[[1 2 3]
[4 5 6]]
21 # 1+2+3+...+6
[5 7 9] # [1+4 2+5 3+6]
|
prod() 方法求元素乘积,可以指定特定轴:
0 1 2 3 4 5 | print(A.prod())
print(A.prod(axis=0))
>>>
720 # 1*2*3*...*6
[ 4 10 18] # [1*4 2*5 3*6]
|
最大最小值¶
max() 和 min() 方法统计最大最小值:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | A = np.arange(1,7).reshape(2,3)
print(A)
print(A.max(), A.min()) # 对整个数组求最大最小值
>>>
[[1 2 3]
[4 5 6]]
6 1
print(A.max(axis=0)) # 对 0 轴统计最大值
>>>
[4 5 6]
print(A.max(axis=1)) # 对 1 轴统计最大值
>>>
[3 6]
|
最大最小值索引¶
argmax() 和 argmin() 求最大最小值对应的索引。
0 1 2 3 4 5 6 7 8 9 | A = np.arange(1,7).reshape(2,3)
print(A)
print(A.argmax(), A.argmin()) # 对整个数组求最大最小值的索引
print(A.argmax(axis=0), A.argmin(axis=0)) # 对特定轴求做大最小索引
>>>
[[1 2 3]
[4 5 6]]
5 0
[1 1 1] [0 0 0]
|
求均值¶
平均数:一组数据的总和除以这组数据个数所得到的商叫这组数据的平均数,也即均值。
mean() 用于求元素和的均值,等价于 sum()/size。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | A = np.arange(1,7).reshape(2,3)
print(A.mean()) # 所有元素均值
print(A.mean(axis = 0)) # 0 轴元素均值
>>>
3.5
[ 2.5 3.5 4.5]
print(A.mean() == A.sum()/A.size)
print(A.mean(axis=0) == A.sum(axis=0)/A.shape[0])
>>>
True
[ True True True]
|
中位数¶
中位数:将数据按照从小到大或从大到小的顺序排列,如果数据个数是奇数,则处于最中间位置的数就是这组数据的中位数;如果数据的个数是偶数,则中间两个数据的平均数是这组数据的中位数。
0 1 2 3 4 5 6 7 8 9 10 | A = np.arange(1, 10).reshape(3, 3)
print(A)
print(np.median(A))
print(np.median(A,axis=0))
>>>
[[1 2 3]
[4 5 6]
[7 8 9]]
5.0
[ 4. 5. 6.]
|
median() 不是对象方法,只能通过 np. 引用。
加权均值¶
np.average() 只能通过 np. 调用,不是对象的方法,如果不提供 weights 则等同于 np.mean()。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | print(A)
>>>
[[1 2 3]
[4 5 6]]
print(np.average(A))
print(np.average(A, axis=0))
>>>
3.5
[ 2.5 3.5 4.5]
# 3 = (1*1 + 4*2) / (1+2)
print(np.average(A, axis=0, weights=([1,2]))) # 加权平均
>>>
[ 3. 4. 5.]
|
方差和标准差¶
方差(Variance)在概率统计中,用于描述样本离散程度。 标准差(Standard Deviation) = sqrt(var)。
0 1 2 3 | def var(A):
return np.sum((A - A.mean()) ** 2) / A.size
def std(A):
return var(A) ** 0.5
|
方差和标准差的实现如上,方差公式如下,其中 \(\rho\) 为标准差, \(\rho^2\) 为方差,\(X\) 为样本值,\(N\) 为样本数,\(\mu\) 为样本均值。
\[ \rho^{2}=\frac{\sum(X - \mu)^2}{N}\]均值相同的两组数据,标准差/方差未必相同,越大说明数据离散程度越大。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | A = np.arange(0,2)
print(A)
>>>
[0 1]
print(var(A), A.var()) # 方差
print(A.var(axis=0)) # 特定轴方差
>>>
0.25 0.25
0.25
print(A.std(), std(A)) # 标准差
>>>
0.5 0.5
|
矩阵转换¶
numpy 库提供了 matrix 类,它对应 matrix 对象。matrix 类继承了 ndarray,因此它们和 ndarray 有相同的属性和方法。
np.mat 实现从 2 维的 ndarray 转换为 matrix。同时可以接受一个字符串参数,形如 ‘1 2 3; 4 5 6’
转矩阵¶
字符串参数转矩阵:
0 1 2 3 4 5 6 7 | M = np.mat('1 2 3; 4 5 6')
print(M)
print(type(M).__name__)
>>>
[[1 2 3]
[4 5 6]]
matrix
|
二维数组转矩阵:
0 1 2 3 4 5 6 7 8 | A = np.arange(1,5).reshape(2,2)
M = np.mat(A) # 等价于 np.asmatrix
print(M)
print(M.shape)
>>>
[[1 2]
[3 4]]
(2, 2)
|
np.mat 不接受更高维 ndarray 作为参数。
矩阵属性¶
矩阵对象具有一些特性:
- 只有两个维度,也即 ndim 永远为 2
- M.ravel 和 M.flatten 展平操作返回的还是二维数组,只是第一维为 shape 为 1,形如 [[1 2 3 4]]
- matrix 重载了 * (星乘) 运算符,实现矩阵的乘积,M * M 等同于 np.dot(ndarray)
- matrix 重载了 ** (乘幂) 的运算,M ** 2 等价于 M * M
- matrix 具有一些特殊使用,让矩阵计算更方便,例如 M.T(转置),M.I(逆矩阵),M.H(共轭矩阵)和 M.A(以 ndarray 对象返回)
矩阵乘积:
0 1 2 3 4 5 | # 等价于 print(M.dot(M))
print(M * M)
>>>
[[ 7 10]
[15 22]]
|
矩阵展平:
0 1 2 3 | print(M.ravel)
>>>
[[1 2 3 4]]
|
矩阵乘幂:
0 1 2 3 4 5 | # 等价于 M * M 也即 M.dot(M)
print(M ** 2)
>>>
[[ 7 10]
[15 22]]
|
矩阵内置属性:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # 矩阵转置
print(M.T)
>>>
[[1 3]
[2 4]]
# 逆矩阵,等价于 la.inv(M)
print(M.I)
>>>
[[-2. 1. ]
[ 1.5 -0.5]]
# 共轭矩阵
print(M.H)
>>>
[[1 3]
[2 4]]
# 以 ndarray 对象返回
print(M.A)
print(type(M.A).__name__)
>>>
[[1 2]
[3 4]]
ndarray
|
置换矩阵¶
我们使用使用切割和拼接的方式来调换数组的行或者列,但是对于矩阵来说,我们可以根据矩阵的性质,使用置换矩阵来快速交换和行或列。
置换矩阵(permutation matrix)在矩阵理论中定义为一个方形0/1矩阵,它在每行和每列中只有一个1,而在其他地方则为0。
我们可使用单位矩阵逆序取得一个常规的置换矩阵,它的斜对角线元素均为 1:
0 1 2 3 4 5 6 7 | A = np.eye(3, dtype='uint8')
P = A[:, ::-1] # 行逆序取得置换矩阵
print(P)
>>>
[[0 0 1]
[0 1 0]
[1 0 0]]
|
一个矩阵左点乘一个置换矩阵,交换的是该矩阵的行;一个矩阵右点乘一个置换矩阵,交换的是该矩阵的列。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | A = np.arange(9).reshape(3,3)
print(A)
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
# 交换行
print(P.dot(A))
>>>
[[6 7 8]
[3 4 5]
[0 1 2]]
# 交换列
print(A.dot(P))
>>>
[[2 1 0]
[5 4 3]
[8 7 6]]
|
置换矩阵扩展¶
置换矩阵的一般性推广,通过观察可以发现:
- 如果置换矩阵 P i 行元素全为0,AP 中的 i 行被清 0,PA 则 i 列被清 0
- 如果置换矩阵元素 P[i,j] = 1,P[i,^j] = 0, 如果被左乘则表示用 j 行填充到 i 行上。
- 如果被右乘则表示用 j 列填充到 i 列上。
- 交行置换矩阵的行或列,等同于交换矩阵的行或列。
这样就理解了为何单位矩阵乘以任何矩阵和任何矩阵乘以单位矩阵不会改变原矩阵了。
清0行或列¶
根据置换矩阵的性质,进行扩展,可以实现清0特定行或列
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | P = np.eye(3, dtype='uint8')
P[1,1] = 0 # 清 0 行或列 1
# 等价于 A[1,:] = 0,清 0 行 1
print(P.dot(A))
>>>
[[0 1 2]
[0 0 0]
[6 7 8]]
# 等价于 A[:,1] = 0,清 0 列 1
print(A.dot(P))
>>>
[[0 0 2]
[3 0 5]
[6 0 8]]
|
这种方法没有切片赋值方式简便,只是用来理解置换矩阵的本质。 我们使用切片方式封装为一个函数,用于清零特定的行或列:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | def zerorows(A, rows, val=0):
arr = np.array([rows]).ravel()
A[arr, :] = val
return A
def zerocols(A, cols, val=0):
arr = np.array([cols]).ravel()
A[:, arr] = val
return A
# 对行 0,2 清0
print(zerorows(A, [0,2]))
>>>
[[0 0 0]
[3 4 5]
[0 0 0]]
# 对列 0,2 清0
print(zerocols(A, [0,2]))
>>>
[[0 0 0]
[0 4 0]
[0 0 0]]
|
使用以上函数不仅仅可以清零任意行和列,还可以赋任何值。
交换行或列¶
交换行或列可以使用切片,参考 交换行和列。这里作为理解置换矩阵的方法。由于只要交换置换矩阵的行 a 和 行 b 就可以实现矩阵行列的交换。由于置换矩阵只有斜对角线上的元素为 1,交换等同于把行上的 1 移动位置。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | # 交换 a,b 行等同于 P[a,b] = 1 P[b,a] = 1
def swaprow(P, rowa, rowb):
P[rowa, rowa] = 0
P[rowb, rowb] = 0
P[rowa, rowb] = 1
P[rowb, rowa] = 1
return P
P = swaprow(P, 1, 2) # 交换行 1 和行 2
print(P)
print(P.dot(A))
>>>
[[1 0 0]
[0 0 1]
[0 1 0]]
[[0 1 2]
[6 7 8]
[3 4 5]]
|
我们可以扩展以上函数,以完成任意行列之间的交换:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | # swap=1 swap cols
def swaprowcols(A, vecm, vecn, swap=0):
P = np.eye(A.shape[0], dtype='uint8')
M = np.array([vecm]).ravel()
N = np.array([vecn]).ravel()
if M.shape != N.shape:
print("vecm and vecn must have same dims")
return
# swap permutation matrix
P[M, M] = 0
P[N, N] = 0
P[M, N] = 1
P[N, M] = 1
if swap == 0:
return P.dot(A)
return A.dot(P)
A = np.arange(16).reshape(4, 4)
print(swaprowcols(A, 0, 2)) # 交换行 0 和 行 2
>>>
[[ 8 9 10 11]
[ 4 5 6 7]
[ 0 1 2 3]
[12 13 14 15]]
# 交换列 0 和 列 2,列 1 和 列 3
print(swaprowcols(A, [0,1], [2,3], 1))
>>>
[[ 2 3 0 1]
[ 6 7 4 5]
[10 11 8 9]
[14 15 12 13]]
|
线性代数¶
线性代数是数学的一个分支,它的研究对象是向量,向量空间(或称线性空间),线性变换和有限维的线性方程组。
常用运算有矩阵乘法,分解,变换,行列式等,对任何一个数组库来说都是重要的部分。
以下函数直接使用 np 引用:
函数 描述 diag 数组和对角线向量互转 trace 计算对角线上元素的和 dot 行列式乘积
numpy.linalg 有一个关于矩阵分解和像转置和行列式等的一个标准集合。常用 numpy.linglg 函数如下表所示:
基本函数 描述 norm 向量或矩阵的范数 inv 方阵逆矩阵 pinv 方阵 Moore-Penrose pseudo-inverse 广义逆矩阵 solve 求解线性系统方程 Ax = b 的x,其中A是一个方阵 det 求行列式 slogdet 行列式的符号和自然对数 lstsq 计算Ax=b的最小二乘解 matrix_power 矩阵乘幂 matrix_rank 基于奇异值分解法(SVD)求矩阵的秩
特征值相关函数如下:
特征值与分解 描述 eig 向量或方阵的特征值和特征向量 eigh 自共轭矩阵的特征值和特征向量 eigvals Eigenvalues of a square matrix eigvalsh Eigenvalues of a Hermitian matrix qr 计算 QR 分解 svd 计算奇异值分解(SVD) cholesky Cholesky 矩阵分解
引用以上函数,需要导入 linalg:
0 | from numpy import linalg as la
|
矩阵对角线¶
np.diag 在数组和对角线向量互转,传入参数必须是向量或者矩阵。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | A = np.arange(9).reshape(3,3)
print(A)
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
print(np.diag(A)) # 返回对角线向量
>>>
[0 4 8]
# 如果参数为向量,则返回以该向量为对角线的方阵
print(np.diag([1,2,3]))
>>>
[[1 0 0]
[0 2 0]
[0 0 3]]
|
如果不是方阵,也会返回“对角线”向量:
0 1 2 3 4 5 6 7 | A = np.arange(8).reshape(2,4)
print(A)
print(np.diag(A))
>>>
[[0 1 2 3]
[4 5 6 7]]
[0 5]
|
对角线元素和¶
np.trace 返回对角线元素和,等价于 np.sum(np.diag(A)):
0 1 2 3 4 5 6 7 8 | A = np.arange(1,10,1).reshape(3,3)
print(A)
print(A.trace(), np.sum(np.diag(A)))
>>>
[[1 2 3]
[4 5 6]
[7 8 9]]
15 15 # 15 = 1+5+9
|
点积¶
注意点积(Dot product) 和 外积 的区别。
dot(a, b, out=None)
np.dot 实现向量点积或矩阵乘积,如果 b 为标量则等同为 a * b,返回标量值:
- 点积:用于向量相乘,表示为C = A.*B,A 与 B均为向量,C 为标量,也称标量积(scalar product)、内积、数量积等。两个向量a = [a1, a2,…, an]和b = [b1, b2,…, bn]的点积定义为:a.*b = a1b1 + a2b2 + … + anbn。
- 乘积: 用于矩阵相乘,表示为C=A*B,A的列数与B的行数必须相同,C 也是矩阵,C 的行数等于 A 的行数,C 的列数等于 B 的列数。Cij 为 A 的第 i 行与 B 的第 j 列的点积。
向量点积:
0 1 2 3 | print(np.dot(np.array([1,2]), np.array([3,4])))
>>>
11 # 1*3 + 2*4
|
标量乘标量,向量乘标量,以及矩阵乘标量,均等于各个元素与标量相乘:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | # 等同 2 * 2
print(np.dot(2, 2))
>>>
4
# 等同 [1 2] * 2
print(np.dot(np.array([1,2]), 2))
>>>
[2 4]
A = np.arange(4).reshape(2,2)
print(A)
>>>
[[0 1]
[2 3]]
print(A.dot(2))
>>>
[[0 2]
[4 6]]
|
矩阵乘积,注意 np.dot 和 * (星乘)的区别:
0 1 2 3 | print(A.dot([1,2]))
>>>
[2 8]
|
还有一个 np.inner 函数在向量乘的时候与 np.dot 行为一致,但是在矩阵乘时行为不一致,通常应该使用 np.dot。
矩阵乘向量¶
由于向量是 1 维的,所以它转置之后还是自身。通常我们使用矩阵和向量相乘,均是指列向量,而 np.dot 把一维向量自动作为行向量,并且结果还是行向量。
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.arange(4).reshape(2,2)
print(A)
>>>
[[0 1]
[2 3]]
# 矩阵点乘行向量
V = np.array([1,2]) # V 为 [1 2],shape 为 (2,)
print(A.dot(V))
>>>
[2 8]
|
np.dot 乘以列向量,实际上执行的是矩阵点乘,列向量是 shape 为 (n, 1) 的二维矩阵,结果还是shape 为 (n, 1) 的二维矩阵(列向量)。
0 1 2 3 4 5 6 | # 矩阵点乘列向量
V = B.reshape(2,1)
print(A.dot(V))
>>>
[[2]
[8]]
|
所以 np.dot 可以根据向量类型自动计算矩阵和向量的点积,并生成对应的向量。
叉乘¶
向量积,数学中又称叉积,物理中称矢积、叉乘,是一种在向量空间中向量的二元运算。与点积不同,它的运算结果是一个向量而不是一个标量。并且两个向量的叉积与这两个向量和垂直。
向量积 ≠ 向量的积(向量的积一般指点乘)。
0 1 2 3 | print(np.cross([1,0,0], [0,1,0]))
>>>
[0 0 1]
|
在物理学光学和计算机图形学中,叉积被用于求物体光照相关问题。
逆矩阵和多项式求解¶
\(np.dot(A,A^{-1})=I\) 单位矩阵,则称 \(A^{-1}\) 为 A的逆矩阵,A 必须为方阵。如果 A 没有逆矩阵,则称 A 为奇异矩阵 (Sigular matrix)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | from numpy import linalg as la
A = np.array([[1, 1], [2, 3]])
print(la.inv(A))
>>>
[[ 3. -1.]
[-2. 1.]]
# A 的逆矩阵点乘 A 等于单位矩阵
print(la.inv(A).dot(A))
>>>
[[ 1. 0.]
[ 0. 1.]]
|
逆矩阵类似一个矩阵的倒数,AB = C,已知 A 和 C 求 B,则左侧同时乘以 \(A^{-1}\),则 \(B = A^{-1}C\)。最简单的应用是用来求线性方程组的解:
0 1 2 3 4 5 6 7 8 9 | # 求如下多项的解
x + y = 10
2x + 3y = 25
A = np.array([[1, 1], [2, 3]])
C = np.array([10, 25])
print(np.dot(la.inv(A), C))
>>>
[ 5. 5.]
|
实际上 linglg 中提供了 solve 函数用于求解线性方程组的解,以上解法等价于:
0 1 2 3 | print(la.solve(A, C))
>>>
[ 5. 5.]
|
实际上很少使用逆矩阵求解线性返程组,因为它的计算量大约使用行变换方法的3倍,而且行变换方法更为精确。
伪逆 pinv¶
la.pinv (pseudo-inverse)和 la.inv 不同,pinv是求广义逆,也即伪逆。
对于方阵A,若有方阵B,使得:AB = BA = I,则称B为A的逆矩阵。
如果矩阵 A 不是一个方阵,或者 A 是一个非满秩的方阵时(奇异矩阵),矩阵 A 没有逆矩阵,但可以找到一个与 A 的转置矩阵同型的矩阵 B,使得: ABA = A 并且 BAB = B,此时称矩阵 B 为矩阵 A 的伪逆,即广义逆矩阵。因此伪逆阵与原阵相乘不一定是单位阵。
满足上面关系的 A, B 矩阵,有很多和逆矩阵相似的性质。
当 A 可逆时,B 就是 A 的逆矩阵,pinv 结果和 inv 的结果相同,否则 pinv 返回伪逆。和 inv 相比,pinv 会消耗大量的计算时间。
行列式¶
二阶的行列式计算方式如下,行列式 det 如果为0,说明矩阵是奇异矩阵,不可逆:
[[a b]
[c d]] = a*d - b*c
linalg 中的 det 函数用于计算行列式:
0 1 2 3 4 5 6 7 8 | from numpy import linalg as la
A = np.arange(4).reshape(2,2)
print(A)
print(la.det(A))
>>>
[[0 1]
[2 3]]
-2.0 # 0*3 - 1*2
|
slogdet 用于求行列式的符号和以自然数 e 为底的对数:
# 等价于 print(np.log(np.abs(la.det(A)))) print(la.slogdet(A))
>>> (-1.0, 0.69314718055994529)
QR 因式分解¶
QR 分解是将矩阵分解为一个正交矩阵与上三角矩阵的乘积。A = Q.dot(R),如果 A.shape(m,n),则 Q.shape(m,n),R.shape(n,n)。
Q 的各列由 A 的一组标准正交基构成,并且 Q 的转置点乘 Q 等于单位矩阵。R 则是上三角矩阵。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | from numpy import linalg as la
A = np.arange(6).reshape(2,3)
print(A)
>>>
[[0 1 2]
[3 4 5]]
Q,R = la.qr(A)
print(Q)
print(R)
>>>
[[ 0. -1.]
[-1. 0.]]
[[-3. -4. -5.]
[ 0. -1. -2.]]
# 使用 A = Q.dot(R) 验证
print(Q.dot(R))
>>>
[[ 0. 1. 2.]
[ 3. 4. 5.]]
|
验证 Q 的转置点乘 Q 等于单位矩阵:
0 1 2 3 4 | print(Q.T.dot(Q))
>>>
[[ 1. 0.]
[ 0. 1.]]
|
QR 求最小二乘解¶
QR 分解常用于求取 Ax = b 的最小二乘解(结果差值的平方和的开方根最小),求取公式为 x = inv(R) * (Q.T) * b:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # 求解 Ax = b 的最小二乘解 x = [[Xa],[Xb]]
A = np.array([[0, 1], [1, 1], [1, 1], [2, 1]])
b = np.array([[1], [0], [2], [1]])
Q, R = la.qr(A)
lst = la.inv(R).dot(Q.T.dot(b))
print(lst)
>>>
[[ 2.22044605e-16] # Xa = 0 Xb = 1 是最小二乘解
[ 1.00000000e+00]]
print(la.norm(A.dot(lst) - b)) # 最小二乘解的误差值
>>>
1.41421356237
|
以上过程等同于求解以下线性方程组的最小二乘解:
0 1 2 3 | 0Xa + 1Xb = 1
1Xa + 1Xb = 0
1Xa + 1Xb = 2
2Xa + 1Xb = 1
|
也可以理解为对 A 中的列向量如何进行 X1 和 X2 的权重组合来最接近向量 b。矩阵理论证明 b 距离 X1 和 X2 构成的空间的最短距离是向该空间的正交投影点,所以 X1 和 X2 组合成的向量如果构成 b 的正交投影向量,那么 X1 和 X2 就是最小二乘解。
最小二乘法拟合¶
lstsq (LeaST SQuare 的缩写)函数用最小二乘法拟合数据,得到一个形如 y = mx + c 的线性函数,也即目标是求出参数 m 和 c。
求得的结果 m 和 c 满足所有点距直线的距离的平方和的平方根最小。
0 1 2 3 4 5 6 | # 以 y = m*x + c 直线为例
x = np.array([0, 1, 2, 3, 4])
y = np.array([-1, 0.2, 0.9, 2.1, 3])
# 增加对应常数项 c 的列
A = np.vstack([x, np.ones(x.shape[0])]).T
m, c = np.linalg.lstsq(A, y)[0]
|
为了查看效果,我们把 x,y 构成的点,和返回的拟合直线画出来:
0 1 2 3 4 5 6 | plt.rcParams['font.sans-serif']=['SimHei']
plt.rcParams['axes.unicode_minus'] = False
plt.title('最小二乘解', fontsize=20)
plt.plot(x, y, 'o', label='原数据', markersize=8)
plt.plot(x, m*x + c, 'r', label='拟合直线')
plt.legend(fontsize=16)
plt.show()
|
在一些应用中,必须将数据点拟合为非直线形式,例如 y = ax^2 + bx + c:
0 1 2 3 4 5 6 7 8 9 10 11 | # 以 y = 2x^2 + 10x + 2 曲线为例
x = np.array([1,2,3,4,5,6,7,8])
#y = 2*x*x + 10*x + 2 # 如果使用此 y 值,则会精确拟合到指定的参数,可用于测试
y = np.array([13,28,49,75,102,134,170,215]) # 观测值
# 增加对应常数项 c 的列
A = np.vstack([x**2, x, np.ones(x.shape[0])]).T
a, b, c = np.linalg.lstsq(A, y)[0]
print(a, b, c)
>>>
2.14285714286 9.35714285714 1.5
|
画图方式相同,只要改变拟合曲线的 y 坐标生成方式为 a * x**2 + b * x + c:
0 1 2 3 4 | plt.title('最小二乘解', fontsize=20)
plt.plot(x, y, 'o', label='原数据', markersize=8)
plt.plot(x, a * x**2 + b * x + c, 'r', label='拟合曲线')
plt.legend(fontsize=16)
plt.show()
|
傅里叶逼近¶
傅里叶逼近是曲线逼近的一种,当使用傅里叶逼近的函数阶数越高,均方误差可以趋近于 0。
傅里叶级数形式:f(t) = A0/2 + sum(Am*cos(mt) + Bm*sin(mt)) m 取 1 到无穷,这里以 2 阶傅里叶级数拟合曲线:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # 2 阶傅里叶逼近
x = np.array(np.linspace(0, np.pi * 2, 40))
# 直接使用函数值并对其中的项微调,作为观察值进行测试
y = 3.0 - 2.0*np.sin(x) + 5.0*np.sin(2.0*x) - 5.0*np.cos(2.0*x)
y[0] = -1 # 微调第一项的值
# 生成 A 矩阵
A = np.vstack([np.ones(x.shape[0]), np.sin(x), np.sin(2.0*x), np.cos(2.0*x)]).T
a, b, c, d = np.linalg.lstsq(A, y)[0]
print(a, b, c, d)
>>>
3.02380952381 -2.0 5.0 -4.95238095238
plt.title('最小二乘解', fontsize=20)
plt.plot(x, y, 'o', label='原数据', markersize=8)
plt.plot(x, a + b*np.sin(x) + c*np.sin(2.0*x) + d*np.cos(2.0*x), 'r', label='拟合曲线')
plt.legend(fontsize=16)
plt.show()
|
范数¶
范数(Norm)是对向量或矩阵大小的度量。通常有 0,1,2 和无穷范数。la.norm 中的 ord 参数可以指定要求的范数的类型:
ord 矩阵的范数类型 向量的范数类型 None Frobenius norm 2-norm 向量长度 ‘fro’ Frobenius norm – ‘nuc’ nuclear norm – inf max(sum(abs(x), axis=1)) max(abs(x)) -inf min(sum(abs(x), axis=1)) min(abs(x)) 0 sum(x != 0) 1 max(sum(abs(x), axis=0)) as below -1 min(sum(abs(x), axis=0)) as below 2 2-norm (largest sing. value) as below -2 smallest singular value as below other – sum(abs(x)**ord)**(1./ord)
以上范数解释如下:
- 0 范数,向量中非零元素的个数,sum(x != 0)。
- 1 范数,各个元素的绝对值之和。
- 2 范数,Frobenius norm 称为弗罗贝尼乌斯,也即矩阵或者向量各个元素的平方和的开方,对于向量来说就是 2 范数,也即向量的长度(模)。
- 正无穷范数,就是取向量的各元素绝对值的最大值。
- 负无穷范数,就是取向量的各元素绝对值的最小值。
一个向量各类范数的示例:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | from numpy import linalg as la
A = np.array([0,3,4,-1])
print("Norm0:", la.norm(A, 0)) # 非0元素个数
print("Norm1:", la.norm(A, 1)) # 各元素绝对值之和
print("Norm2:", la.norm(A, 2)) # 欧氏距离
print("NormInf:", la.norm(A, np.inf)) # 绝对值的最大值
print("Norm-Inf:", la.norm(A, -np.inf))# 绝对值的最小值
>>>
Norm0: 3.0
Norm1: 8.0
Norm2: 5.09901951359
NormInf: 4.0
Norm-Inf: 0.0
|
向量距离和角度¶
由于 u.dot(v) = |u||u|cosa, 可以借助 2 范数求两个向量的夹角 a:
0 1 2 3 4 5 6 7 8 9 10 | def vector_angle(V0, V1):
V0 = np.array(V0)
V1 = np.array(V1)
cos_rad = V0.dot(V1) / (la.norm(V0, 2)*la.norm(V1, 2))
return np.rad2deg(np.arccos(cos_rad))
print(vector_angle([0,1], [1,0]))
>>>
90.0
|
cosa 在统计学中也被称为 u 和 v 的相关系数,它越大说明夹角越小,相关性越大。
范数也可以用来计算两个向量间的距离,即两个向量相减得到的新向量的 2 范数:
0 1 2 3 4 5 6 7 8 9 | def vector_dist(V0, V1):
V0 = np.array(V0)
V1 = np.array(V1)
return la.norm(V1 - V0)
print(vector_dist([0,1],[0,3]))
>>>
2.0
|
向量正交投影¶
图中的 Vproj 为 Vy 在 Vu 上的投影,点 Vproj 是 Vy 到 Vu 所在直线上的最短距离点,经过 Vy 和 Vproj 点的直线 L 垂直于 Vu。
如果把 L 看做一个向量,那么 Vy = Vproj + L。由于 L 垂直于 Vu,这是两个正交向量。所以 (Vy - Vproj)Vu = 0,其中 Vproj = cVu,c 为以常数,从而得出 c = (VyVu)/VuVu。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | from numpy import linalg as la
# 计算Vy 在 Vu 上的正交投影,返回的是投影向量
def vector_proj(Vu, Vy):
Vu = np.array(Vu)
Vy = np.array(Vy)
# Vproj = cVu,系数 c = (VyVu)/VuVu
c = Vy.dot(Vu)/Vu.dot(Vu)
return c * Vu
# 返回最短距离
def vector_proj_dist(Vu, Vy):
Vproj = vector_proj(Vu, Vy)
return la.norm(Vproj - Vy)
Vu = np.array([4,2])
Vy = np.array([7,6])
Vproj = vector_proj(Vu, Vy)
print(Vproj)
print(vector_proj_dist(Vu, Vy))
>>>
[ 8. 4.]
2.2360679775
|
向量投影可以推广到多维空间。对于 n 维向量来说这意味着 Vy 可以被投影到 n 维的各个正交基上,同时 Vy 等于各个正交基上投影的和。对于 Rn 空间的子空间,Vy 到子空间的投影点就是 Vy 到子空间的最短距离。
使用向量投影原理,可以找到垂直于某个向量的向量,例如 L 和 Vu,所以可以将一个空间中不相关的基转换为正交基,格拉姆施密特方法基于向量投影原理来进行矩阵的 QR 分解。
著名的最小二乘法也是基于向量投影原理实现的。
广播和迭代¶
数据间运算¶
相同形状数组¶
相同形状的数组间运算,作用在对应的元素之间,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | A = np.array([1, 1, 1])
B = np.array([2, 2, 2])
print(A + B)
print(A - B)
print(A * B)
print(A / B)
print(A % B)
print(A // B)
>>>
[3 3 3]
[-1 -1 -1]
[2 2 2]
[ 0.5 0.5 0.5]
[1 1 1]
[0 0 0]
|
广播可视化¶
参考 算术运算,数组和标量之间的运算,相当于把标量扩展为相同形状的数组,然后再进行运算。
0 1 2 3 4 5 | >>>
A = np.array([1, 1, 1])
print(A + 5) # 等价于 print(A + [5,5,5])
>>>
[6 6 6]
|
NumPy 这种对数据扩展以适应操作的行为被称为广播。实际上这种对数组的重复实际上并没有发生,但是这是一种很好用的理解广播的模型。
Broadcast Visualization 中提供了一张理解广播扩展的图片。
广播规则¶
NumPy 的广播遵循一组严格的规则,设定这组规则是为了决定两个数组间的操作,从规则1到规则3依次尝试:
- 规则1,如果两个数组的维度(ndim,轴数)不相同,那么小轴数的数组形状在最左边补 1 以使得维度(轴数)相同。
- 规则2,如果两个数组的形状在各轴的维数(元素数)不匹配, 那么数组的形状会沿着元素个数为 1 的轴扩展以匹配另外一个数组的形状。
- 规则3,扩展后两个数组的形状不匹配并且任何一个数组都不再有维度为 1 的轴, 报错处理,否则继续规则 2。
来看两个数组均需要广播的示例:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | A = np.arange(3).reshape((3, 1))
B = np.arange(3)
print(A)
print(B)
print(A.ndim, A.shape)
print(B.ndim, B.shape)
>>>
[[0]
[1]
[2]]
[0 1 2]
2 (3, 1)
1 (3,)
|
由于两个数组的轴数 2 != 1,按照规则1 对小数组 B 在最左边补 1,也即 B.shape 变为 (1,3)。
此时按照规则2,发现 A.shape = (3,1) 与 B.shape = (1,3) 在各轴的元素数目不同,需要更新这两个数组的各轴的维度来相互匹配,也即 A 在维度为 1 的轴上扩展为 A.shape = (3,3),B 在元素数为 1 的轴上扩展,B.shape 变为 (3,3)。
扩展以后 A 和 B 的 shape 是相等的,此时完成对应元素的相应操作。
0 1 2 3 4 5 | print(A + B)
>>>
[[0 1 2]
[1 2 3]
[2 3 4]]
|
这里再看一个两个数组不能进行广播扩展的例子。
0 1 2 3 4 5 6 7 8 9 10 11 | A = np.arange(4).reshape((2, 2))
B = np.arange(3)
print(A, A.shape)
print(B, B.shape)
print(A + B)
>>>
[[0 1]
[2 3]] (2, 2)
[0 1 2] (3,)
ValueError: operands could not be broadcast together with shapes (2,2) (3,)
|
B 的维度小于 A,把 B.shape 扩展为 (1, 3),然后按照 A 的第0轴扩展为 (2, 3),此时两个数组没有维数为 1 的轴,但是形状不同,无法进行扩展操作。
这些广播规则对于任意二元通用函数都是适用的。 例如 power(a, b) 函数。
广播实现¶
np.broadcast_to 函数提供广播实现,我们可以使用它观察数组广播的行为,只需提供 shape 参数即可:
0 1 2 3 4 5 6 | a = np.array([1, 2, 3])
print(np.broadcast_to(a, (3, 3)))
>>>
[[1 2 3]
[1 2 3]
[1 2 3]]
|
此外 np.broadcast_arrays 可以直接对多个数组进行广播扩展:
0 1 2 3 4 5 6 7 8 9 10 | x = np.array([[1,2,3]])
y = np.array([[4],[5]])
a, b = np.broadcast_arrays(x, y)
print(a)
print(b)
>>>
[[1 2 3]
[1 2 3]]
[[4 4 4]
[5 5 5]]
|
当参数不止 2 个时,可以使用列表扩展来返回多个数组,这样更方便:
0 | [np.array(a) for a in np.broadcast_arrays(x, y)]
|
广播应用¶
数据中心化¶
中心化也成为零均值化(Zero-centered 或 Mean-subtraction),所有数据减去均值,中心化后的数据均值为零。中心化在坐标轴上看起来是把数据移动到了原点附近。
我们的样本空间有 4 个样本,每个样本有 5 个特征值,我们对每个特征值进行中心化:
0 1 2 3 4 5 6 7 | A = np.random.randint(0,10,20).reshape(4,5)
print(A, A.mean(0))
>>>
[[3 5 2 4 7]
[6 8 8 1 6]
[7 7 8 1 5]
[9 8 9 4 3]] [ 6.25 7. 6.75 2.5 5.25]
|
对特征值(每列)中心化的过程就是减去 0 轴上的均值:
0 1 2 3 4 5 6 | A_centered = A - A.mean(0)
print(A_centered, A_centered.mean()) # 中心化后数据均值为 0
[[-3.25 -2. -4.75 1.5 1.75]
[-0.25 1. 1.25 -1.5 0.75]
[ 0.75 0. 1.25 -1.5 -0.25]
[ 2.75 1. 2.25 1.5 -2.25]] 0.0
|
这里以第一个特征为例(第一列),通过 matplob 画图,可以看出数据(Y方向)中心化后向原点聚集。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import matplotlib.pyplot as plt
import matplotlib as mpl
print(A[:, 0:1])
plt.figure(figsize=(6,4))
X = np.arange(A.shape[0])
plt.xlim(-4, 5)
plt.rcParams['font.sans-serif']=['SimHei']
plt.rcParams['axes.unicode_minus'] = False
plt.scatter(X, A[:, 0:1], label='原数据')
plt.scatter(X, A_centered[:, 0:1], label='中心化数据')
plt.scatter(0,0, s=500, c='r', marker='+')
plt.legend(loc='upper left', fontsize='large')
plt.show()
|
数据标准化¶
标准化(Standardization)处理又称为正态化,是指数值减去均值(中心化)后,再除以标准差。标准化处理后,得到均值为0,标准差为1的服从标准正态分布的数据。
在一些实际问题中,我们得到的样本数据都是多个维度的,即一个样本是用多个特征来表征的。很显然,这些特征的量纲和数值得量级都是不一样的,而通过标准化处理,可以使得不同的特征具有相同的尺度(Scale)。这样,在学习参数的时候,不同特征对参数的影响程度就一样了。简言之,当原始数据不同维度上的特征的尺度(单位)不一致时,需要标准化步骤对数据进行预处理。
0 1 2 3 4 5 6 7 8 9 10 11 | A_standard = A_centered / A.std(0)
print(A_standard)
print("A_standard mean:", A_standard.mean())
print("A_standard std:", A_standard.std())
>>>
[[-1.5011107 -1.63299316 -1.71317231 1. 1.18321596]
[-0.11547005 0.81649658 0.45083482 -1. 0.50709255]
[ 0.34641016 0. 0.45083482 -1. -0.16903085]
[ 1.27017059 0.81649658 0.81150267 1. -1.52127766]]
A_standard mean: -2.22044604925e-17
A_standard std: 1.0
|
通过 matplob 画图,可以看出数据(Y方向)标准化后向原点继续被压缩靠拢。
0 1 2 3 4 5 6 7 8 9 10 | plt.figure(figsize=(6,4))
X = np.arange(A.shape[0])
plt.xlim(-4, 5)
plt.rcParams['font.sans-serif']=['SimHei']
plt.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
plt.scatter(X, A[:, 0:1], label='原数据')
plt.scatter(X, A_centered[:, 0:1], label='中心化数据')
plt.scatter(X, A_standard[:, 0:1], label='标准化数据', c='m')
plt.scatter(0,0, s=500, c='r', marker='+')
plt.legend(loc='upper left', fontsize='large')
plt.show()
|
数据归一化¶
数据归一化(Normalization)是一种数据预处理方法,就是把待处理数据经某种算法限制在需要的一定范围内,为了后面数据处理的方便,其次是保正程序运行时收敛加快,一般指将数据限制在[0,1]之间。
比如说,对于奇异样本数据(奇异样本数据数据是相对于其他输入样本特别大或特别小的样本矢量),该数据引起的网络训练时间增加,并可能引起网络无法收敛,所以对于训练样本存在奇异样本数据的数据集在训练之前,最好先进形归一化,若不存在奇异样本数据,则不需要事先归一化。
数据归一化另一个作用是将一个有量纲的表达式转化为无量纲的表达式,成为一个纯量,避免具有不同物理意义和量纲的输入变量不能平等使用。而且在统计学中,数据归一化的具体作用是归纳统一样本的统计分布性。归一化在 0-1 之间是统计的概率分布,归一化在[-1, 1]之间是统计的坐标分布。
数据归一化的方法有:
- 若是区间上的值,则可以用区间上的相对位置来归一化,即选中一个相位参考点,用相对位置和整个区间的比值或是整个区间的给定值作比值,得到一个归一化的数据,比如概率值范围[0,1]。
- 若是物理量,则一般可以统一度量衡之后归一,实在没有统一的方法,则给出一个自定义的概念来描述亦可。
- 若是数值,则可以用很多常见的数学函数进行归一化,使它们之间的可比性更显然,比如对数归一,指数归一,三角或反三角函数归一等,归一的目的可能是使得没有可比性的数据变得具有可比性,同时还会保持相比较的两个数据之间的相对关系,如大小关系,或是为了作图,原来很难在一张图上作出来,归一化后就可以很方便的给出图上的相对位置等,通用的有线性函数转换(最大最小值转换法)、对数函数转换和反余切函数转换等。
- 线性函数转换(最大最小值转换法)
0 | y = (x-min)/(max-min)
|
x、y 分别表示输入、输出值,max、min表示样本中的最大、最小值。
- 对数函数转换
0 | y = log(x) or log2(x) or log10(x)
|
x、y 分别表示输入、输出值,y 为 x 的以 e,2 或 10 为底的对数函数转换值。
- 反余切函数转换
0 | y = atan(x)*2/pi
|
此外,从集合的角度来看,有些数据或者对象不具备可比性,但是可以通过做维度的维一,即抽象化归一,把不重要的,不具可比性的集合中的元素的属性去掉,保留人们关心的那些属性,这样,本来不具有可比性的对象或是事物,就可以实现归一,即归为一类,然后就可以比较了。并且,人们往往喜欢用相对量来比较,比如人和牛,身高体重都没有可比性,但“身高/体重”的值,就可能有了可比性,这些,从数学角度来看,可以认为是把有纲量变成了无纲量了。
这里使用线性函数进行归一化:
0 1 2 3 4 5 6 | A_normal = (A - A.min(0)) / (A.max(0) - A.min(0))
print(A_normal)
[[ 0. 0. 0. 1. 1. ]
[ 0.5 1. 0.85714286 0. 0.75 ]
[ 0.66666667 0.66666667 0.85714286 0. 0.5 ]
[ 1. 1. 1. 1. 0. ]]
|
显然归一化后的数据均落在 [0-1] 之间,使用 matplotlib 作图:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | plt.figure(figsize=(6,4))
X = np.arange(A.shape[0])
plt.xlim(-4, 5)
plt.rcParams['font.sans-serif']=['SimHei']
plt.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
plt.scatter(X, A[:, 0:1], label='原数据')
plt.scatter(X, A_centered[:, 0:1], label='中心化数据')
plt.scatter(X, A_standard[:, 0:1], label='标准化数据', c='m')
plt.scatter(X, A_normal[:, 0:1], label='归一化数据', c='r')
plt.scatter(0,0, s=500, c='r', marker='+')
plt.plot([-4,5], [0,0], c='gray', linewidth=1)
plt.plot([-4,5], [1,1], c='gray', linewidth=1)
plt.legend(loc='upper left', fontsize='large')
plt.show()
|
二维函数作图¶
广播另外一个非常有用的地方在于, 它能基于二维函数显示图像。
我们希望定义一个函数 z = f(x, y), 可以用广播沿着数值区间计算该函数:
0 1 2 3 4 5 6 7 8 9 10 11 | X = np.linspace(0, np.pi * 4, 250)
Y = np.linspace(0, np.pi * 4, 250)[:, np.newaxis]
Z = np.sin(X) + np.cos(Y)
fig = plt.figure()
ax = plt.axes(projection='3d')
ax.set_title('sin(X) + cos(Y)')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap='viridis')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
ax.view_init(30, -60)
|
数组迭代¶
如果我们要一次取元素,并对元素进行特殊的处理,就会用到数组迭代。
首先我们看下默认 Python 的 for 循环,如何返回数组元素:
0 1 2 3 4 5 6 7 8 9 10 | A = np.arange(4).reshape((2, 2))
print(A)
for i in A:
print('->', i)
>>>
[[0 1]
[2 3]]
-> [0 1]
-> [2 3]
|
可以看到默认的 for 循环,始终从第一维取元素,也即逐行取。
我们可以使用 ndarry 的 data 属性来遍历一维数组:
0 1 2 3 4 5 | A = np.arange(4)
for i in A.data: # A的视图,仅支持1维
print(i, end=' ')
>>>
0 1 2 3
|
flat¶
参考 数组展平,np.flatten 将数组转换为一维数组,而 np.flat 将数组转换为 1-D的迭代器。可以用for访问迭代器中每一个元素。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | A = np.arange(4).reshape((2, 2))
print(A)
>>>
[[0 1]
[2 3]]
A_flat = A.flat
print(type(A_flat).__name__)
for i in A_flat:
print('->', i)
flatiter
-> 0 # 逐个返回元素
-> 1
-> 2
-> 3
|
可以看出 flat 迭代器按照索引大小顺序来生成返回的元素。
广播迭代¶
np.nditer 可以同时对多个数组进行迭代,如果数组形状不匹配,则使用广播规则进行扩展。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | A = np.arange(4).reshape(2, 2)
B = np.array([1, 2])
>>>
[[0 1]
[2 3]]
[1 2]
# 数组 B 广播扩展到 A 的大小
print(A)
print(B)
for x,y in np.nditer([A, B]):
print('({},{})'.format(x,y))
>>>
(0,1)
(1,2)
(2,1)
(3,2)
|
可以通过 op_flags 传递一些标志,例如 no_broadcast 禁止广播,此时 A 和 B的形状必须相同,否则报错。
0 1 | for x,y in np.nditer([A, B], op_flags = ['readonly', 'no_broadcast']):
print('({},{})'.format(x,y))
|
迭代对象创建数组¶
np.fromiter(iterabe, dtype, count=-1)
np.fromiter 从可迭代对象中建立1维数组,可以指定个数。
0 1 2 3 4 5 6 | iterator = iter(range(10))
print(np.fromiter(iterator, dtype=int, count=2))
print(np.fromiter(iterator, dtype=int, count=3))
>>>
[0 1]
[2 3 4]
|
布尔数组和掩码¶
如果我们有大量数据需要进行统计,比如大于某值的数据有多少个如何处理呢?
比较操作¶
和算数运算类似,Numpy 同时支持比较运算符和内置函数:
比较运算符 对应通用函数 == np.equal != np.not_equal < np.less <= np.less_equal > np.greater >= np.greater_equal
0 1 2 3 4 5 6 7 8 9 10 11 | A = np.arange(4).reshape(2, 2)
print(A)
print(A > 1) # 符合交换律
print(1 < A)
>>>
[[0 1]
[2 3]]
[[False False]
[ True True]]
[[False False]
[ True True]]
|
对应的函数操作如下:
0 | print(np.greater(A, 1))
|
可以看出比较运算返回的结果是一个布尔数组:所有元素都是布尔值 True 或 False,每个元素只有 1 个 bit 的存储空间。
布尔数组统计¶
布尔数组的统计函数如下表所示,它们均支持指定轴 axis 参数。此外必须通过 np. 引用。
统计函数 描述 np.count_nonzero 统计布尔数组中 True 记录的个数 np.sum 等价于 np.count_nonzero np.any 有没有值满足条件,只要有一个满足就返回 True np.all 所有值都满足条件时返回 True
0 1 2 3 4 5 6 7 8 9 | A = np.arange(4).reshape(2, 2)
print(A)
print(np.count_nonzero(A > 1), np.sum(A > 1))
print(np.any(A > 1), np.all(A > 1))
>>>
[[0 1]
[2 3]]
2 2
True False
|
逻辑运算¶
NumPy 借用了 Python 的位运算符 &、 |、 ~ 和 ^ 来实现逻辑运算,对应 Python 中的 and,or,not,^ 是异或,没有对应的符号。
布尔数组可以被当作是由比特字符组成的, 其中 1 = True、 0 = False。 这样在数组上使用上面介绍的位运算符操作就易于理解了。
运算符 对应通用函数 & np.bitwise_and | np.bitwise_or ^ np.bitwise_xor ~ np.bitwise_not
有了逻辑运算符,我们就可以进行指定多个过滤条件:
0 1 2 3 4 5 6 7 | A = np.arange(4).reshape(2, 2)
print(A)
print(np.sum((A >= 1) & (A < 4))) # 统计 [1,4) 之间值的个数
>>>
[[0 1]
[2 3]]
3
|
需要注意的是布尔数组的每个元素只有 1 个 bit 的存储空间,所以 A & (A > 0) 返回的是 A 中每个元素的最后一位组成的数组:
0 1 2 3 4 | print(A & (A > 0))
>>>
[[0 1]
[0 1]]
|
布尔数组作为掩码¶
布尔数组不仅可以用于统计个数,还可以用于掩码来提取数据值。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | A = np.arange(4).reshape(2, 2)
print(A)
# 等价于 A_mask = (np.bitwise_and(A >= 1, A < 4))
A_mask = (A >= 1) & (A < 4)
print(A_mask)
print(A[A_mask]) # 布尔数组作为索引,提取 True 对应的值
>>>
[[0 1]
[2 3]]
[[False True]
[ True True]]
[1 2 3]
|
返回的是一个一维数组,所有的这些值是掩码数组对应位置为 True 的值。
如果我们不需要查看 A_mask,可以直接把比较表达式作为索引,例如:
0 | print(A[(A >= 1) & (A < 4)])
|
保留形状筛选¶
以上示例可以看到,布尔数组直接作为索引,返回的是一个一维数组。使用布尔数组也可以在保留原数组形状的情况下提取特定数据。
首先使用布尔数组乘以 1 转换为整型的 0/1 筛选数组,然后使用乘法进行筛选:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.array([[0, 1], [2, 3]])
print(A)
>>>
[[0 1]
[2 3]]
# 提取所有数据为 1 的元素,其余元素置为 0
print(A * (A==1)*1)
>>>
[[0 1]
[0 0]]
|
筛选数组中每个 1 对应的元素被保留,每个 0 对应的数组被过滤掉,只留下 0。当然也可以使用逻辑运算来组合生成布尔数组:
0 1 2 3 4 5 | # 返回 (0, 3) 之间的所有元素
B = ((A > 0) & (A < 3)) * 1
print(A * B)
[[0 1]
[2 0]]
|
保留形状筛选同样可以使用 np.where 实现。
where条件筛选¶
当元素满足条件(condition)时,输出 x 中对应元素,不满足输出 y 中对应元素。x, y 和 condition 自动进行广播扩展,以进行筛选。
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.array([0,0,1,2]).reshape(2,2)
print(A)
>>>
[[0 0]
[1 2]]
# 0 元素(False)被替换为 -1, (True)被替换为 1
print(np.where(A, 1, -1))
>>>
[[-1 -1]
[ 1 1]]
|
此时 1 和 -1 会自动根据 A 的形状进行广播扩展。下面的这个示例更清晰的看到了这一点:
0 1 2 3 4 5 6 7 8 9 | A = np.array([0,1])
print(np.where(True, A, -1))
>>>
[0 1]
print(np.where(False, A, -1))
>>>
[-1 -1]
|
只有条件 (condition)参数,没有x和y,等价于numpy.nonzero。这里的坐标以元组的形式给出,通常原数组有多少维,输出的元组中就包含几个数组,分别对应符合条件元素的各维坐标。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | A = np.array([0,1,2,3]).reshape(2,2)
print(A)
>>>
[[0 1]
[2 3]]
B = np.where(A)
print(type(B).__name__) # 元组
print(len(B)) # 2个数组,对应各个维度的索引
print(B)
>>>
tuple
2
(array([0, 1, 1], dtype=int64), array([1, 0, 1], dtype=int64))
|
A[0,1],A[1,0] 和 A[1,1] 对应元素均大于 0。A[B] 等价于 A[np.where(A)]。
花式索引和组合索引¶
我们已经了解了如何利用简单的索引值(A[0]) 切片(A[:5]) 和布尔掩码(A[A > 0]) 获得数组元素。
花式索引(fancy indexing)提供更复杂的索引方式:索引数组。
花式索引¶
花式索引指的是用整数列表(array-likes)或者整数数组进行索引。与切片索引不同,花式索引会对原数组数据进行复制以生成新数组。
索引列表¶
传递由整数索引组成的索引列表,用于提取索引对应的元素:
0 1 2 3 4 5 6 7 8 9 10 | A = np.arange(4)
print(A)
>>>
[0 1 2 3]
# 索引列表提取元素
print(A[[0, 3]])
>>>
[0 3]
|
更高维度的数组需要传递每个维度上的索引列表:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.arange(4).reshape(2, 2)
print(A)
>>>
[[0 1]
[2 3]]
row = [0, 1]
column = [0, 1]
print(A[row, column])
>>>
[0 3] # 对应 [A[0,0] A[1,1]]
|
每个维度上的索引列表长度必须一致,以进行行列的索引配对,使用列表作为索引的结果均是一维数组。
索引数组¶
一个更简单的方式是使用索引数组。生成的数组形状与索引数组相同,与原数组无关。
0 1 2 3 4 5 6 7 8 9 | A = np.arange(9)
print(A)
index_array = np.array([[3, 7], [4, 5]])
print(A[index_array])
>>>
[0 1 2 3 4 5 6 7 8]
[[3 7]
[4 5]]
|
再看一个更复杂的例子,可以更明确这里的 index_array 指定的是对第一维的索引值:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | A = np.arange(4).reshape(2, 2)
print(A)
index_array = np.array([[0,1],[1,0]])
print(index_array)
>>>
[[0 1] # 原数组
[2 3]]
[[0 1] # 索引数组
[1 0]]
print(A[index_array])
>>>
[[[0 1]
[2 3]]
[[2 3]
[0 1]]]
|
依次根据索引数组中的值 index = index_array[i][j] 取 A[index] 放在索引数组的 [i][j] 位置,最终生成新的数组。
我们已经看到索引数组对多维度数组同样适用,如果只提供一个索引数组则对应第一维的索引,如果为每一个轴指定一个索引向量(1维数组),就可以索引到特定元素。
0 1 2 3 4 5 6 7 8 9 10 | A = np.arange(9).reshape((3, 3))
print(A)
row = np.array([0, 1, 2]) # 0 轴索引向量
col = np.array([2, 1, 2]) # 1 轴索引向量
print(A[row, col])
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
[2 4 8] # 2 对应 A[0,2],也即 A[row[0], col[0]]
|
在花式索引中,索引值的配对遵循广播规则。 因此当我们将一个列向量和一个行向量组合在一个索引中时, 会得到一个二维的结果:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.arange(9).reshape((3, 3))
print(A)
row = np.array([0, 1, 2])
col = np.array([2, 1, 2])
print(A[row[:, np.newaxis], col])
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
[[2 1 2]
[5 4 5]
[8 7 8]]
|
在 A[row[:, np.newaxis], col] 进行索引时,会根据广播规则自动生成对应 0 轴的索引数组和 1 轴的索引数组:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | # 0 轴索引
[[0 0 0]
[1 1 1]
[2 2 2]]
# 1 轴索引
[[2 1 2]
[2 1 2]
[2 1 2]]
# 生成的数组对应的索引
[[[0,2] [0,1] [0,2]]
[[1,2] [1,1] [1,2]]
[[2,2] [2,1] [2,2]]]
|
用于索引的数组形状如下所示,花式索引返回的值反映的是广播后的索引数组的形状, 而不是被索引的数组的形状。
0 1 2 3 4 5 | print(row[:, np.newaxis] * col)
>>>
[[0 0 0]
[2 1 2]
[4 2 4]]
|
组合索引¶
花式索引可以和其他索引方案结合起来形成更强大的索引操作。
和简单的索引组合使用:
0 1 2 3 4 5 6 7 8 | A = np.arange(9).reshape((3, 3))
print(A)
print(A[2, [0,1,2]]) # 进行广播,也即[[2,2,2], [0,1,2]]
>>>
[[0 1 2]
[3 4 5]
[6 7 8]]
[6 7 8]
|
和切片组合使用:
0 1 2 3 4 5 | # 切片选择多行 1: 等价于 [[1],[2]]
print(A[1:, [0,1,2]])
>>>
[[3 4 5]
[6 7 8]]
|
和掩码组合使用:
0 1 2 3 4 5 6 7 | mask = np.array([1, 0, 0])
row = np.array([0, 1, 2])
print(A[row[:, np.newaxis], mask])
>>>
[[1 0 0]
[4 3 3]
[7 6 6]]
|
索引选项的组合可以实现非常灵活的获取和修改数组元素的操作。
各类索引总结¶
- 简单索引:返回的数组元素是原数组的拷贝
- 切片:返回原数组视图
- 花式索引:返回数组拷贝,提供几个索引数组,它所在的位置决定了它的值要索引的维。几个索引数组通过广播规则进行扩展,扩展后的形状必须相同。
花式索引中的索引数组个数小于被索引数组维数时,则取到的元素为一个数组,此时生成的新数组维数将大于索引数组的维数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | A = np.arange(8).reshape(2, 2, 2)
print(A)
index_array = np.array([[0,1],[1,0]])
print(index_array)
C = A[index_array, index_array]
print(C)
>>>
[[[0 1] # 原数组
[2 3]]
[[4 5]
[6 7]]]
[[0 1] # 索引数组,shape(2,2)
[1 0]]
[[[0 1] # 结果,shape(2,2,2)
[6 7]]
[[6 7]
[0 1]]]
|
转置和滚动¶
数组转置¶
np.transpose 实现数组的形状逆序,例如 shape(1,2,3,4) 的数组经 transpose 处理后返回 shape(4,3,2,1) 的数组视图。所以对于 1 维数组来说,转换后还是其自身。
0 1 2 3 4 5 6 7 8 | A = np.arange(6).reshape((2, 3, 1))
print(A.transpose().shape)
print(A.T) # A.transpose() 的快捷方式
>>>
(1, 3, 2)
[[[0 3]
[1 4]
[2 5]]]
|
transpose() 可以直接使用对象引用它。作用在二维数组(矩阵)上,相当于对矩阵进行转置:
0 1 2 3 4 5 6 7 8 | A = np.arange(4).reshape((2, 2))
print(A)
print(A.transpose()) # 转置矩阵
>>>
[[0 1]
[2 3]]
[[0 2]
[1 3]]
|
对于更高维数组的转换,我们继续第一个例子,来了解元素是怎么被转换的:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | A = np.arange(6).reshape((2, 3, 1))
print(A) # A.shape(2,3,1)
print(A.T) # A.T.shape(1,3,2)
>>>
[[[0]
[1]
[2]]
[[3]
[4]
[5]]]
[[[0 3]
[1 4]
[2 5]]]
|
转换的第一步是以元组方式从大到小列出 A 中元素的所有索引,接着按照转换规则调换索引。
根据 A.T.shape(1,3,2) 我们列出转换后数组的元素索引:
| 0 | 1 | 2 | 3 | 4 | 5 | 索引对应的值
---------------------------------------------------
| 000 | 010 | 020 | 100 | 110 | 120 | A元素的索引
---------------------------------------------------
| 000 | 010 | 020 | 001 | 011 | 021 | A调换0/2轴的索引
---------------------------------------------------
| 000 | 001 | 010 | 011 | 020 | 021 | A.T元素的索引
可以发现 A 数组调换0/2轴的索引和 A.T 元素的索引的组合是一致的,由排列组合可知它们一一对应。
根据 A调换0/2轴的索引对应的元素,填入到 A.T 相同索引处。例如 001 对应 3,放到 A.T[0,0,1]处。
指定转换索引¶
np.transpose 默认对所有轴按中心对称方式交换。我们也可以通过 axes 参数指定转换索引。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | A = np.arange(6).reshape((2, 3, 1))
AT = A.transpose((2,0,1)) # 索引交换规则:2->0, 0->1, 1->2
print(A, AT.shape)
print(AT)
>>>
[[[0]
[1]
[2]]
[[3]
[4]
[5]]] (1, 2, 3)
[[[0 1 2]
[3 4 5]]]
|
按照以上转换规则,我们可以列出如下索引,按照交换后的索引,找到对应元素。
| 0 | 1 | 2 | 3 | 4 | 5 | 索引对应的值
---------------------------------------------------
| 000 | 010 | 020 | 100 | 110 | 120 | A元素的索引
---------------------------------------------------
| 000 | 001 | 002 | 010 | 011 | 012 | 2->0, 0->1, 1->2
---------------------------------------------------
| 000 | 001 | 002 | 010 | 011 | 012 | A.transpose元素的索引
按轴滚动¶
np.roll(a, shift, axis=None) 将数组 a 沿着 axis 的方向,滚动 shift 长度可以向后滚动特定的轴到一个指定位置。如果不指定 axis 则默认先进行展平操作,然后按照向量滚动,最后按原 shape 返回。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # a 为向量
a = np.arange(10)
print(a)
>>>
[0 1 2 3 4 5 6 7 8 9]
# shift 为正数表示向右滚动
b = np.roll(a, 2)
print(b)
>>>
[8 9 0 1 2 3 4 5 6 7]
# shift 为负数表示向左滚动
c = np.roll(a, -2)
print(c)
>>>
[2 3 4 5 6 7 8 9 0 1]
|
a 为向量,只有一个轴,滚动操作默认 axis = 0,可以不指定。shift 正负决定了滚动方向。多维矩阵:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | a = np.reshape(np.arange(12), (4,3))
print(a)
>>>
[[ 0 1 2]
[ 3 4 5]
[ 6 7 8]
[ 9 10 11]]
# 沿着 0 轴(垂直方向滚动 1 行)
print(np.roll(a, 1, axis=0))
>>>
[[ 9 10 11]
[ 0 1 2]
[ 3 4 5]
[ 6 7 8]]
# 沿着 1 轴(水平方向滚动 1 行)
print(np.roll(a, 1, axis=1))
>>>
[[ 2 0 1]
[ 5 3 4]
[ 8 6 7]
[11 9 10]]
# 展平后滚动再按原 shape 返回
print(np.roll(a, 1))
>>>
[[11 0 1]
[ 2 3 4]
[ 5 6 7]
[ 8 9 10]]
|
对于二维或者多维矩阵,shift 和 axis 可以指定滚动某一维,也可以通过 tuple 指定多维。
0 1 2 3 4 5 6 7 | # 相当于多次滚动操作,先在 0 轴滚动 1 次,然后再在 1 轴滚动 1次
print(np.roll(a, (1,1), axis=(0,1)))
>>>
[[11 9 10]
[ 2 0 1]
[ 5 3 4]
[ 8 6 7]]
|
滚动轴¶
np.rollaxis 可以向后滚动特定的轴到一个指定位置,格式如下:
numpy.rollaxis(A, axis, start=0)
0 1 2 3 4 5 6 7 8 | A = np.ones((3,4,5,6))
print(np.rollaxis(A, 2).shape) # 轴2放到shape[0],其他轴顺序不变
print(np.rollaxis(A, 3, 1).shape) # 轴3放到shape[1],其他轴顺序不变
print(np.rollaxis(A, 1, 4).shape) # 轴1放到shape[4],其他轴顺序不变
>>>
(5, 3, 4, 6)
(3, 6, 4, 5)
(3, 5, 6, 4)
|
类似 np.transpose 的处理流程,我们可以根据索引交换规则,来写出交换后的索引然后把元素映射到新的数组视图中。
交换轴¶
np.swapaxes() 交换两个指定的轴。
swapaxes(a, axis1, axis2)
Interchange two axes of an array.
0 1 2 3 4 5 6 | A = np.arange(6).reshape((2, 3, 1))
print(A.shape)
print(np.swapaxes(A, 0, 2).shape, np.swapaxes(A, 0, 1).shape)
>>>
(2, 3, 1)
(1, 3, 2) (3, 2, 1)
|
类似 np.transpose 的处理流程,我们可以根据索引交换规则,来写出交换后的索引然后把元素映射到新的数组视图中。
数组排序¶
在进行大数据排序时,NumPy 提供的排序函数要比 Python 提供的 sort 和 sorted 函数高效得多。
排序函数 说明 np.sort() 快速排序,返回复制的新数组 A.sort() 对象内置排序,作用在数组上 np.argsort() 原数组排好序的索引值
快速排序¶
排序函数默认使用快速排序,并作用在最后一个轴上。
0 1 2 3 4 5 | A = np.array([1, 3, 2, 4])
print(A)
>>>
[1 3 2 4]
[1 2 3 4]
|
np.sort() 返回复制元素的新数组,尝试修改 B 中元素,不影响 A。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | A = np.array([1, 3, 2, 4])
print(A)
B = np.sort(A) # B 中元素是拷贝
print(B)
>>>
[1 3 2 4]
[1 2 3 4]
B[0] = -1 # 不影响 A
print(A)
>>>
[1 3 2 4]
|
为了节省空间,可以直接在 ndarray 上排序:
0 1 2 3 4 5 6 7 | A = np.array([1, 3, 2, 4])
print(A)
A.sort() # 无返回
print(A)
>>>
[1 3 2 4]
[1 2 3 4]
|
获取排序的索引数组,并使用索引数组获取排序后的数组。
0 1 2 3 4 5 6 7 | A = np.array([1, 3, 2, 4])
print(A)
I = np.argsort(A)
print(I, A[I])
>>>
[1 3 2 4]
[0 2 1 3] [1 2 3 4]
|
归并排序和堆排序¶
以上排序函数均支持 kind 参数来指定排序方法:’quicksort’, ‘mergesort’ 和 ‘heapsort’,默认为快速排序。
0 1 2 3 4 5 6 7 8 9 | A = np.array([1, 3, 2, 4])
B = A.copy()
print(A)
A.sort(kind='mergesort')
B.sort(kind='heapsort')
print(A, B)
>>>
[1 3 2 4]
[1 2 3 4] [1 2 3 4]
|
指定轴排序¶
指定轴,可以沿着行或列排序。以上排序函数均支持 axis 参数。axis 默认为 -1,也即作用在最后一个轴上。
0 1 2 3 4 5 6 7 8 9 | A = np.array([[4, 1], [2, 3]])
B = A.copy()
print(A)
print(np.sort(A)) # 作用在最后一维上,对于二维数组也即行排序
>>>
[[4 1]
[2 3]]
[[1 4]
[2 3]]
|
指定 axis = 0,进行列排序:
0 1 2 3 4 | print(np.sort(A, axis=0))
>>>
[[2 1]
[4 3]]
|
若要进行逆序排序,排序函数为提供对应参数,可以对结果进行切片逆序实现:
0 1 2 3 4 | print(np.sort(A, axis=0)[::-1])
>>>
[[4 3]
[2 1]]
|
部分排序¶
有时候我们不希望对整个数组进行排序, 仅仅希望找到数组中第 K 小的值, NumPy 的 np.partition 函数提供了该功能。
np.partition 函数的输入是数组和数字 kth, 输出结果是一个新数组, 最左边是前 kth 索引的排序, 往右是任意顺序的其他值:
0 1 2 3 4 5 6 7 8 | A = np.array([2, 0, 3, 1, 6, 5, 4])
print(A)
print(np.partition(A, 1))
print(np.partition(A, 3))
>>>
[2 0 3 1 6 5 4]
[0 1 3 2 6 5 4] # 索引[0],[1] 排序 2 个最小元素
[0 1 2 3 4 5 6] # 索引[0],[1],[2],[3] 排序 4 个最小元素
|
与排序类似,也可以沿着多维数组任意的轴进行分隔,默认 axis=-1:
0 1 2 3 4 5 6 7 8 | A = np.array([[2, 0, 3, 5], [1, 6, 5, 4]])
print(A)
print(np.partition(A, 1, axis=1)) # 对行排序,2个最小元素
>>>
[[2 0 3 5]
[1 6 5 4]]
[[0 2 3 5]
[1 4 5 6]]
|
与 np.partition 对应,np.argpartition 返回部分排序的索引:
0 1 2 3 4 | print(np.argpartition(A, 1, axis=1))
>>>
[[1 0 2 3]
[0 3 2 1]]
|
结构化数据¶
大多数时候, 我们的数据可以通过一个异构类型值组成的数组表示, 但有时却并非如此。 本节介绍 NumPy 的结构化数组和记录数组, 它们为复合的、 异构的数据提供了非常有效的存储。 尽管这里列举的模式对于简单的操作非常有用, 但是这些场景通常也可以用 Pandas 的 DataFrame 来实现。
假定现在有关于一些学生的分类数据(如姓名、 年龄和体重) , 我们需要存储这些数据用于 Python 项目, 那么一种可行的方法是将它们存在三个单独的数组中。
0 1 2 | name = ['Tom', 'Bob', 'John', 'George']
age = [18, 25, 22, 19]
weight = [58.0, 65.5, 68.0, 61.5]
|
但是这种方法有点笨, 因为并没有任何信息告诉我们这三个数组是相关联的。 如果可以用一种单一结构来存储所有的数据, 那么看起来会更自然。 NumPy 可以用结构化数组实现这种存储,这些结构化数组是复合数据类型的。
结构化数组¶
通过 dtype 参数可以生成复合数据类型的结构化数组:
0 1 2 3 4 5 6 | # 使用复合数据结构的结构化数组
data = np.zeros(4, dtype={'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
print(data.dtype)
>>>
[('name', '<U10'), ('age', '<i4'), ('weight', '<f8')]
|
这里 U10 表示“长度不超过 10 的 Unicode 字符串”, i4 表示“4 字节(即32 比特) 整型”, f8 表示“8 字节(即 64 比特) 浮点型”。
现在生成了一个空的数组容器, 可以将列表数据放入数组中:
0 1 2 3 4 5 6 7 8 9 | data['name'] = name
data['age'] = age
data['weight'] = weight
print(data)
print(data.shape)
>>>
[('Tom', 18, 58. ) ('Bob', 25, 65.5) ('John', 22, 68. )
('George', 19, 61.5)]
(4,)
|
结构化数组的方便之处在于,可以通过索引或名称查看相应的值:
0 1 2 3 4 5 6 7 | print(data['name']) # 获取所有名字
print(data[0]) # 获取数据第一行
print(data[-1]['name']) # 获取最后一行的名字
>>>
['Tom' 'Bob' 'John' 'George']
('Tom', 18, 58.)
George
|
利用布尔掩码, 还可以做一些更复杂的操作, 如按照年龄进行筛选:
0 1 2 3 4 | # 获取年龄大于20岁的人的名字
print(data[data['age'] > 20]['name'])
>>>
['Bob' 'John']
|
如果你希望实现比上面更复杂的操作, 那么你应该考虑使用 Pandas。
定制数据类型¶
结构化数组的数据类型有多种制定方式。 此前我们看过了采用字典的方法:
0 1 | np.dtype({'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
|
数值数据类型可以用 Python 类型或 NumPy 的 dtype 类型指定:
0 1 | np.dtype({'names':('name', 'age', 'weight'),
'formats':((np.str_, 10), int, np.float32)})
|
复合类型也可以通过元组列表指定:
0 | np.dtype([('name', 'S10'), ('age', 'i4'), ('weight', 'f8')])
|
如果类型的名称对你来说并不重要, 那你可以仅仅用一个字符串来指定它。 在该字符串中数据类型用逗号分隔:
0 | np.dtype('S10,i4,f8')
|
简写的字符串格式的代码可能看起来令人困惑, 但是它们其实基于非常简单的规则。
第一个(可选) 字符是 < 或者 >, 分别表示“低字节序”(little endian) 和“高字节序”(bid endian),表示字节(bytes)类型的数据在内存中存放顺序的习惯用法。
后一个字符指定的是数据的类型: 字符、 字节、 整型、 浮点型, 等等(如下表所示)。
最后一个字符表示该对象的字节大小。
类型符号 描述 示例 ‘b’ 字节型 np.dtype(‘b’) ‘i’ 有符号整型 np.dtype(‘i4’) == np.int32 ‘u’ 无符号整型 np.dtype(‘u1’) == np.uint8 ‘f’ 浮点型 np.dtype(‘f8’) == np.int64 ‘c’ 复数浮点型 np.dtype(‘c16’) == np.complex128 ‘S’,’a’ 字符串 np.dtype(‘S5’) ‘U’ Unicode 编码字符串 np.dtype(‘U’) == np.str_ ‘V’ 原生数据(raw data) np.dtype(‘V’) == np.void
高级复合类型¶
NumPy 中也可以定义更高级的复合数据类型。 例如, 你可以创建一种类型, 其中每个元素都包含一个数组或矩阵。 我们会创建一个数据类型,该数据类型用 mat 组件包含一个 3×3 的浮点矩阵:
0 1 2 3 4 5 6 7 8 9 | tp = np.dtype([('id', 'i8'), ('mat', 'f8', (3, 3))])
X = np.zeros(1, dtype=tp)
print(X[0])
print(X['mat'][0])
>>>
(0, [[ 0., 0., 0.], [ 0., 0., 0.], [ 0., 0., 0.]])
[[ 0. 0. 0.]
[ 0. 0. 0.]
[ 0. 0. 0.]]
|
现在 X 数组的每个元素都包含一个 id 和一个 3×3 的矩阵。 为什么我们宁愿用这种方法存储数据, 也不用简单的多维数组, 或者 Python 字典呢? 原因是 NumPy 的 dtype 直接映射到 C 结构的定义, 因此包含数组内容的缓存可以直接在 C 程序中使用。 如果你想写一个 Python 接口与一个遗留的 C 语言或 Fortran 库交互, 从而操作结构化数据, 你将会发现结构化数组非常有用!
NumPy 还提供了 np.recarray 类。 它和前面介绍的结构化数组几乎相同, 但是它有一个独特的特征: 域可以像属性一样获取, 而不是像字典的键那样获取。 前面的例子通过以下代码获取年龄:
0 1 2 3 | print(data['age'])
>>>
[18 25 22 19]
|
如果将这些数据当作一个记录数组, 我们可以用更简短的方式来获取这个结果:
0 1 2 3 4 | data_rec = data.view(np.recarray)
print(data_rec.age)
>>>
[18 25 22 19]
|
记录数组的不好的地方在于, 即使使用同样的语法, 在获取域时也会有一些额外的开销。
应用实例¶
更复杂和专业的图像处理应该使用 PIL,PIL(Python Image Library)是python的第三方图像处理库,由于其强大的功能与众多的使用人数,几乎已经被认为是python官方图像处理库了。图形处理相关模块还有 OpenCV, SciKit-Image 和 Pillow。
矩阵和图像变换¶
在图像处理软件中对图像的变换均是通过矩阵操作来完成的,这种矩阵被称为变换矩阵。
基本的常用矩阵变换操作包括平移、缩放、旋转、斜切。每种变换都对应一个变换矩阵,通过矩阵乘法,可以把多个变换矩阵相乘得到复合变换矩阵。矩阵乘法不支持交换律,因此不同的变换顺序得到的变换矩阵也是不相同的。
首先,我们使用四个坐标点来生成一个正方形,然后基于它进行各类变换:
0 1 2 3 4 5 6 7 | P = np.array([[0,1,1,0], # x 轴坐标
[0,0,1,1]]) # y 轴坐标
plt.figure(figsize=(7,7))
plt.title('Square', fontsize=16)
plt.xlim(-2, 2)
plt.ylim(-2, 2)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
|
可以看到该正方形左下角为坐标原点,单位边长为 1,使用矩阵进行线性变换,原点是不会移动的,所以平移操作并不是线性的,后面在平移小结会介绍。
旋转¶
首先看一个围绕原点旋转的示例,然后分析为何乘以某个矩阵就会实现旋转的效果。
0 1 2 3 4 5 6 7 | # P 为二维数组,包含需要转换的坐标点数据,angle 为转换角度
def rotate(P, angle):
rad = angle/180 * np.pi
# 2D 旋转变换矩阵
R = np.array([[np.cos(rad), -np.sin(rad)],
[np.sin(rad), np.cos(rad)]])
return R.dot(P)
|
rotate 函数实现逆时针旋转,当然只要把参数 angle 改为 -angle 就可以实现顺时针旋转。
0 1 2 3 4 5 6 7 8 9 | plt.figure(figsize=(7,7))
plt.title('Rotate', fontsize=16)
plt.xlim(-2, 2)
plt.ylim(-2, 2)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
# 依次旋转 45 度
for i in range(1,5,1):
rr = rotate(P, i*45)
plt.fill(rr[0], rr[1], alpha=1) # 自动填充不同颜色
|
从图中可以看出,原始位置的正方形围绕原点向左逆时针依次旋转 45 度,这里旋转了四次。
缩放¶
缩放通过缩放变换矩阵完成:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # scalex 和 scaley 分别对应 x 轴和 y 轴缩放系数
def scale(P, scalex, scaley):
# 2 维缩放矩阵
S = np.array([[scalex, 0],
[0, scaley]])
return S.dot(P)
plt.figure(figsize=(7,7))
plt.xlim(-2, 2)
plt.ylim(-2, 2)
plt.title('Scale', fontsize=16)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
for i in range(1,5,1):
rr = scale(P, 1/i, 1/i)
rr = rotate(rr, -i*45) # 为了查看缩放效果,同时进行旋转
plt.fill(rr[0], rr[1], alpha=1)
|
镜像¶
所谓镜像,也即关于某根线进行对称映射。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # x 轴镜像
def xmirror(P):
MX = np.array([[1, 0],
[0, -1]])
return MX.dot(P)
# y 轴镜像
def ymirror(P):
MY = np.array([[-1, 0],
[0, 1]])
return MY.dot(P)
# 关于 y=x 镜像
def xymirror(P):
XY = np.array([[0, 1],
[1, 0]])
return XY.dot(P)
# 关于 y=-x 镜像
def nxymirror(P):
XY = np.array([[0, -1],
[-1, 0]])
return XY.dot(P)
# 关于原点镜像
def omirror(P):
XY = np.array([[-1, 0],
[0, -1]])
return XY.dot(P)
|
使用以上镜像函数生成一组效果图:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | dic = {'XMirror' : xmirror(P),
'YMirror' : ymirror(P),
'Y=x Mirror' : xymirror(P),
'Y=-x Mirror' : nxymirror(P),
'OriginMirror' : omirror(P)
}
# 调整坐标轴位置
def set_axis(plt):
ax = plt.gca()
ax.spines['left'].set_color('none')
ax.spines['top'].set_color('none')
ax.xaxis.set_ticks_position('bottom')
ax.spines['bottom'].set_position(('data', 0))
ax.yaxis.set_ticks_position('right')
ax.spines['right'].set_position(('data', 0))
plt.xlim(-2, 2)
plt.ylim(-2, 2)
plt.xticks([-2,-1,0,1,2])
plt.yticks([-2,-1,0,1,2])
index = 0
plt.figure(figsize=(12, 8))
for i in dic:
plt.subplot(2,3,index+1)
plt.title(i, fontsize=16)
set_axis(plt)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
T = dic[i]
plt.fill(T[0], T[1], facecolor='g', alpha=0.5)
index += 1
|
斜切¶
图像斜切是一种变形:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | def oblique(P, shearx, sheary):
S = np.array([[1, shearx],
[sheary, 1]])
return S.dot(P)
dic = {'X Oblique' : oblique(P, -1, 0),
'Y Oblique' : oblique(P, 0, -1)}
index = 0
plt.figure(figsize=(8, 4))
for i in dic:
plt.subplot(1,2,index+1)
plt.title(i, fontsize=16)
set_axis(plt)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
M = dic[i]
plt.fill(M[0], M[1], facecolor='m', alpha=0.5)
index += 1
|
位移¶
位移变换不是线性变换,需要借助第三个维度的齐次坐标实现。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # x 和 y 参数表示在 x 和 y 轴上的位移距离
def move(P, x, y):
S = np.array([[1, 0, x],
[0, 1, y],
[0, 0, 1]])
# 填充第三维坐标以适配矩阵点乘
P = np.vstack([P, np.ones((P.shape[1]))])
return S.dot(P)
plt.figure(figsize=(8,8))
set_axis(plt)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
M = move(P, -1, -2)
plt.fill(M[0], M[1], facecolor='m', alpha=0.5)
plt.show()
|
组合变换¶
组合变换即将多个变换组合在一起,例如旋转和位移。
由于所有的线性变换都是基于原点的(针对整个坐标系的),图像的处理先把原图移动到原点,然后基于原点缩放剪切处理后再移动到原位置 ,如果包含了移动,那么就要使用齐次坐标,所有的其他矩阵也要变为齐次坐标,这样可以得到复合变换矩阵,然后一次性点乘需要变换的坐标矩阵即可。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | def homo_rotate(P, angle):
rad = angle/180 * np.pi
P = np.vstack([P, np.ones((P.shape[1]))])
m0 = move_matrix(-P[0][0], -P[1][0])
m1 = move_matrix(P[0][0], P[1][0])
# 齐次坐标组合旋转变换
R = np.array([[np.cos(rad), -np.sin(rad), 0],
[np.sin(rad), np.cos(rad), 0],
[0, 0, 1]])
T = m1.dot(R).dot(m0)
return T.dot(P)
P = np.array([[0.5,1.5,1.5,0.5],
[0.5,0.5,1.5,1.5]])
plt.figure(figsize=(8,8))
set_axis(plt)
plt.fill(P[0], P[1], facecolor='r', alpha=0.5)
M = homo_rotate(P, 45)
plt.fill(M[0], M[1], facecolor='m', alpha=0.5)
plt.show()
|
缩放和斜切的组合变换实现如下:
0 1 2 3 4 5 6 7 8 9 10 | def homo_scale(P, scalex, scaley):
S = np.array([[scalex, 0, 0],
[0, scaley, 0],
[0, 0, 1]])
return S.dot(P)
def homo_shear(P, shearx, sheary):
S = np.array([[1, shearx, 0],
[sheary, 1, 0],
[0, 0, 1]])
return S.dot(P)
|
镜像变换的基准线如果通过原点,那么无需移动到原点,直接变换即可。
简单图像处理¶
一个图像由若干个像素组成,rows * cols 就是像素数。以著名的手写图片数据集 MNIST 为例:
TEST SET IMAGE FILE (t10k-images-idx3-ubyte):
[offset] [type] [value] [description]
0000 32 bit integer 0x00000803(2051) magic number
0004 32 bit integer 10000 number of images
0008 32 bit integer 28 number of rows
0012 32 bit integer 28 number of columns
0016 unsigned byte ?? pixel
0017 unsigned byte ?? pixel
........
Pixel values are 0 to 255. 0 means background (white), 255 means foreground (black).
这里使用 t10k-images-idx3-ubyte 数据集,它的格式如上所示,一共有 1W 张图片,每个图片有 28*28 = 784 个像素,每个像素占用 1字节,值在 [0-255] 之间,0 表示白色背景,其他值表示灰度。
0 1 2 3 4 5 6 7 8 9 | import struct
def load_mnist(fname):
with open(fname, 'rb') as imgpath:
magic, num, rows, cols = struct.unpack('>IIII', imgpath.read(16))
print("image number:", num)
print("image rows:", rows)
print("image columns:", cols)
images = np.fromfile(imgpath, dtype=np.uint8)
return images.reshape(num, rows * cols), rows, cols
|
使用 struct 模块读取文件头部,并返回 images 数组,shape 为 (10000,784),rows 和 cols 为 28。
使用 images[0] 这一行数据,并转换为 shape 为 (28,28) 的二维像素数组 img0。
0 1 | images,rows,cols = load_mnist('t10k-images.idx3-ubyte')
img0 = images[0].reshape(28, 28)
|
接下来使用 numpy 提供的各类函数,对数组进行操作,并使用 matplotlib 作图,比较它们的不同效果:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | def xrotate(img0, step=1):
splits = np.hsplit(img0, [step])
return np.hstack((splits[1], splits[0]))
def yrotate(img0, step=1):
splits = np.vsplit(img0, [step])
return np.vstack((splits[1], splits[0]))
dic = {'Original' : img0,
'Transpose' : img0.transpose(),
'SwapXY' : np.swapaxes(img0, 0, 1),
'HalfRows' : img0[0:int(cols/2+1)],
'HalfCols' : img0[:, 0:int(rows/2+1)],
'ReverseX' : img0[:, ::-1],
'ReverseY' : img0[::-1, :],
'ReverseXY' : img0[::-1, ::-1],
'Leftupper' : img0[0:int(cols/2+1), 0:int(rows/2+1)],
'Rightlower': img0[int(cols/2+1):, int(rows/2+1):],
'CompressX' : img0[:, ::2],
'CompressY' : img0[::2, :],
'Bolded' : (img0 > 0) * 255,
'Lighted' : img0 >> 7,
'Reverse' : 255 - img0,
'XRotate' : xrotate(img0, cols >> 1), # 向右平移 1/2 宽
'YRotate' : yrotate(img0, rows >> 1), # 向上平移 1/2 高
'XYRotate' : yrotate(xrotate(img0, cols >> 1), rows >> 1), # 右上平移 1/2宽高
}
index = 0
plt.figure(figsize=(12, 6))
for i in dic:
plt.subplot(2,5,index+1)
plt.title(i, fontsize=16)
plt.imshow(dic[i], cmap='binary')
index += 1
plt.show()
|
常规图像处理¶
通常不会直接使用 NumPy 来作为图像处理工具,但是使用 NumPy 操作图像数据却是最直观了解和学习 NumPy 各类操作的方式。
这里使用256*256的 Lena.png 样图。
对于普通像素图像的加载,通常借助 matplotlib 模块:
0 1 2 3 4 5 6 | img = plt.imread("lena.png")
print(type(img).__name__)
print(img.shape)
>>>
ndarray
(256, 256, 3)
|
令人欣喜的是 plt.imread 返回的是 ndarray 对象,它的形状 (256, 256, 3) 分别表示 x,y 轴的像素数为 256,图像通道数为 3,通常为 RGB,如果为 4 通常为 RGBA。
裁剪图片¶
水平裁剪相当于取特定的行数:
0 | mpl.image.imsave('horizontal.png', img[:128, :,:]) # 水平裁剪
|
垂直裁剪相当于取特定的列数:
0 | mpl.image.imsave('vertical.png', img[:, :128]) # 垂直裁剪
|
分离通道¶
分离通道是对第三维 RGB 数据进行操作,分离 R 通道相当于把 G,B 通道赋值为0。
0 1 2 3 4 5 6 7 8 | R,G,B = img.copy(),img.copy(),img.copy()
R[:,:,[1,2]] = 0
G[:,:,[0,2]] = 0
B[:,:,[0,1]] = 0
# R 通道
mpl.image.imsave('R.png', R)
# RGB 三通道图片保存到在一个张图片中
mpl.image.imsave('RGB.png', np.hstack([R,G,B]))
|
灰阶¶
如何把彩色图片转换成灰度图?每个彩色像素由三通道(R,G,B)的强度描述,如何把一个像素映射到一个单独的数字作为的灰度值?通常有三种算法:
- lightness方法:是取最突触颜色和最不突出颜色的平均值:(max(R, G, B) + min(R, G, B)) / 2。
- average方法:最简单取R,G,B的平均值:(R + G + B) / 3。
- luminosity方法:它通过加权平均来解释人类感知。我们对绿色比其他颜色更敏感,所以绿色加权最大。其计算公式为亮度为0.21 R + 0.72 G + 0.07 B。
这里使用第三种方式转换为 8 bits 的灰度图片。
0 1 2 3 4 5 6 7 8 9 | rgb2grey = [0.21, 0.72, 0.07]
grey = img * rgb2grey # 权重相乘
print(grey.shape)
grey = grey.sum(axis=2) # 求和后变为 256*256 二维数组
print(grey.shape)
mpl.image.imsave('grey.png', grey, cmap='Greys') # 灰度形式输出
>>>
(256, 256, 3)
(256, 256)
|
pandas¶
NumPy 的 ndarray 数据处理要求数据类型一致,且不能缺失,不可为数据项添加额外标签等,为了解决 ndarray 的强类型限制,Panda 对 NumPy 的 ndarray 对象进行了扩展。
建立在 NumPy 数组结构上的 Pandas, 提供了 Series 和 DataFrame 对象,为极度繁琐和耗时的“数据清理”(data munging)任务提供了捷径。
笔者使用 Anaconda 提供的集成数据处理环境,查看 pandas 版本:
0 1 2 3 4 | import pandas as pd
print(pd.__version__)
>>>
0.20.3
|
基本数据结构¶
pandas 在 NumPy 的 ndarray 对象基础上封装了三个基本数据结构 Series、 DataFrame 和 Index。 Pandas 在这些基本数据结构上实现了许多功能和方法。
Series 对象¶
Series 对象是一个带索引标签(Labels)的一维数组,打印查看很像只有一列的表,可以看做向量。可以使用 list 作为参数,来生成对应 Series 对象,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | sdata = pd.Series([1, 2, 3.14])
print(sdata)
>>>
0 1.00 # 默认使用从 0 开始的整数作为索引
1 2.00
2 3.14
dtype: float64
print(type(sdata).__name__)
print(sdata.dtype) # dtype 属性记录成员的类型
>>>
Series
float64
|
可以使用索引访问 Series 对象成员,如果使用切片返回的是一个 Series 对象。
0 1 2 3 4 5 | print(type(sdata[1]).__name__, sdata[1])
print(type(sdata[0:-1]).__name__)
>>>
float64 2
Series
|
如同查看列表长度一样,可以使用 len() 查看成员数目:
0 1 2 3 | print(len(sdata))
>>>
3
|
Series 索引¶
Series 对象和一维 NumPy 数组的本质差异在于索引:
- NumPy 数组通过隐式定义的整数索引获取数值。
- Pandas 的 Series 对象用显式定义的 RangeIndex 索引与数值关联。
0 1 2 3 4 | # 打印 RangeIndex 类型
print(sdata.index)
>>>
RangeIndex(start=0, stop=3, step=1)
|
显式索引让 Series 对象拥有了更具弹性的索引方式。 索引不再局限于整数,可以是任意想要的类型。例如用字符串作为索引:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | sdata = pd.Series([1, 2, 3.14], index=['num1', 'num2', 'pi'])
print(sdata)
>>>
num1 1.00
num2 2.00
pi 3.14
dtype: float64
# 使用字符串作为索引
print(sdata['pi'])
>>>
3.14
|
Series 成员可以是其他任何对象,也可以是不同对象,这看起来很像字典,此时它的类型为 object:
0 1 2 3 4 5 6 7 | sdata = pd.Series({'a': 1, 'b': 2, 'c': 'abc'})
print(sdata)
>>>
a 1
b 2
c abc
dtype: object
|
Series 是特殊字典¶
字典是一种将任意键映射到一组任意值或对象的数据结构,而 Series 对象是一种将类型键映射到一组类型值的数据结构。Pandas Series 的类型信息使得它在某些操作上比 Python 的字典更高效。
可以直接用 Python 的字典创建一个 Series 对象:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | id_dicts = {'John': 100,
'Tom' : 101,
'Bill': 102}
ids = pd.Series(id_dicts)
print(ids['Bill'])
>>>
102
# 元素顺序按照索引字母大小进行排序
print(ids)
>>>
Bill 102
John 100
Tom 101
dtype: int64
|
和字典不同,Series 对象还支持数组形式的操作, 比如切片:
0 1 2 3 4 5 6 7 | # 注意切片索引顺序不是按照字典中元素定义顺序,而是按照 Series 对象的索引顺序
sub_ids = ids['Bill':'John']
print(sub_ids)
>>>
Bill 102
John 100
dtype: int64
|
创建 Series 对象¶
pd.Series(data, index=index)
创建 Series 对象的格式如上所示,index 可选,指定索引序列,默认值为整数序列;data 参数支持多种数据类型:列表,字典或者一维的 ndarray 对象。
0 1 2 3 4 5 6 7 8 | ndata = np.arange(1, 4, 1)
sdata = pd.Series(ndata)
print(sdata)
>>>
0 1
1 2
2 3
dtype: int32
|
data 也可以是一个数值, 创建 Series 对象时会重复填充到每个索引上:
0 1 2 3 4 5 6 7 | sdata = pd.Series(1, index=['a', 'b', 'c'])
print(sdata)
>>>
a 1
b 1
c 1
dtype: int64
|
当参数为字典时,可以通过显式指定索引筛选需要的成员:
0 1 2 3 4 5 6 | subsdata = pd.Series({'a': 1, 'b': 2, 'c': 'abc'}, index=['a', 'c'])
print(subsdata)
>>>
a 1
c abc
dtype: object
|
注意
Series 对象只会保留显式定义的键值对。
Series.index 属性获取所有行索引信息:
0 1 2 3 4 | # 获取行索引信息
print(subsdata.index)
>>>
Index(['a', 'c'], dtype='object')
|
DataFrame 对象¶
如果将 Series 类比为带索引的一维数组(或者含有一列数据的带有行标签的单列表), 那么 DataFrame 就可以看作是一种既有行索引,又有列名的二维数组(或者含有行标签和列标签的表,每一列都是一个 Series 对象)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | id_dicts = {'John': 100,
'Tom' : 101,
'Bill': 102}
age_dicts = {'John': 20,
'Tom' : 21,
'Bill': 19}
studentd = pd.DataFrame({'id': pd.Series(id_dicts),
'age': pd.Series(age_dicts)})
print(studentd)
>>>
age id
Bill 19 102
John 20 100
Tom 21 101
|
从示例中可以看出 DataFrame 是一组 Series 的集合,每一列都是一个 Series 对象。
DataFrame 索引¶
在 NumPy 的二维数组里, data[0] 返回第一行;而在 DataFrame 中, data[‘col0’] 返回第一列。 因此,DataFrame 是一种通用字典,而不是通用数组。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | # 使用列名字访问特定列
print(studentd['age'])
>>>
Bill 19
John 20
Tom 21
Name: age, dtype: int64
# 指定列名和行名
print(studentd['age']['John'])
>>>
20
|
创建DataFrame对象¶
上面的示例指定列名和 Series 对象创建多列,也可以创建单列的 DataFrame 对象:
0 1 2 3 4 5 | # 以下两种创建方式等价
ids = pd.Series(id_dicts)
# 通过 Series 对象字典创建
studentd = pd.DataFrame({'id': ids})
studentd = pd.DataFrame(ids, columns=['id'])
|
通过字典列表创建: 任何元素是字典的列表都可以变成 DataFrame。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 创建字典列表
num = [{'num0': i, 'num*3': 3 * i} for i in range(3)]
print(num)
>>>
[{'num0': 0, 'num*3': 0}, {'num0': 1, 'num*3': 3}, {'num0': 2, 'num*3': 6}]
# 创建 DataFrame 对象
print(pd.DataFrame(num))
>>>
num*3 num0
0 0 0
1 3 1
2 6 2
|
如果字典中有些键不存在,Pandas 会用 NaN(不是数字或此处无数,Not a number) 来表示:
0 1 2 3 4 5 | numd = pd.DataFrame([{'a': 1, 'b': 2}, {'b': 3, 'c': 4}])
print(numd)
a b c
0 1.0 2 NaN
1 NaN 3 4.0
|
通过 NumPy 二维数组创建。 假如有一个二维数组, 就可以创建一个可以指定行列索引值的 DataFrame。 如果不指定行列索引值,那么行列默认都是整数索引值:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | narray = np.random.randint(3, size=(3, 2))
print(narray)
>>>
[[2 0]
[2 2]
[2 1]]
df = pd.DataFrame(narray,
columns = ['foo', 'bar'],
index=['a', 'b', 'c'])
print(df)
>>>
foo bar
a 2 0
b 2 2
c 2 1
|
查看行索引和列索引:
0 1 2 3 4 5 | print(df.index)
print(df.columns)
>>>
Index(['a', 'b', 'c'], dtype='object')
Index(['foo', 'bar'], dtype='object')
|
通过 NumPy 结构化数组创建:
0 1 2 3 4 5 6 7 8 9 10 11 12 | A = np.ones(3, dtype=[('A', 'i8'), ('B', 'f8')])
print(A)
>>>
[(1, 1.) (1, 1.) (1, 1.)]
print(pd.DataFrame(A))
>>>
A B
0 1 1.0
1 1 1.0
2 1 1.0
|
更改行或列名¶
通过属性更改¶
可以通过 df.index 和 df.columns 查看行名和列名,同样可以通过这些属性更改行或列的名称,对于 Series 来说只有行名:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | narray = np.random.randint(3, size=(3, 2))
df = pd.DataFrame(narray,
columns = ['foo', 'bar'],
index=['a', 'b', 'c'])
# 更新行索引标签
df.index = [0, 1, 2]
print(df.index)
# 更新列索引标签
df.columns = ['a', 'b']
print(df.columns)
>>>
Int64Index([0, 1, 2], dtype='int64')
Index(['a', 'b'], dtype='object')
print(df)
>>>
a b
0 0 0
1 1 0
2 0 2
|
注意行或列的标签个数和 DataFrame 对象的行数或列数必须一致,否则会报错。
Index 对象¶
Pandas 的 Index 对象可以将它看作是一个不可变数组或有序集合, Index 对象可以包含重复值。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # 可以包含重复值
ind = pd.Index([2, 3, 5, 7, 7, 11])
print(type(ind).__name__)
>>>
Int64Index
# 索引访问元素
print(ind[1])
>>>
3
# 切片访问返回 Index 对象
print(ind[::2])
>>>
Int64Index([2, 5, 7], dtype='int64')
|
Index 对象不支持对数据的修改:
0 1 2 3 | ind[1] = 1
>>>
TypeError: Index does not support mutable operations
|
Index 对象还有许多与 NumPy 数组相似的属性:
0 1 2 3 | print(ind.size, ind.shape, ind.ndim, ind.dtype)
>>>
6 (6,) 1 int64
|
排序操作¶
Index 对象支持对元素的排序:
0 1 2 3 4 5 6 7 8 9 10 | ind = pd.Index([2, 4, 5, 1, 11])
print(ind)
>>>
Int64Index([2, 4, 5, 1, 11], dtype='int64')
ind = ind.sort_values()
print(ind)
>>>
Int64Index([1, 2, 4, 5, 11], dtype='int64')
|
集合操作¶
Pandas 对象被设计用于实现多种操作, 如连接(join) 数据集,其中会涉及许多集合操作。 Index 对象遵循 Python 标准库的集合(set) 数据结构的许多习惯用法, 包括并集、 交集、 差集等:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | indA = pd.Index([1, 3, 5, 7, 9])
indB = pd.Index([2, 3, 5, 7, 11])
# 交集,等价于 indA.intersection(indB)
print(indA & indB)
>>>
Int64Index([3, 5, 7], dtype='int64')
# 并集
print(indA | indB)
>>>
Int64Index([1, 2, 3, 5, 7, 9, 11], dtype='int64')
# 异或
print(indA ^ indB)
>>>
Int64Index([1, 2, 9, 11], dtype='int64')
|
Index 对象进行集合操作的结果还是 Index 对象。它可以是一个空对象。
0 1 2 3 4 5 6 7 | indA = pd.Index([1, 3, 5, 7, 9])
indB = pd.Index([2])
# 交集
print(indA & indB)
>>>
Int64Index([], dtype='int64')
|
数据选择和扩展¶
NumPy 数组可以通过索引,切片,花式索引和掩码操作进行各类选择,Pandas 的 Series 和 DataFrame 对象具有相似的数据获取与调整操作。
Series数据选择¶
访问数据¶
将Series看作字典,和字典一样, Series 对象提供了键值对(索引)的映射:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | # 使用 in 或者 not in 判断键是否存在
sdata = pd.Series([1, 2, 3.14], index=['num1', 'num2', 'pi'])
# 等价于 sdata.index
print(sdata.keys())
print('pi' in sdata) # 等价于 'pi' in sdata.keys()
>>>
Index(['num1', 'num2', 'pi'], dtype='object')
True
# 判断值是否存在,Series.values 是 ndarray 类型
print(sdata.values, type(sdata.values).__name__)
print(1 in sdata.values)
>>>
[ 1. 2. 3.14] ndarray
True
# Series.items() 返回 zip 类型,可以转换为 list
print(sdata.items())
print(list(sdata.items()))
>>>
<zip object at 0x0000020B8A3DCF08>
[('num1', 1.0), ('num2', 2.0), ('pi', 3.1400000000000001)]
|
Series 不仅有着和字典一样的接口, 而且还具备和 NumPy 数组一样的数组数据选择功能,包括索引、掩码、花式索引等操作,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | # 将显式索引作为切片,结果包含最后一个索引
subs = sdata['num1':'num2']
print(subs)
>>>
num1 1.0
num2 2.0
dtype: float64
# 将隐式整数索引作为切片,结果不含最后一个索引
print(sdata[0:2])
print(sdata[-1:0:-1])
>>>
num1 1.0
num2 2.0
dtype: float64
pi 3.14
num2 2.00
dtype: float64
# 掩码,返回 bool 类型的 Series 掩码对象
print((sdata > 1) & (sdata < 4))
>>>
num1 False
num2 True
pi True
dtype: bool
# Series 掩码对象作为索引
subs = sdata[(sdata > 1) & (sdata < 4)]
print(subs)
>>>
num2 2.00
pi 3.14
dtype: float64
# 花式索引
subs = sdata[['num1', 'pi']]
print(subs)
>>>
num1 1.00
pi 3.14
dtype: float64
|
切片是绝大部分混乱之源。 需要注意的是,当使用显式索引(即 data[‘a’:’c’]) 作切片时, 结果包含最后一个索引; 而当使用隐式索引(即 data[0:2]) 作切片时, 结果不包含最后一个索引。
索引标签默认是无序的,也即根据创建时标签声明的顺序来排列,我们可以对它进行排序,以方便切片操作:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | sdata = pd.Series([1, 2, 3.14], index=['num2', 'num1', 'pi'])
print(sdata)
>>>
num2 1.00
num1 3.14
pi 2.00
dtype: float64
# 对索引进行排序
sdata = sdata.sort_index()
print(sdata['num1':'num2'])
>>>
num1 2.0
num2 1.0
dtype: float64
|
索引器¶
切片和取值的习惯用法经常会造成混乱。如果 Series 是显式整数索引,那么 data[1] 这样的取值操作会使用显式索引,而 data[1:3] 样的切片操作却会使用隐式索引。
0 1 2 3 4 5 6 7 8 9 10 11 | sdata = pd.Series([1, 2, 3.14], index=[1, 2, 3])
print(sdata[1]) # 显式索引,使用 sdata[0] 将报错
>>>
1.0
print(sdata[0:2]) # 隐式索引,不含 sdata[2]
>>>
1 1.0
2 2.0
dtype: float64
|
由于整数索引很容易造成混淆,所以 Pandas 提供了一些索引器(indexer) 属性来作为取值的方法。它们不是 Series 对象的函数方法, 而是暴露切片接口的属性。
第一种索引器是 loc 属性, 表示取值和切片都是显式的:
0 1 2 3 4 5 6 7 8 9 10 11 | sdata = pd.Series([1, 2, 3.14], index=[1, 2, 3])
print(sdata.loc[1]) # 显式索引
>>>
1.0
print(sdata.loc[1:2]) # 显式索引
>>>
1 1.0
2 2.0
dtype: float64
|
第二种是 iloc 索引属性,表示取值和切片都是隐式索引(从 0 开始, 左闭右开区间):
0 1 2 3 4 5 6 7 8 9 10 | sdata = pd.Series([1, 2, 3.14], index=[1, 2, 3])
print(sdata.iloc[1]) # 隐式索引
>>>
2.0
print(sdata.iloc[1:2])# 隐式索引
>>>
2 2.0
dtype: float64
|
第三种取值属性是 ix,它是前两种索引器的混合形式,从 0.20.0 版本开始,ix 索引器不再被推荐使用。
Python 代码的设计原则之一是“显式优于隐式”。 使用 loc 和 iloc 可以让代码更容易维护, 可读性更高。 特别是在处理整数索引的对象时, 我强烈推荐使用这两种索引器。 它们既可以让代码阅读和理解起来更容易, 也能避免因误用索引 / 切片而产生的小 bug。
扩展数据¶
Series 对象还可以用字典语法调整数据。可以通过增加新的索引值扩展 Series:
0 1 2 3 4 5 6 7 8 | sdata['e'] = 2.72
print(sdata)
>>>
num1 1.00
num2 2.00
pi 3.14
e 2.72
dtype: float64
|
DataFrame数据选择¶
访问数据¶
既可以通过字典方式也可以通过属性方式访问 DataFrame :
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | studentd = pd.DataFrame({'id': pd.Series(id_dicts),
'age': pd.Series(age_dicts)})
print(studentd['id']['John']) # 字典键方式访问
>>>
100
print(studentd['id']) # 列属性方式访问
>>>
Bill 102
John 100
Tom 101
Name: id, dtype: int64
print(studentd['id']['John']) # 列属性和行属性方式访问
>>>
100
|
虽然属性形式的数据选择方法很方便, 但是它并不是通用的。 如果列名不是纯字符串, 或者列名与 DataFrame 的方法同名, 那么就不能用属性索引。 例如, DataFrame 有一个 pop() 方法, 如果用data.pop 就不会获取 ‘pop’ 列, 而是显示为方法。
另外, 还应该避免对用属性形式选择的列直接赋值(即可以用data[‘pop’] = z,但不要用 data.pop = z)防止覆盖方法名。
和前面介绍的 Series 对象一样,还可以用字典形式的语法调整对象,如果要增加一列可以这样做:
0 1 2 3 4 5 6 7 8 | # 等价于 studentd['newcol'] = studentd.id + studentd.age
studentd['newcol'] = studentd['id'] + studentd['age']
print(studentd)
>>>
age id newcol
Bill 19 102 121
John 20 100 120
Tom 21 101 122
|
将DataFrame看作二维数组,用 values 属性按行查看数组数据:
0 1 2 3 4 5 6 | print(studentd.values, '\n', type(studentd.values).__name__)
>>>
[[ 19 102]
[ 20 100]
[ 21 101]]
ndarray
|
由于返回值是 ndarray 类型,所以可以对其进行任何矩阵操作:
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 获取行数据(获取一列数据要传递列索引)
print(studentd.values[0])
>>>
[ 19 102]
print(studentd.values.T)
>>>
[[ 19 20 21]
[102 100 101]]
print(studentd.keys())
|
keys() 方法返回列名组成的索引类型 Index:
0 1 | >>>
Index(['age', 'id'], dtype='object')
|
使用索引器¶
索引器的作用在于指明使用隐式索引还是显示索引。通过 iloc 索引器,可以像对待 NumPy 数组一样索引 Pandas 的底层数组(Python 的隐式索引),DataFrame 的行列标签会自动保留在结果中:
0 1 2 3 4 | print(studentd.iloc[:1, :2])
>>>
age id
Bill 19 102
|
任何用于处理 NumPy 形式数据的方法都可以用于这些索引器。例如,可以在 loc 索引器中结合使用掩码与花式索引方法:
0 1 2 3 4 5 6 | # 选择 age >= 20 的学生的 id 信息
print(studentd.loc[studentd.age >= 20, ['id']])
>>>
id
John 100
Tom 102
|
切片选择¶
如果对单个标签取值就选择列,而对多个标签用切片就选择行:
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 列选取,返回 Series 对象
print(studentd['age'])
>>>
Name: age, dtype: int64
# 行选取,返回 DataFrame 对象
print(studentd['John':'Tom'])
>>>
age id
John 20 100
Tom 21 101
|
切片也可以不用索引值, 而直接用行数来实现:
0 1 2 3 4 5 | print(studentd[1:3])
>>>
age id
John 20 100
Tom 21 101
|
与之类似,掩码操作也可以直接对每一行进行过滤,而不需要使用 loc 索引器:
0 1 2 3 4 5 | print(studentd[studentd.age >= 20])
>>>
age id
John 20 100
Tom 21 101
|
更新数据¶
任何一种索引方法都可以用于调整数据, 这一点和 NumPy 的常用方法是相同的:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | studentd.loc['John', 'age'] = 23
print(studentd)
>>>
age id
Bill 19 102
John 23 100
Tom 21 101
# 更新第一行的值全为 5
studentd.iloc[0] = 5
print(studentd)
age id
Bill 5 5
John 20 100
Tom 21 101
|
数值运算¶
NumPy 的基本能力之一是快速对每个元素进行运算,既包括基本算术运算(加、 减、 乘、 除) , 也包括更复杂的运算(三角函数、 指数函数和对数函数等),参考 算术运算。 Pandas 继承了 NumPy 的功能,也即这些函数同样可以作用在 Pandas 对象上。
除此之外,Pandas 也实现了一些高效技巧:一元运算作用在 Pandas 对象上时会保留索引和列标签;而对于二元运算(如加法和乘法),Pandas 在传递通用函数时会自动对齐索引进行计算。这就意味着,保存数据内容与组合不同来源的数据——两处在NumPy 数组中都容易出错的地方在 Pandas 中很容易实现。
一元运算¶
0 1 2 3 4 5 6 7 8 | sdata = pd.Series(np.arange(4))
print(sdata * 2)
>>>
0 0
1 2
2 4
3 6
dtype: int32
|
可以发现 np 函数作用在 Pandas 对象上的返回值还是 Pandas 对象,会保留原标签。
0 1 2 3 4 5 6 | df = pd.DataFrame(np.arange(4).reshape(2, 2), columns=['a', 'b'])
print(np.sin(df / 4 * np.pi))
>>>
a b
0 0.0 0.707107
1 1.0 0.707107
|
二元运算¶
当在两个 Series 或 DataFrame 对象上进行二元计算时,Pandas 会在计算过程中对齐两个对象的索引。当处理不完整的数据时,这一点非常方便。
0 1 2 3 4 5 6 7 8 9 10 11 12 | sdata0 = pd.Series(np.arange(3))
sdata1 = pd.Series(np.arange(2))
print(sdata0)
print(sdata1)
>>>
0 0
1 1
2 2
dtype: int32
0 0
1 1
dtype: int32
|
首先生成两个索引不同的 Series 对象,然后进行相加:
0 1 2 3 4 5 6 | print(sdata0 + sdata1)
>>>
0 0.0
1 2.0
2 NaN
dtype: float64
|
结果数组的索引是两个输入数组索引的并集。对于缺失位置的数据,Pandas 会用 NaN 填充,表示“此处无数”。这是 Pandas 表示缺失值的方法。
如果用 NaN 值不是我们想要的结果, 那么可以用适当的对象方法代替运算符。 例如, A.add(B) 等价于 A + B, 也可以设置参数自定义 A 或 B 缺失的数据:
0 1 2 3 4 5 6 7 | # sdata1 中缺失的索引 2 的值将使用 0 替代
print(sdata0.add(sdata1, fill_value=0))
>>>
0 0.0
1 2.0
2 2.0 # 0 + 2
dtype: float64
|
在计算两个 DataFrame 时,类似的索引对齐规则也同样会出现在共同(并集)列中:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | df0 = pd.DataFrame(np.arange(4).reshape(2,2), columns=list('BA'))
df1 = pd.DataFrame(np.arange(2).reshape(2,1), columns=list('A'))
print(df0)
print(df1)
>>>
B A
0 0 1
1 2 3
A
0 0
1 1
# 填充缺省值 NaN
print(df0 + df1)
>>>
A B
0 1 NaN
1 4 NaN
# 指定缺省值
print(df0.sub(df1, fill_value=0))
>>>
A B
0 1 0.0
1 2 2.0
|
两个对象的行列索引可以是不同顺序的,结果的索引会自动按顺序排列。
Python运算符与Pandas方法的映射关系:
Python运算符 Pandas 对象方法 + add() - sub()、 subtract() * mul()、 multiply() / truediv()、 div()、 divide() // floordiv() % mod() ** pow()
DataFrame与Series的运算¶
DataFrame 与 Series 之间的运算遵循 NumPy 中二维数组和一维数组之间的广播运算规则。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | narray0 = np.array([2,2])
narray1 = np.array([[1,1],[2,2]])
print(narray0 + narray1)
>>>
[[3 3]
[4 4]]
sdata = pd.Series(narray0, index=list('AB'))
print(sdata)
>>>
A 2
B 2
dtype: int32
df = pd.DataFrame(narray1, columns=list('AB'))
print(df)
>>>
A B
0 1 2
1 1 2
print(sdata + df)
>>>
A B
0 3 3
1 4 4
|
根据 NumPy 的广播规则,让二维数组减自身的一行数据会按行计算。如果想按列计算,就需要利用前面介绍过的运算符方法, 通过 axis 参数设置:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 默认按行计算
print(df + df.iloc[0])
>>>
A B
0 2 2
1 3 3
# 按列相加
print(df.add(df['A'], axis=0))
>>>
A B
0 2 2
1 4 4
|
这些行列索引的保留与对齐方法说明 Pandas 在运算时会一直保存这些数据内容, 从而避免在处理数据类型有差异和 / 或维度不一致的 NumPy 数组时可能遇到的问题。
缺失值处理¶
现实中采集的数据很少是干净整齐的,许多目前流行的数据集都会有数据缺失的现象。
通常有两种方式表示缺失值:
- 通过一个覆盖全局的掩码表示缺失值,例如 R 语言为每个元素保留 1 bit 用于标记缺失值。
- 用一个标签值(sentinel value) 表示缺失值,比如用 NaN(不是一个数) 表示缺失的浮点数。
Pandas 选择用标签方法表示缺失值,包括两种 Python 原有的缺失值: 浮点数据类型的 NaN 值, 以及 Python 的 None 对象。
None¶
None 是一个 Python 内置对象,经常在代码中表示缺失值。
0 1 2 3 | print(None, type(None).__name__)
>>>
None NoneType
|
由于 None 是一个 Python 对象,只能用于 ‘object’ 数组类型(即由 Python 对象构成的数组),不能用于其他类型的数组:
0 1 2 3 4 5 6 7 8 9 10 | print(np.array([1, None, 3, 4], dtype=object))
>>>
[1 None 3 4]
# 如果不是 object 类型将报错
print(np.array([1, None, 3, 4], dtype=int))
>>>
TypeError: int() argument must be a string, a bytes-like
object or a number, not 'NoneType'
|
这里 dtype=object 表示 NumPy 认为由于这个数组是 Python 对象构成的,因此将其类型判断为 object。虽然这种类型在某些情景中非常有用,对数据的任何操作最终都会在 Python 层面完成,但是在进行常见的快速操作时,这种类型比其他原生类型数组要更耗时。
由于 Python 没有对 None 对象定义加减等运算操作,所以在包含 None 的数组上执行这类操作均会报错。
0 1 2 3 4 | narray = np.array([1, None, 3, 4], dtype=object)
print(narray.sum())
>>>
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
|
在 Pandas 中,None 被自动转化为 NaN 类型,由于 NaN 是特殊的浮点数,所以生成的对象类型默认为浮点型 float64:
0 1 2 3 4 5 6 7 | ps = pd.Series([1, 2, None])
print(ps)
>>>
0 1.0
1 2.0
2 NaN
dtype: float64
|
当为一个整型对象的元素赋值为 None 时,类型自动转换为 float64:
0 1 2 3 4 5 6 7 8 9 10 11 12 | ps = pd.Series([1, 2])
print(ps.dtype)
>>>
int64
ps[0] = None
print(ps)
>>>
0 NaN
1 2.0
dtype: float64
|
NaN¶
NaN(全称 Not a Number,不是一个数字),是一种按照 IEEE 浮点数标准设计、在任何系统中都兼容的特殊浮点数。表示未定义或不可表示的值。
IEEE 754-1985中,用指数部分全为1、小数部分非零表示NaN。以32位IEEE单精度浮点数的NaN为例,按位表示即:S111 1111 1AXX XXXX XXXX XXXX XXXX XXXX,S为符号位,符号位S的取值无关紧要;A是小数部分的最高位(the most significant bit of the significand),其取值表示了 NaN 的类型:X 不能全为0,并被称为 NaN 的payload。
通常返回 NaN 的运算有如下三种:
- 至少有一个参数是 NaN 的运算
- 不定式
- 下列除法运算:0/0、∞/∞、∞/−∞、−∞/∞、−∞/−∞
- 下列乘法运算:0×∞、0×−∞
- 下列加法运算:∞ + (−∞)、(−∞) + ∞
- 下列减法运算:∞ - ∞、(−∞) - (−∞)
- 产生复数结果的实数运算。例如:
- 对负数进行开偶次方的运算
- 对负数进行对数运算
- 对正弦或余弦到达域以外的数进行反正弦或反余弦运算
由于 NaN 是特殊的浮点数,所以当数组成员包含 NaN 时,其类型为浮点型,默认为 float64。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | narray = np.array([1, np.nan, 3, 4])
print(narray.dtype)
>>>
float64
# 作用在 NaN 上的运算总是返回 NaN
print(narray.sum())
>>>
nan
# 指定类型为 int 将报错
narray = np.array([1, np.nan, 3, 4], dtype=int)
>>>
ValueError: cannot convert float NaN to integer
|
NumPy 同时提供了一类特殊的累计函数,参考 聚合统计,它们可以忽略缺失值的影响:
0 1 2 3 4 5 6 7 8 | print(np.nansum(narray))
>>>
8.0
print(np.nanmin(narray), np.nanmax(narray))
>>>
1.0 4.0
|
注意
NaN 是一种特殊的浮点数, 不是整数、 字符串以及其他数据类型。
np.nan 表示常量 NaN,如果在创建 Pandas 对象时,包含 np.nan 成员,则对象 dtype 自动转化为 float64 类型,同样赋值操作也会改变 dtype:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | ps = pd.Series([1, 2, np.nan])
print(ps.dtype)
>>>
float64
ps = pd.Series([1, 2])
print(ps.dtype)
>>>
int64
ps[0] = np.nan
print(ps)
>>>
0 NaN
1 2.0
dtype: float64
|
缺失值转换规则¶
Pandas对不同类型缺失值的转换规则:
类型 缺失值转换规则 NA标签值 floating 无变化 np.nan object 无变化 None 或 np.nan integer 强制转换为 float64 np.nan boolean 强制转换为 float64 np.nan boolean 无变化 None
以 bool 类型为例,分别对元素赋值 None 和 np.nan,观察类型变化:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | ps = pd.Series([1, 0, 1], dtype=bool)
# 赋值为 None 等价于 False 类型不变
ps[1] = None
print(ps)
>>>
0 True
1 False
2 True
dtype: bool
# 赋值为 np.nan 类型转换为 float64
ps[1] = np.nan
print(ps)
>>>
0 1.0
1 NaN
2 1.0
dtype: float64
|
缺失值函数¶
Pandas 提供了一些列用于处理确实值的函数或方法。例如发现缺失值,替换缺失值等。
发现缺失值¶
Pandas 数据结构有两种有效的方法可以发现缺失值:isnull() 和 notnull()。每种方法都返回布尔类型的掩码数据,例如:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | ps = pd.Series([1, np.nan, None])
print(ps.isnull())
>>>
0 False
1 True
2 True
dtype: bool
# 与 isnull() 方法相反
print(ps.notnull())
>>>
0 True
1 False
2 False
dtype: bool
|
布尔类型掩码数组可以直接作为 Series 或 DataFrame 的索引使用:
0 1 2 3 4 5 6 | ps = pd.Series([1, np.nan, 2, None])
print(ps[ps.notnull()])
>>>
0 1.0
2 2.0
dtype: float64
|
以上操作同样适用于 DataFrame 对象:
0 1 2 3 4 5 6 7 | df = pd.DataFrame([1, np.nan, None])
print(df.isnull())
>>>
0
0 False
1 True
2 True
|
剔除缺失值¶
dropna() 用于剔除缺失值,它返回一个数组副本。在 Series 上使用它非常简单:
0 1 2 3 4 5 6 7 | # 剔除缺失值
ps = pd.Series([1, np.nan, 2, None])
print(ps.dropna())
>>>
0 1.0
2 2.0
dtype: float64
|
由于 Series 是一维的,任何元素是 NaN 都可以直接删除这一元素(相当于一列),而在 DataFrame 上使用它们时需要设置一些参数, 例如:
0 1 2 3 4 5 6 7 8 9 | df = pd.DataFrame([[1, np.nan, 2],
[2, 3, 5],
[np.nan, 4, 6]])
print(df)
>>>
0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6
|
无法从 DataFrame 中单独剔除一个值,要么是剔除缺失值所在的整行,要么是整列。根据实际需求,来剔除整行或整列,DataFrame 中的 dropna() 会有一些参数可以配置。 默认情况下, dropna() 会剔除任何包含缺失值的整行数据:
0 1 2 3 4 | print(df.dropna())
>>>
0 1 2
1 2.0 3.0 5
|
可以设置按不同的坐标轴剔除缺失值, 比如 axis=1(或 axis=’columns’) 会剔除任何包含缺失值的整列数据:
0 1 2 3 4 5 6 | print(df.dropna(axis='columns'))
>>>
2
0 2
1 5
2 6
|
这么做也会把非缺失值一并剔除,因为可能有时候只需要剔除全部是缺失值的行或列,或者绝大多数是缺失值的行或列。可以通过设置 how 或 thresh 参数来满足,它们可以设置剔除行或列缺失值的数量阈值。
默认设置是 how=’any’, 也就是说只要有缺失值就剔除整行或整列(通过 axis 设置坐标轴)。还可以设置 how=’all’, 这样就只会剔除全部是缺失值的行或列了:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | df[3] = np.nan
print(df)
>>>
0 1 2 3
0 1.0 NaN 2 NaN
1 2.0 3.0 5 NaN
2 NaN 4.0 6 NaN
df = df.dropna(axis='columns', how='all')
print(df)
>>>
0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6
|
还可以通过 thresh 参数设置行或列中非缺失值的最小数量,从而实现更加个性化的配置:
0 1 2 3 4 5 | df = df.dropna(axis='rows', thresh=3)
print(df)
>>>
0 1 2 3
1 2.0 3.0 5 NaN
|
第 1 行与第 3 行被剔除了, 因为它们只包含两个非缺失值。
填充缺失值¶
有时可能并不想移除缺失值,而是想把它们替换成有效的数值。 有效的值可能是像 0、 1、 2 那样单独的值,也可能是经过填充(imputation) 或转换(interpolation) 得到的。 虽然你可以通过isnull() 方法建立掩码来填充缺失值, 但是 Pandas 为此专门提供了一个 fillna() 方法, 它将返回填充了缺失值后的数组副本。
0 1 2 3 4 5 6 7 8 | ps = pd.Series([1, np.nan, 2, None], index=list('abcd'))
print(ps)
>>>
a 1.0
b NaN
c 2.0
d NaN
dtype: float64
|
我们将用一个单独的值来填充缺失值, 例如用 -1:
0 1 2 3 4 5 6 7 | print(ps.fillna(-1))
>>>
a 1.0
b -1.0
c 2.0
d -1.0
dtype: float64
|
可以用缺失值前面的有效值来从前往后填充(forward-fill):
0 1 2 3 4 5 6 7 | print(ps.fillna(method='ffill'))
>>>
a 1.0
b 1.0
c 2.0
d 2.0
dtype: float64
|
也可以用缺失值后面的有效值来从后往前填充(back-fill) :
0 1 2 3 4 5 6 | print(ps.fillna(method='bfill'))
a 1.0
b 2.0
c 2.0
d NaN
dtype: float64
|
无论是从前往后还是从后往前,NaN 之后或之前如果都是 NaN 则无法实现填充。
DataFrame 的操作方法与 Series 类似, 只是在填充时需要设置坐标轴参数 axis:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | df = pd.DataFrame([[1, np.nan, 2],
[2, 3, 5],
[np.nan, np.nan, np.nan]])
print(df)
>>>
0 1 2
0 1.0 NaN 2.0
1 2.0 3.0 5.0
2 NaN NaN NaN
# 从前向后填充行
print(df.fillna(method='ffill', axis=1))
>>>
0 1 2
0 1.0 1.0 2.0
1 2.0 3.0 5.0
2 NaN NaN NaN
# 从后向前填充行
print(df.fillna(method='bfill', axis=1))
>>>
0 1 2
0 1.0 2.0 2.0
1 2.0 3.0 5.0
2 NaN NaN NaN
|
需要注意的是,假如在从前往后填充时,需要填充的缺失值前面没有值,那么它就仍然是缺失值,这个机制是递归填充。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # 从前向后填充列
print(df.fillna(method='ffill', axis=0))
>>>
0 1 2
0 1.0 NaN 2.0
1 2.0 3.0 5.0
2 2.0 3.0 5.0
# 从后向前填充列
print(df.fillna(method='bfill', axis=0))
>>>
0 1 2
0 1.0 3.0 2.0
1 2.0 3.0 5.0
2 NaN NaN NaN
|
数据加载¶
Pandas 提供了丰富的数据加载接口,例如 pd.read_csv,pd.read_json,pd.read_sql 等。
csv 文件数据¶
CSV 是逗号分隔值(Comma-Separated Values有时也称为字符分隔值,因为分隔字符也可以不是逗号)的缩写,其文件以纯文本形式存储表格数据(数字和文本)。可以使用记事本直接打开它,或者使用 Excel 打开。
名为 students.csv 的示例文件内容如下:
0 1 2 3 | age,id,name
20,100,John
21,101,Tom
19,102,Bill
|
读取数据¶
0 1 2 3 4 5 6 7 8 9 | # 参数 header 默认值为 0,表示以第一行为列索引
# 等价于 df = pd.read_csv('students.csv')
df = pd.read_csv('students.csv', header=0)
print(df)
>>>
age id name
0 20 100 John
1 21 101 Tom
2 19 102 Bill
|
read_csv() 方法具有非常丰富的参数,常用参数说明如下:
- sep:分隔符,默认是‘,’,CSV文件的分隔符
- header:列名所在 csv 中的行(列索引),默认第一行为列名(默认header=0),header=None 说明第一行不是列名,它会生成新的整数列名。
- names:当 csv 文件没有列名时候,可以用 names 加上要用的列名
- index_col:要用的行名(index),int或sequence或False,默认为 None,即默认添加从 0 开始的 index,若要用第一列作为行索引则 index_col = 0。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | # header 为 None,表示 csv 第一行数据作为普通数据
df = pd.read_csv('students.csv', header=None)
print(df)
>>>
0 1 2
0 age id name
1 20 100 John
2 21 101 Tom
3 19 102 Bill
# 使用 names 指定列名
df = pd.read_csv('students.csv', header=None, names=['a', 'b', 'c'])
print(df)
a b c
0 age id name
1 20 100 John
2 21 101 Tom
3 19 102 Bill
# 指定 csv 文件第一列为行名
df = pd.read_csv('students.csv', header=0, index_col=0)
print(df)
>>>
id name
age
20 100 John
21 101 Tom
19 102 Bill
|
分块读取¶
read_csv() 的 chunksize 参数支持指定每次读取的行数,返回的是一个可迭代的对象 TextFileReader,这对于读取超大文件特别有用:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # 每次读取两行
tfr = pd.read_csv('students.csv', header=0, chunksize=2)
print(type(tfr).__name__)
>>>
TextFileReader
for chunk in tfr:
print('------------------')
print(chunk)
>>>
------------------
age id name
0 20 100 John
1 21 101 Tom
------------------
age id name
2 19 102 Bill
|
可以看到每次从 TextFileReader 迭代对象读取时都会带上列名。
保存数据¶
to_csv() 用于写出数据到文件,注意 index 参数指明是否写出行信息:
0 1 2 3 | studentdf = pd.DataFrame({'id': [100,101,102],
'name':['John', 'Tom', 'Bill'],
'age': [20, 21, 19]})
studentdf.to_csv('students.csv', index=False)
|
其他数据类型¶
以下列出 Pandas 支持的数据文件类型,以及读取和保存的方法:
数据格式类型 描述 读取方法 写出方法 text CSV read_csv to_csv text JSON read_json to_json text HTML read_html to_html text 本地粘贴板 read_clipboard to_clipboard binary Excel read_excel to_excel binary HDF5 read_hdf to_hdf binary Feather包 read_feather to_feather binary Parquet read_parquet to_parquet binary Msgpack read_msgpack to_msgpack binary Stata read_stata to_stata binary SAS read_sas binary Pickle read_pickle to_pickle SQL SQL read_sql to_sql SQL 谷歌BigQuery read_gbq to_gbq
查看行数据¶
head(n) 方法用于查看从头部开始的 n 行数据:
0 1 2 3 4 5 6 | df = pd.read_csv('students.csv', header=0)
print(df.head(2))
>>>
age id name
0 20 100 John
1 21 101 Tom
|
tail(n) 方法用于查看尾部的 n 行数据:
0 1 2 3 4 5 | print(df.tail(2))
>>>
age id name
1 21 101 Tom
2 19 102 Bill
|
当我们对一个很大的数据文件一无所知时,可以打开前几行观察数据的类型,列标签等。
层级索引¶
一级索引的 Series 看起来很像一维数组,且是单列数组。DataFrame 可以看做有两个索引的二维数组。
通过层级索引(hierarchical indexing,也被称为多级索引,multi-indexing)配合多个有不同等级(level)的一级索引一起使用,这样就可以将高维数组转换成类似一维 Series 和二维 DataFrame 对象的形式。
多级索引的 Series¶
多级索引的 Series,索引是一个二维数组,相当于多个索引决定一个值,类似于 DataFrame 的行索引和列索引:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ps = pd.Series([90, 80, 95, 91, 92, 88], index=[['John'] * 3 + ['Tom'] * 3,
['Maths', 'English', 'Chemistry'] * 2])
print(ps)
>>>
John Maths 90
English 80
Chemistry 95
Tom Maths 91
English 92
Chemistry 88
dtype: int64
print(ps.index)
>>>
MultiIndex(levels=[['John', 'Tom'], ['Chemistry', 'English', 'Maths']],
labels=[[0, 0, 0, 1, 1, 1], [2, 1, 0, 2, 1, 0]])
|
此时的索引类型为 MultiIndex。MultiIndex 里面的 levels 属性表示索引的等级,可以看到 John 和 Tom 处在第一级,各科课程名称为第二级。
labels 标签包含了各个索引等级对应的数据的整数索引。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # 使用一级索引查看数据
print(ps['Tom'])
>>>
Maths 91
English 92
Chemistry 88
dtype: int64
# 使用切片查看二级索引 Maths 数据
print(ps[:, 'Maths'])
>>>
John 90
Tom 91
dtype: int64
|
多级索引 Series 转 DataFrame¶
unstack() 方法可以快速将一个多级索引的 Series 转化为普通索引的 DataFrame:
0 1 2 3 4 5 6 7 8 9 10 11 | df = ps.unstack()
print(type(df).__name__)
>>>
DataFrame
print(df)
>>>
Chemistry English Maths
John 95 80 90
Tom 88 92 91
|
stack() 方法实现相反的转换:
0 1 2 3 4 5 6 7 8 9 | print(df.stack())
>>>
John Chemistry 95
English 80
Maths 90
Tom Chemistry 88
English 92
Maths 91
dtype: int64
|
增加索引层级¶
如果我们可以用含多级索引的一维 Series 数据表示二维数据,那么我们就可以用 Series 或 DataFrame 表示三维甚至更高维度的数据。 多级索引每增加一级,就表示数据增加一维, 利用这一特点就可以轻松表示任意维度的数据了。
假如上面示例中的学生成绩是 2012 年数据,我们要添加 2013 年的数据,只需要增加一个新的索引层级即可:
0 1 2 3 4 5 6 7 8 9 10 | newps = pd.DataFrame({'2012': ps, '2013': [98,87,93, 90,91,84]})
print(newps)
>>>
2012 2013
John Maths 90 98
English 80 87
Chemistry 95 93
Tom Maths 91 90
English 92 91
Chemistry 88 84
|
当然我们可以使用 stack() 转化为 Series 类型:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | print(newps.stack())
>>>
John Maths 2012 90
2013 98
English 2012 80
2013 87
Chemistry 2012 95
2013 93
Tom Maths 2012 91
2013 90
English 2012 92
2013 91
Chemistry 2012 88
2013 84
|
这一实现效果令人惊喜。求取各科平均成绩非常简单:
0 1 2 3 4 5 6 7 | # 求取两年各科平均成绩
average = (newps['2013'] + newps['2012']) / 2
print(average.unstack())
>>>
Chemistry English Maths
John 94.0 83.5 94.0
Tom 86.0 91.5 90.5
|
创建多级索引¶
有多种方式创建多级索引 MultiIndex 对象:
- MultiIndex.from_arrays 转换由 arrays 组成的 list 为 MultiIndex
- MultiIndex.from_tuples 转换元组为 MultiIndex
- MultiIndex.from_product 由迭代对象的笛卡尔积生成 MultiIndex
array 转多级索引¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # names 指明每个层级的名称
arrays = [[1, 1, 2, 2], ['red', 'blue', 'red', 'blue']]
pm = pd.MultiIndex.from_arrays(arrays, names=('number', 'color'))
print(pm)
>>>
MultiIndex(levels=[[1, 2], ['blue', 'red']],
labels=[[0, 0, 1, 1], [1, 0, 1, 0]],
names=['number', 'color'])
# 查看多级索引的属性
print(pm.levels)
print(pm.labels)
print(pm.names)
>>>
[[1, 2], ['blue', 'red']]
[[0, 0, 1, 1], [1, 0, 1, 0]]
['number', 'color']
|
元组转多级索引¶
0 1 2 3 4 5 6 7 | tuples = [(1, 'red'), (1, 'blue'),(2, 'red'), (2, 'blue')]
pm = pd.MultiIndex.from_tuples(tuples, names=('number', 'color'))
print(pm)
>>>
MultiIndex(levels=[[1, 2], ['blue', 'red']],
labels=[[0, 0, 1, 1], [1, 0, 1, 0]],
names=['number', 'color'])
|
笛卡尔积转多级索引¶
0 1 2 3 4 5 6 7 8 | numbers = [0, 1, 2]
colors = ['green', 'purple']
pm = pd.MultiIndex.from_product([numbers, colors], names=['number', 'color'])
print(pm)
>>>
MultiIndex(levels=[[0, 1, 2], ['green', 'purple']],
labels=[[0, 0, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
names=['number', 'color'])
|
使用笛卡尔积方式创建 MultiIndex 对象,层次是比较清晰的。
0 1 2 3 4 5 6 7 8 9 10 11 | # 增加模拟数据,查看层级和层级名称
pf = pd.DataFrame(np.arange(6), index=pm)
print(pf)
>>>
number color
0 green 0
purple 1
1 green 2
purple 3
2 green 4
purple 5
|
多级列索引¶
上面的示例均是创建多级行索引,当然也可以创建多级列索引。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | students = ['John', 'Tom']
subjects = ['Maths', 'English', 'Chemistry']
# 创建多级行索引
index = pd.MultiIndex.from_product([students, subjects], names=['student', 'subject'])
# 创建多级列索引
columns = pd.MultiIndex.from_product([['2012', '2013'], ['first_half', 'latter_half']],
names=['year', 'half'])
# 模拟成绩数据,一共是 6 行 4 列
pf = pd.DataFrame(99 - np.random.randint(20, size=(6, 4)), index=index, columns=columns)
print(pf)
>>>
year 2012 2013
half first_half latter_half first_half latter_half
student subject
John Maths 82 81 86 81
English 83 81 88 94
Chemistry 81 85 85 81
Tom Maths 97 80 95 82
English 89 99 94 92
Chemistry 97 92 92 84
|
有上例可以看出多级行列索引的创建非常简单。我们可以方便查看各级索引的数据:
0 1 2 3 4 5 6 | # 查询 2012 年上半年成绩数据
print(pf['2012','first_half'].unstack())
subject Chemistry English Maths
student
John 89 88 88
Tom 83 84 99
|
如果想获取包含多种标签的数据,需要通过对多个维度(姓名、科目等标签)的多次查询才能实现,这时使用多级行列索引进行查询会非常方便。
多级索引排序和切片¶
Series多级索引排序¶
和单级索引一样,多级索引顺序是按照声明顺序确定的,也即是无序的,如果按照索引字母顺序排序,将方便切片操作:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | students = ['Tom', 'John']
subjects = ['Maths', 'English', 'Chemistry']
index = pd.MultiIndex.from_product([students, subjects], names=['student', 'subject'])
ps = pd.Series(np.arange(6) + 90, index=index)
print(ps)
>>>
student subject
Tom Maths 90
English 91
Chemistry 92
John Maths 93
English 94
Chemistry 95
dtype: int32
|
可以看到,默认的索引顺序和声明中索引顺序相同,但是使用切片 [start:end:step] 操作时,start 要小于 end,否则返回空对象,如果是乱序的,我们每次切片时都要记住声明的标签顺序,且声明顺序一旦更改,切片相关的代码就要更新,如果对索引进行排序,就不会再出现这类问题:
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 默认使用 level=0 排序
ps = ps.sort_index()
print(ps)
>>>
student subject
John Chemistry 95
English 94
Maths 93
Tom Chemistry 92
English 91
Maths 90
dtype: int32
|
经过排序后,可以发现第一级索引和第二季索引都被更新了。 可以使用 level 参数指定优先进行排序的索引层:
0 1 2 3 4 5 6 7 8 9 10 11 | # 使用 subject 索引排序
print(ps.sort_index(level=1))
>>>
student subject
John Chemistry 95
Tom Chemistry 92
John English 94
Tom English 91
John Maths 93
Tom Maths 90
dtype: int32
|
逆序排序¶
sort_index() 方法的 ascending 参数可以指定升序或者降序排列,例如 ascending = False 将降序排列:
0 1 2 3 4 5 6 7 8 9 10 | print(ps.sort_index(ascending=False))
>>>
student subject
Tom Maths 90
English 91
Chemistry 92
John Maths 93
English 94
Chemistry 95
dtype: int32
|
Series多级索引访问¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | # 使用已排序数据
print(ps)
>>>
student subject
John Chemistry 95
English 94
Maths 93
Tom Chemistry 92
English 91
Maths 90
dtype: int32
# 直接通过访问属性方式访问
print(ps.Tom.Maths)
>>>
90
# 通过数组访问方式,这类似于 2 维的 DataFrame 访问方式
print(ps.loc['Tom', 'Maths'])
>>>
90
# 切片方式访问
print(ps.loc[:, 'Maths'])
>>>
student
John 93
Tom 90
dtype: int32
# 二级切片索引访问
print(ps.loc['Tom', 'Chemistry':'English'])
>>>
student subject
Tom Chemistry 92
English 91
dtype: int32
|
使用 loc 或者 iloc 属性进行多级索引操作,应该为所有层级指定索引,例如 ps.loc[:, :],而不是 ps.loc[:]。
排序直接修改¶
以上示例排序结果不对对象直接修改,如果需要结果直接作用在排序对象上,可以传入 inplace = True,此时无返回值。
0 1 2 3 4 5 6 7 8 9 10 11 12 | ps.sort_index(level=1, inplace=True)
print(ps)
>>>
student subject
John Chemistry 95
Tom Chemistry 92
John English 94
Tom English 91
John Maths 93
Tom Maths 90
dtype: int32
|
DataFrame 多级索引排序¶
与 Series 对象类似,DataFrame 同样支持多级索引的排序,唯一不同点在于它有行索引和列索引,可以接受 axis 参数:
- axis = 0,对行索引进行排序,Series 只有行索引,所以 axis 永远为 0.
- axis = 1,对列索引进行排序。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | students = ['Tom', 'John']
subjects = ['Maths', 'English', 'Chemistry']
columns = pd.MultiIndex.from_product([['2013', '2012'], ['first_half', 'latter_half']],
names=['year', 'half'])
# 使用固定的模拟成绩数据,以观察排序影响
pf = pd.DataFrame(75 + np.arange(24).reshape(6, 4), index=index, columns=columns)
print(pf)
>>>
year 2013 2012
half latter_half first_half latter_half first_half
student subject
Tom Maths 75 76 77 78
English 79 80 81 82
Chemistry 83 84 85 86
John Maths 87 88 89 90
English 91 92 93 94
Chemistry 95 96 97 98
|
我们使用上面的示例数据,为了查看排序效果,我们把所有索引标签的顺序都颠倒了。
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 行标签排序
print(pf.sort_index(axis=0))
>>>
year 2013 2012
half latter_half first_half latter_half first_half
student subject
John Chemistry 95 96 97 98
English 91 92 93 94
Maths 87 88 89 90
Tom Chemistry 83 84 85 86
English 79 80 81 82
Maths 75 76 77 78
|
行标签排序后,对列标签顺序无影响,同样列标签排序对行标签顺序无影响:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # 列标签排序
print(pf.sort_index(axis=1))
>>>
year 2012 2013
half first_half latter_half first_half latter_half
student subject
Tom Maths 78 77 76 75
English 82 81 80 79
Chemistry 86 85 84 83
John Maths 90 89 88 87
English 94 93 92 91
Chemistry 98 97 96 95
# 同时对行和列排序
sorted_pf = pf.sort_index(axis=0).sort_index(axis=1)
print(sorted_pf)
>>>
year 2012 2013
half first_half latter_half first_half latter_half
student subject
John Chemistry 98 97 96 95
English 94 93 92 91
Maths 90 89 88 87
Tom Chemistry 86 85 84 83
English 82 81 80 79
Maths 78 77 76 75
|
无论是行排序还是列排序,均对行或列的所有层级标签依次进行了排序,我们当然可以使用 level 指定优先排序的索引层:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # 优先使用 subject 排序
print(pf.sort_index(axis=0, level=1))
>>>
year 2013 2012
half latter_half first_half latter_half first_half
student subject
John Chemistry 95 96 97 98
Tom Chemistry 83 84 85 86
John English 91 92 93 94
Tom English 79 80 81 82
John Maths 87 88 89 90
Tom Maths 75 76 77 78
# # 优先使用半学期 half 排序
print(pf.sort_index(axis=1, level=1))
>>>
year 2012 2013 2012 2013
half first_half first_half latter_half latter_half
student subject
Tom Maths 78 76 77 75
English 82 80 81 79
Chemistry 86 84 85 83
John Maths 90 88 89 87
English 94 92 93 91
Chemistry 98 96 97 95
|
DataFrame多级索引访问¶
DataFrame 不支持属性访问方式。所以通常使用 loc 显式索引方式访问:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | # 使用已排序数据
pf.sort_index(inplace=True)
print(pf)
>>>
year 2013 2012
half latter_half first_half latter_half first_half
student subject
John Chemistry 95 96 97 98
English 91 92 93 94
Maths 87 88 89 90
Tom Chemistry 83 84 85 86
English 79 80 81 82
Maths 75 76 77 78
# 访问 Tom 的各科成绩
print(pf.loc['Tom', :])
>>>
year 2013 2012
half latter_half first_half latter_half first_half
subject
Chemistry 83 84 85 86
English 79 80 81 82
Maths 75 76 77 78
# 查看 Tom 的 2012 年各科成绩
print(pf.loc['Tom', :]['2012'])
>>>
half latter_half first_half
subject
Chemistry 85 86
English 81 82
Maths 77 78
# 查看 Tom 的 2012 年下半年各科成绩
# 等价于 print(pf.loc['Tom', '2012']['latter_half'])
print(pf.loc['Tom', :]['2012']['latter_half'])
>>>
subject
Chemistry 85
English 81
Maths 77
Name: latter_half, dtype: int32
|
注意体会 DataFrame 的数组访问方式,第一维索引的形式有几种:
- [‘Tom’, ‘2012’]:指定行索引和列索引
- [‘Tom’, ‘Maths’]:均指定行索引
- [‘Tom’, :]:均指定行索引
显然第一维索引无法指定的列索引的第二层索引,就要通过增加第二维索引来访问,例如 [‘Tom’, ‘2012’][‘latter_half’]。
索引调整和重置¶
我们可以通过对象的 index 和 columns 属性更新行或列标签。也可以调整索引的顺序。
reindex¶
reindex() 是 pandas 对象的一个重要方法,其作用是在当前对象基础上创建一个新索引的新对象。它通常和 set_index() 配合使用:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | students = ['Tom', 'John']
subjects = ['Maths', 'English', 'Chemistry']
index = pd.MultiIndex.from_product([students, subjects], names=['student', 'subject'])
ps = pd.Series(np.arange(6) + 90, index=index)
# 重制索引
flat_ps = ps.reset_index()
print(flat_ps)
>>>
student subject 0
0 Tom Maths 90
1 Tom English 91
2 Tom Chemistry 92
3 John Maths 93
4 John English 94
5 John Chemistry 95
# 逆向转换
print(flat_ps.set_index(['student','subject']))
>>>
student subject
Tom Maths 90
English 91
Chemistry 92
John Maths 93
English 94
Chemistry 95
|
可以传入 drop = True 丢弃所有索引,此时变为 Series 对象,只保留数据:
0 1 2 3 4 5 6 7 8 9 10 | flat_ps = ps.reset_index(drop = True)
print(flat_ps)
>>>
0 90
1 91
2 92
3 93
4 94
5 95
dtype: int32
|
多级索引数据统计¶
前面已经介绍过一些 Pandas 自带的数据累计方法,比如 mean()、sum() 和 max()。而对于层级索引数据,可以设置参数 level 实现对数据子集的累计操作。
首先我们准备如下用于统计的带有多级索引的数据:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # Series 类型的数据
student subject # 行标签名
Tom Maths 90
English 91
Chemistry 92
John Maths 93
English 94
Chemistry 95
dtype: int32
print(ps.index)
>>>
MultiIndex(levels=[['John', 'Tom'], ['Chemistry', 'English', 'Maths']],
labels=[[1, 1, 1, 0, 0, 0], [2, 1, 0, 2, 1, 0]],
names=['student', 'subject'])
|
可以使用 level 指定要统计的行标签名或者整数索引,来进行统计:
0 1 2 3 4 5 6 | print(ps.mean(level='student')) # 等价于 ps.mean(level=0)
>>>
student
John 94 # (93+94+95) / 3 = 94
Tom 91 # (90+91+92) / 3 = 91
dtype: int32
|
结合 axis 参数, 就可以对 DataFrame 列索引进行类似的统计操作:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | # 准备以下 DataFrame 数据
year 2013 2012
half latter_half first_half latter_half first_half
student subject
John Chemistry 95 96 97 98
English 91 92 93 94
Maths 87 88 89 90
Tom Chemistry 83 84 85 86
English 79 80 81 82
Maths 75 76 77 78
# 查看多级索引信息
print(pf.index)
>>>
MultiIndex(levels=[['John', 'Tom'], ['Chemistry', 'English', 'Maths']],
labels=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2]],
names=['student', 'subject'])
# 获取每个学生的平均成绩,axis=0 指定对行统计
print(pf.mean(level='student', axis=0))
>>>
year 2013 2012
half latter_half first_half latter_half first_half
student
John 91 92 93 94
Tom 79 80 81 82
# 获取年平均成绩,axis=1 指定对列统计
print(pf.mean(level='year', axis=1))
>>>
year 2012 2013
student subject
John Chemistry 97.5 95.5
English 93.5 91.5
Maths 89.5 87.5
Tom Chemistry 85.5 83.5
English 81.5 79.5
Maths 77.5 75.5
|
matplotlib¶
matplotlib 是 python 中一个非常强大的 2D 函数绘图模块,它提供了子模块 pyplot 和 pylab 。pylab 是对 pyplot 和 numpy 模块的封装,更适合在 IPython 交互式环境中使用。
经典参考:绘图: matplotlib核心剖析
对于一个项目来说,官方建议分别导入使用,这样代码更清晰,即:
0 1 | import numpy as np
import matplotlib.pyplot as plt
|
而不是
0 | import pylab as pl
|
基本绘图流程¶
这里以绘制正余弦函数图像为例。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # 分别导入 numpy 和 pyplot 模块
import numpy as np
import matplotlib.pyplot as plt
# 生成 X 坐标,256个采样值足够图像平滑
X = np.linspace(-np.pi, np.pi, 256, endpoint=True)
# 生成 Y 坐标
C,S = np.cos(X), np.sin(X)
# 绘制正余弦
plt.plot(X,S)
plt.plot(X,C)
# 显示图像
plt.show()
|
默认配置¶
matplotlib 的相关配置主要包括以下几种,用户可以自定义它们:
- 图片大小和分辨率(dpi)
- 线宽、颜色、风格
- 坐标轴、坐标轴以及网格的属性
- 文字与字体属性。
所有的默认属性均保存在 matplotlib.rcParams 字典中。
默认配置概览¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | X = np.linspace(-np.pi, np.pi, 256, endpoint=True)
C,S = np.cos(X), np.sin(X)
# 创建一个宽10,高8 英寸(inches,1inch = 2.54cm)的图,并设置分辨率为72 (每英寸像素点数)
plt.figure(figsize=(10, 8), dpi=72)
# 创建一个新的 1 * 1 的子图,接下来的图样绘制在其中的第 1 块(也是唯一的一块)
plt.subplot(1,1,1)
# 绘制正弦曲线,使用绿色的、连续的、宽度为 1 (像素)的线条
plt.plot(X, S, color="orange", linewidth=1.0, linestyle="-")
# 绘制余弦曲线,使用蓝色的、连续的、宽度为 1 (像素)的线条
plt.plot(X, C, color="blue", linewidth=1.0, linestyle="-")
# 设置 x轴的上下限
plt.xlim(-np.pi, np.pi)
# 设置 x轴记号
plt.xticks(np.linspace(-4, 4, 9, endpoint=True))
# 设置 y轴的上下限
plt.ylim(-1.0, 1.0)
# 设置 y轴记号
plt.yticks(np.linspace(-1, 1, 5, endpoint=True))
# 在屏幕上显示
plt.show()
|
我们可以依次改变上面的值,观察不同属性对图像的影响。
图像大小等¶
图像就是以「Figure #」为标题的那些窗口。图像编号从 1 开始,与 MATLAB 的风格一致,而于 Python 从 0 开始的索引编号不同。以下参数是图像的属性:
参数 默认值 描述 num 1 图像的数量 figsize figure.figsize 图像的长和宽(英寸) dpi figure.dpi 分辨率(像素/英寸) facecolor figure.facecolor 绘图区域的背景颜色 edgecolor figure.edgecolor 绘图区域边缘的颜色 frameon True 是否绘制图像边缘
0 1 2 3 4 5 6 7 8 9 10 11 | import matplotlib as mpl
figparams = ['figsize', 'dpi', 'facecolor', 'edgecolor']
for para in figparams:
name = 'figure.' + para
print(name + '\t:', mpl.rcParams[name])
>>>
figure.figsize : [10.0, 8.0]
figure.dpi : 72.0
figure.facecolor : white
figure.edgecolor : white
|
我们可以通过查询参数字典来获取默认值。除了图像 num 这个参数,其余的参数都很少修改,num 可以是一个字符串,此时它会显示在图像窗口上。
可以看到调整长宽英寸数和分辨率均会影响图片显示大小,以宽度为例,显示大小为 w * dpi / 显示屏幕宽度分辨率。
14 英寸显示屏是指屏幕对角线长度 35.56cm,如果屏幕宽高比为 16 : 9,则宽和高约为 31cm 和 17.4cm,如果分比率为 1920 * 1080,则上述图像显示宽度的 10 * 36 / 1920 * 31 = 5.8cm,或者 5 * 72 / 1920 * 31 = 5.8cm。
高 dpi 显示图像更细腻,但是图像尺寸也会变大。使用默认值即可。如果图像非常复杂,为了看清细节,我们可以调整宽高的英寸数。
绘图区域的背景色改为橙色的效果,通常不需要改变它。
线条的颜色¶
0 | plt.plot(X, S, color="orange", linewidth=1.0, linestyle="-")
|
上文中,已经观察到线条属性有如下几个:
颜色,color/c 参数指定。我们可以通过 help(plt.plot) 查看帮助信息,颜色属性可以通过如下方式指定:
- 颜色名,例如 ‘green’。
- 16进制的RGB值 ‘#008000’,或者元组类型 RGBA (0,1,0,1)。
- 灰度值,例如 ‘0.8’。
- 颜色缩写字符,例如 ‘r’ 表示 ‘red’
当前支持的颜色缩写有:
缩写字符 颜色 ‘b’ blue ‘g’ green ‘r’ red ‘c’ cyan ‘m’ magenta ‘y’ yellow ‘k’ black ‘w’ white
0 1 2 3 4 5 6 7 | plt.subplot(2,2,1)
plt.plot(X, S, color='orange', linewidth=1.0, linestyle="-")
plt.subplot(2,2,2)
plt.plot(X, S, color='b', linewidth=1.0, linestyle="-")
plt.subplot(2,2,3)
plt.plot(X, S, color='0.8', linewidth=1.0, linestyle="-")
plt.subplot(2,2,4)
plt.plot(X, S, color='#003333', linewidth=1.0, linestyle="-")
|
线条的粗细¶
线宽,linewidth/lw,浮点值,指定绘制线条宽度点数。
0 1 2 3 4 5 6 7 | plt.subplot(2,2,1)
plt.plot(X, S, color='blue', linewidth=0.5, linestyle="-")
plt.subplot(2,2,2)
plt.plot(X, S, color='blue', linewidth=1.0, linestyle="-")
plt.subplot(2,2,3)
plt.plot(X, S, color='blue', linewidth=1.5, linestyle="-")
plt.subplot(2,2,4)
plt.plot(X, S, color='blue', linewidth=2.0, linestyle="-")
|
线条的样式¶
线条样式, linestyle/ls 指定绘制线条的样式,当前支持的线条样式表如下:
样式缩写 描述 ‘-‘ 实线 ‘–’ 短划线 ‘-.’ 点划线 ‘:’ 虚线
0 1 2 3 | linestyles = ['-', '--', '-.', ':']
for i in range(1, 5, 1):
plt.subplot(2,2,i)
plt.plot(X, S, color='blue', linewidth=1.0, linestyle=linestyles[i-1])
|
线条的标记¶
标记,marker,可以使用标记代替 linestyle 画图。常用标记如下:
标记缩写 描述 ‘.’ point marker ‘,’ pixel marker ‘o’ circle marker ‘v’ triangle_down marker ‘^’ triangle_up marker ‘<’ triangle_left marker ‘>’ triangle_right marker ‘1’ tri_down marker ‘2’ tri_up marker ‘3’ tri_left marker ‘4’ tri_right marker ‘s’ square marker ‘p’ pentagon marker ‘*’ star marker ‘h’ hexagon1 marker ‘H’ hexagon2 marker ‘+’ plus marker ‘x’ x marker ‘D’ diamond marker ‘d’ thin_diamond marker ‘|’ vline marker ‘_’ hline marker
0 1 2 3 4 5 6 | # 降低X坐标数量,以观察标记的作用
X = np.linspace(-np.pi, np.pi, 56, endpoint=True)
......
markers = ['.', ',', 'o', 'v']
for i in range(1, 5, 1):
plt.subplot(2,2,i)
plt.plot(X, S, color='blue', linewidth=0.0, marker=markers[i-1])
|
图片边界¶
上述图像在 Y 轴上会和边界重合,我们可以调整轴的上下限来调整曲线在图像中的位置。
0 1 2 3 4 | # 设置 x轴的上下限
plt.xlim(-np.pi, np.pi)
# 设置 y轴的上下限
plt.ylim(-1.0, 1.0)
|
0 1 | # 扩展 y轴的上下限 10%
plt.ylim(-1.1, 1.1)
|
一个可重用的设置边界的扩展函数如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def scope_adjust(X, axis='X', scale=0.1):
xmin, xmax = X.min(), X.max()
dx = (xmax - xmin) * scale
if axis == 'X':
plt.xlim(xmin - dx, xmax + dx)
else:
plt.ylim(xmin - dx, xmax + dx)
# 扩展 x 轴边界 10%
def xscope_adjust(X):
scope_adjust(X, 'X')
# 扩展 y 轴边界 10%
def yscope_adjust(Y):
scope_adjust(Y, 'Y')
|
坐标记号标签¶
当讨论正弦和余弦函数的时候,通常希望知道函数在 ±π 和 ±π/2 的值。这样看来,当前的设置就不那么理想了。默认坐标记号总是位于整的分界点处,例如 1,2,3或者0.1,0.2处。
我们要在 x = π 处做记号,就要使用 xticks() 和 yticks() 函数:
0 1 2 3 4 | # 设置 x轴记号
plt.xticks([-np.pi, -np.pi/2, 0, np.pi/2, np.pi])
# 设置 y轴记号
plt.yticks([-1, 0, +1])
|
记号现在没问题了,不过标签却不大符合期望。我们可以把 3.142 当做是 π,但毕竟不够精确。当我们设置记号的时候,我们可以同时设置记号的标签。注意这里使用了 LaTeX 数学公式语法。
0 1 2 3 4 5 | # 设置 x轴记号和标签
plt.xticks([-np.pi, -np.pi/2, 0, np.pi/2, np.pi],
[r'$-\pi$', r'$-\pi/2$', r'$0$', r'$+\pi/2$', r'$+\pi$'])
# 设置 y轴记号和标签
plt.yticks([-1, 0, +1], [r'$-1$', r'$0$', r'$+1$'])
|
移动脊柱(坐标轴)¶
坐标轴线和上面的记号连在一起就形成了脊柱(Spines,一条线段上有一系列的凸起,很像脊柱骨),它记录了数据区域的范围。它们可以放在任意位置,不过至今为止,我们都把它放在图的四边。
实际上每幅图有四条脊柱(上下对应 x坐标轴,左右对应 y坐标轴),为了将脊柱放在图的中间,我们必须将其中的两条(上和左)设置为无色,然后调整剩下的两条到合适的位置,这里为坐标轴原点。
0 1 2 3 4 5 6 | ax = plt.gca()
ax.spines['left'].set_color('none')
ax.spines['top'].set_color('none')
ax.xaxis.set_ticks_position('bottom')
ax.spines['bottom'].set_position(('data', 0))
ax.yaxis.set_ticks_position('right')
ax.spines['right'].set_position(('data', 0))
|
添加图例¶
我们在图的左上角添加一个图例。为此,我们只需要在 plot 函数里以键值的形式增加一个参数。
0 1 2 | plt.plot(X, S, color='orange', linewidth=1.0, linestyle='-', label='sin(x)')
plt.plot(X, C, color='blue', linewidth=1.0, linestyle='-', label='cos(x)')
plt.legend(loc='upper left', fontsize='large')
|
特殊点做注释¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | t = 2 * np.pi / 3
# 两个坐标点,画一条竖线
plt.plot([t,t],[0,np.cos(t)], color ='blue', linewidth=1.5, linestyle="--")
# 在竖线一端画一个点,颜色 blue,30个像素宽
plt.scatter([t,],[np.cos(t),], 30, color ='blue')
# 在特定点添加注释
plt.annotate(r'$\sin(\frac{2\pi}{3})=\frac{\sqrt{3}}{2}$',
xy=(t,np.sin(t)), xycoords='data',
xytext=(+10, +30), textcoords='offset points', fontsize=16,
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=.2"))
plt.plot([t,t],[0,np.sin(t)], color ='orange', linewidth=1.5, linestyle="--")
plt.scatter([t,],[np.sin(t),], 30, color ='orange')
plt.annotate(r'$\cos(\frac{2\pi}{3})=-\frac{1}{2}$',
xy=(t, np.cos(t)), xycoords='data',
xytext=(-90, -50), textcoords='offset points', fontsize=16,
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=.2"))
|
各类参数的表示¶
尺寸¶
为了理解 matplotlib 中的尺寸先关参数,先介绍几个基本概念:
- inch,英寸,1英寸约等于 2.54cm,它是永恒不变的。
- point,点,缩写为 pt,常用于排版印刷领域。字体大小常称为“磅”,“磅”指的是 point 的音译发音,正确的中文译名应为“点”或“点数”,和重量单位没有任何关系。它是一种固定长度的度量单位,大小为1/72英寸,1 inch = 72 points。A4 纸宽度为 8.27 英寸,595 pt。
- pixel,像素,缩写为 px。像素有两个概念,图片中的像素,它是一个bits序列,比如bmp文件中一个8bits 的0-255的灰度值描述了一个像素点,没有物理大小。 另一个概念是指显示屏或者摄像机的像素,一个像素由RGB 3个显示单元组成,它的物理大小并不是一样的,它的尺寸不是一个绝对值。计算机显示屏可以调整屏幕分辨率,其实是通过算法转换的,比如用四个像素表示原一个像素,那么垂直和水平分辨率就各降低了一半。
- 分辨率/屏幕分辨率:横纵2个方向的像素(pixels)数量,常见取值 1024*768 ,1920*1080。在Windows中 一张基于存储像素值的图片(例如BMP,PNG,JPG等格式)的分辨率也可以这样表示。
- 图像分辨率:在图像处理领域,图像分辨率是指每英寸图像内的像素点数。它的单位是 PPI(像素每英寸,pixels per inch),图像分辨率参数通常用于照相机和摄影机等摄录设备,而不是图片本身,图片本身只有像素,而像素在1:1比例下查看,对应显示设备的1个像素。
- DPI(Dots Per Inch),打印分辨率,也称为打印精度,单位每英寸点数。也即每英寸打印的墨点数,普通喷墨打印机在 300-500 DPI,激光打印机可以达到 2000 DPI。
了解了这些概念,我们就可以理解几种常见情况了:
0.图片中dpi和图像分辨率
我们已经强调,图像分辨率参数通常用于照相机和摄影机等摄录设备,而不是图片本身。但是很多图片格式,例如 jpg 文件通过 windows 可以查看文件属性中有 96 dpi 字样,又是什么意思呢?
参考 图片DPI,图片中的 dpi 值保存在图片文件格式头部的某个字段,它仅仅是一个数值,用于被某些设备读取做图片处理的参考,例如打印机,在打印时每英寸打印多少个像素点。
JPG, PNG, TIF, BMP 和 ICO 均支持设置图片文件的 dpi 参数。该参数不影响图片的分辨率,分辨率与像素数量有关。
1.图片像素和屏幕显示大小
一张图片在屏幕上显示的大小是由图片像素数和屏幕尺寸以及屏幕分辨率共同决定。例如一张图片分辨率是640x480,这张图片在屏幕上默认按1:1显示,水平方向有640个像素点,垂直方向有480个像素点。
14英寸的16:9屏幕,也即显示屏对角线长度 35.56cm = 14 inch * 2.54cm/inch,屏幕宽高比为 16 : 9,根据勾股定理宽和高约为 31cm 和 17.4cm,如果分比率为 1920 * 1080,则图像显示宽度 640 / 1920 * 31 = 10.33cm,高度为 480 /1080 * 17.4 = 7.73cm。
如果分辨率是 1600*900,则显示的图片尺寸约为 640 / 1600 * 31 = 12.40cm 和 480 / 900 * 17.4 = 9.28cm。
0 1 2 3 4 5 6 7 | def scatter_create_test_graph():
plt.figure(figsize=(6.4, 4.8), dpi=100)
ax.set_ylim(0, 2)
ax.set_xlim(0, 2)
plt.xticks([0, 1, 2])
plt.yticks([0, 1, 2])
plt.scatter(1, 1)
plt.savefig(filename="test.jpg", format='jpg', facecolor='orange')
|
以上代码生成一张640*480的JPG图片,背景为橘黄色。
上图是一张640*480的JPG图片,为了避免网页对图片缩放,可以先保存它并用画图编辑器在**不缩放**的情况下查看它,根据电脑显示屏的分辨率来换算它的宽和高,然后对比用尺子在屏幕上测量的结果,大小是一定不会错的。
总结:1:1显示时,图片的像素点和屏幕的像素点是一一对应的,在同一台设备上,图片分辨率越高(图片像素越多),图片显示面积越大;图片分辨率越低,图片显示面积越小。对于同一张图片,屏幕分辨率越高,显示越小,屏幕分辨率越低,显示越大。对图片进行放大或者缩小显示时,计算机通过算法对图像进行了像素补足或者压缩。
图像是否清晰与图像分辨率有关。显示器是否能显示清晰的图片需同时考虑屏幕尺寸和分辨率大小,屏幕尺寸相同时,分辨率越高显示越清晰。
2.图片像素和打印
DPI(Dots Per Inch),打印分辨率用于描述打印精度,这里的 Dot 对于使用计算机打印图片来讲就是 Pixel。也即用一个打印墨点打印一个图像像素。通常 300 DPI是照片打印的标准。
照片规格通常用“寸”表示,它是指照片长方向上的边长英寸数,一般四舍五入取整数表示。
照片规格 | 英寸表示 | 厘米 | 图片像素(最低) |
---|---|---|---|
5寸 | 5 * 3 | 12.7 * 8.9 | 1200 * 840 |
6寸 | 6 * 4 | 15.2 * 10.2 | 1440 * 960 |
7寸 | 7 * 5 | 17.8 * 12.7 | 1680 * 1200 |
8寸 | 8 * 6 | 20.3 * 15.2 | 1920 * 1440 |
10寸 | 10 * 8 | 25.4 * 20.3 | 2400 * 1920 |
12寸 | 12 * 10 | 30.5 * 20.3 | 2500 * 2000 |
15寸 | 15 * 10 | 38.1 * 25.4 | 3000 * 2000 |
图片像素的要求为何是最低呢?因为当图片过大时,打印驱动会帮我们压缩像素来适应打印机的DPI要求,但是如果图片像素不足于一个像素对应一个墨点,驱动就要进行像素插值,导致图片模糊。
3.matplotlib中的dpi,matplotlib 不是打印机,为何需要 DPI 参数?实际上在 matplotlib 中,figure 对象被当作一张打印纸,而 matplotlib 的绘图引擎(backend)就是打印机。
图片的数字化,也即将图片存储为数据有两种方案:
- 位图,也被称为光栅图。即是以自然的光学的眼光将图片看成在平面上密集排布的点的集合。每个点发出的光有独立的频率和强度,反映在视觉上,就是颜色和亮度。这些信息有不同的编码方案,最常见的就是RGB。根据需要,编码后的信息可以有不同的位(bit)数——位深。位数越高,颜色越清晰,对比度越高;占用的空间也越大。另一项决定位图的精细度的是其中点的数量。一个位图文件就是所有构成其的点的数据的集合,它的大小自然就等于点数乘以位深。位图格式是一个庞大的家族,包括常见的JPEG/JPG, GIF, TIFF, PNG, BMP。
- 矢量图。它记录其中展示的模式而不是各个点的原始数据。它将图片看成各个“对象”的组合,用曲线记录对象的轮廓,用某种颜色的模式描述对象内部的图案(如用梯度描述渐变色)。比如一张留影,被看成各个人物和背景中各种景物的组合。这种更高级的视角,正是人类看世界时在意识里的反映。矢量图格式有CGM, SVG, AI (Adobe Illustrator), CDR (CorelDRAW), PDF, SWF, VML等等。
matplotlib 支持将图像保存为 eps, jpeg, jpg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff 格式。如果要生成 jpg 文件就相当于“打印”一张图像到 figure 打印纸上。
matplotlib 在“打印”位图时需要 DPI 来指示如何把逻辑图形转换为像素。打印纸的大小由 figsize 参数指定,单位 pt(point),这与现实中的纸张单位一致,而 dpi 参数决定了在 1 inch (72pts) 要生成的像素数。
0 | plt.figure(figsize=(6.4, 4.8), dpi=100)
|
如果 dpi 为 72,那么一个 point 就对应 jpg 中的一个 pixel,如果 dpi 为 100,则一个 point 对应 jpg 中的 100/72 pixels。注意这里没有尺寸(位图图像无法用尺寸描述,只能用分辨率描述)的对应关系,只有个数的对应关系。
以下关系总是成立:
0 | 1 point == fig.dpi/72 pixels
|
matplotlib 在生成矢量图时总是使用72dpi,而忽略用户指定的dpi参数,矢量图中只保存宽和高,也即figsize参数,单位pt。
0 1 2 | <svg height="345pt" version="1.1" viewBox="0 0 460 345"
width="460pt" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
|
一张 figsize=(6.4, 4.8) 参数生成的 svg 图片文件中指定了宽 width = 6.4 * 72 = 460pt,高 height = 4.8 * 72 = 345pt。即便我们认为指定了 dpi = 100,生成的 svg 图片的宽高不会有任何改变。
dpi对生成位图的影响
我们知道 fig.dpi 参数对矢量图的大小没有影响,而对位图有影响。考虑如下两张图片:
图片的宽和高像素数是一致的,但是 dpi = 72 时图片明显清晰,所以 dpi 参数会影响图片中的字体大小和线条粗细,当 dpi 小时,系统会选择小字体和细线条,dpi 大时则相反。
point 和 pixel¶
由于以下关系总是成立,强烈建议将 fig.dpi 设置为 72,并保存为 svg 矢量格式,这会为处理一些关于尺寸的函数参数提供方便。此时计算时生成图片时这些参数就会直接对应(从屏幕上观察)到生成的图片上的元素的长宽或者字体大小上。
0 | 1 point == fig.dpi/72 pixels
|
这些参数包括 markersize,linewidth,markeredgewidth,scatter中的 s 参数和坐标系统相关参数,例如注释的相对坐标 textcoords。
这些参数的单位通常为 points。唯一例外的是 scatter() 函数中的 s 参数。
s 参数可以为一个标量或 array_like,shape(n,),指定绘制点的大小,默认值 rcParams [‘lines.markersize’]^2。注意这里的平方,所以 s 是指的标记所占面积的像素数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | plt.figure(figsize=(8,4), dpi=72)
plt.plot([0],[1], marker="o", markersize=30)
plt.plot([0.2, 1.8], [1, 1], linewidth=30)
plt.scatter([2],[1], s=30**2)
plt.annotate('plt.plot([0],[1], marker="o", markersize=30)',
xy=(0, 1), xycoords='data',
xytext=(0, 70), textcoords='offset points',fontsize=12,
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=.2"))
......
plt.rcParams['font.sans-serif']=['SimHei']
plt.rcParams['axes.unicode_minus'] = False # 解决保存图像时,负号'-'显示为方块问题
plt.annotate('ABC123abc 30号中文字体', xy=(0.2, 1), xycoords='data',
xytext=(-10,-10), textcoords='offset pixels', fontsize=30)
plt.savefig(filename="markersize.svg", format='svg')
|
由上图可以得到以下几点结论:
- scatter 中的 s 参数和 plot 中的 markersize 参数关系为,s = markersize^2,markersize = linewidth。
- s 是指的标记所占面积的像素数。所以可以开根号求出高度或者宽度的 point 值。
- markersize 和 linewidth 单位均是 points,当 dpi 设置为 72 时,它们的单位等同于 pixels。
- 可以看到字体大小 fontsize 单位是 points,和 markersize ,linewidth 是一致的。
- dpi 设置为 72 时,textcoords=’offset points’ 和 textcoords=’offset pixels’ 是等价的。
如果 dpi 设置超过 72,相对于生成的像素增多,图片显示出来会增大,否则显示会变小。
生成的图像分辨率就是 fig.dpi,Windows 中显示的分辨率为图像的宽和高,对应 dpi * figsize。
颜色¶
颜色参数通常为 color 或者 c,它们有几种形式,参考 线条的颜色。在不同的函数中,它们格式基本是通用的。
marker¶
标记,marker,可以使用 marker 标记坐标点。所有标记如下:
标记缩写 描述 ‘.’ point marker ‘,’ pixel marker ‘o’ circle marker ‘v’ triangle_down marker ‘^’ triangle_up marker ‘<’ triangle_left marker ‘>’ triangle_right marker ‘1’ tri_down marker ‘2’ tri_up marker ‘3’ tri_left marker ‘4’ tri_right marker ‘s’ square marker ‘p’ pentagon marker ‘*’ star marker ‘h’ hexagon1 marker ‘H’ hexagon2 marker ‘+’ plus marker ‘x’ x marker ‘D’ diamond marker ‘d’ thin_diamond marker ‘|’ vline marker ‘_’ hline marker
matplotlib.markers.MarkerStyle 类定义标记和标记的各种样式。可以看到 1-11 个数字也可作为标记,它们表示的图形中心不对应坐标点,而是图形的一个边对应坐标点。
0 1 2 3 4 5 6 | # print(mpl.markers.MarkerStyle().markers) # 所有支持的标记
print(mpl.markers.MarkerStyle().filled_markers) # 可填充的标记
print(mpl.markers.MarkerStyle().fillstyles) # 填充类型
>>>
('o', 'v', '^', '<', '>', '8', 's', 'p', '*', 'h', 'H', 'D', 'd', 'P', 'X')
('full', 'left', 'right', 'bottom', 'top', 'none')
|
matplotlib各类对象¶
在 Matplotlib 里面:
- figure(plt.Figure 类的一个实例)可以被看成是一个能够容纳各种坐标轴、图形、文字和标签的容器,好比作画的画布,或者一张打印纸。
- axes(plt.Axes 类的一个实例) 是一个带有刻度和标签的矩形,最终会包含所有可视化的图形元素。
通常会用变量 fig 表示一个图形实例,用变量 ax 表示一个坐标轴实例或一组坐标轴实例。创建好坐标轴之后, 就可以用 ax.plot 画图了。
0 1 2 3 4 5 6 | fig = plt.figure()
ax = plt.axes()
x = np.linspace(0, np.pi*4, 256)
ax.plot(x, np.sin(x));
plt.plot(x, np.cos(x));
plt.show()
|
也可以使用 plt.plot() 来作图,它对 ax.plot() 进行了封装。如果要在 figure 上创建多个图像元素,只要重复调用 plot 等画图命令即可。
坐标轴¶
关闭坐标轴标签:
0 1 | plt.xticks([]) # 关闭 x 轴标签
plt.yticks([]) # 关闭 y 轴标签
|
关闭坐标轴将同时关闭标签:
0 | plt.axis('off')
|
以下操作等价于关闭 x/y 轴标签:
0 1 2 | frame = plt.gca() # get current axis
frame.axes.get_yaxis().set_visible(False) # y 轴不可见
frame.axes.get_xaxis().set_visible(False) # x 轴不可见
|
注意,类似的这些操作需要将其置于 plt.show() 之前 plt.imshow() 之后。
设置坐标轴区间:
0 1 | plt.xlim(xmin, xmax) #设置坐标轴的最大最小区间
plt.ylim(ymin, ymax)#设置坐标轴的最大最小区间
|
设置图形标签:
0 1 2 3 | plt.plot(x, np.sin(x))
plt.title("A Sine Curve") # 坐标轴标题
plt.xlabel("x") # x 轴标签
plt.ylabel("sin(x)") # y 轴标签
|
annotate注释¶
annotate() 注释可以将文本放于任意坐标位置。
matplotlib.pyplot.annotate(s, xy, *args, **kwargs)
s,要注释的文本字符串
xy,(float, float) 要注释的坐标
xycoords,指定 xy 坐标系统,默认 data。
xytext,(float, float),注释要放置的坐标,如果不提供则使用 xy。textcoords 参数指定 xytext 如何使用。
textcoords,指定 xytext 坐标与 xy 之间的关系。如果不提供,则使用 xycoords。
ha /horizontalalignment,水平对齐,和点 xy 的水平对齐关系。取值 ‘center’, ‘right’ 或 ‘left’。
va /verticalalignment,垂直对齐,和点 xy 的垂直对齐关系。取值 ‘center’, ‘top’, ‘bottom’, ‘baseline’ 或 ‘center_baseline’。
**kwargs 参数可以是 matplotlib.text.Text 中的任意属性,例如 color。
xycoords 值 坐标系统 ‘figure points’ 距离图形左下角点数 ‘figure pixels’ 距离图形左下角像素数 ‘figure fraction’ 0,0 是图形左下角,1,1 是右上角 ‘axes points’ 距离轴域左下角的点数量 ‘axes pixels’ 距离轴域左下角的像素数量 ‘axes fraction’ 0,0 是轴域左下角,1,1 是右上角 ‘data’ 使用轴域数据坐标系 ‘polar’ 极坐标 textcoords 取值 描述 ‘offset points’ 相对于 xy 进行值偏移(inch) ‘offset pixels’ 相对于 xy 进行像素偏移
注释位置¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | def annotate():
fig = plt.figure(dpi=72, facecolor='#dddddd')
ax = fig.add_subplot(111, autoscale_on=False, xlim=(-1, 5), ylim=(-3, 5))
plt.rcParams['font.sans-serif']=['SimHei']
t = np.arange(0.0, 5.0, 0.01)
s = np.cos(2 * np.pi * t)
line, = ax.plot(t, s)
# 相对于图像最左下角的偏移像素数,未提供xytext,则表示注释在xy点
ax.annotate('1.figure pixels',
xy=(0, 0), xycoords='figure pixels', color='r', fontsize=16)
# 相对于图像最左下角的偏移点数,由于 dpi=72,这里与'figure pixels' 效果相同
ax.annotate('2.figure points',
xy=(0, 50), xycoords='figure points', color='r', fontsize=16)
# 使用轴域数据坐标系,也即 2,1 相对于坐标原点 (0,0),注释位置再相对于xy 偏移 xytext
ax.annotate('3.data',
xy=(2, 1), xycoords='data',
xytext=(-15, 25), textcoords='offset points',
arrowprops=dict(facecolor='black', shrink=0.05),
horizontalalignment='right', verticalalignment='top',
color='r')
# 整个图像的左下角为 0,0,右上角为1,1,xy 在[0-1] 之间取值
ax.annotate('4.figure fraction',
xy=(0.0, .95), xycoords='figure fraction',
horizontalalignment='left', verticalalignment='top',
fontsize=16, color='r')
# 0,0 是轴域左下角,1,1 是轴域右上角
ax.annotate('5.axes fraction',
xy=(3, 1), xycoords='data',
xytext=(0.8, 0.95), textcoords='axes fraction',
arrowprops=dict(facecolor='black', shrink=0.05),
horizontalalignment='right', verticalalignment='top',
color='r')
# xy被注释点使用轴域偏移 'axes fraction', xytext使用相对偏移
ax.annotate('6.pixel offset from axes fraction',
xy=(1, 0), xycoords='axes fraction',
xytext=(-20, 20), textcoords='offset pixels',
horizontalalignment='right',
verticalalignment='bottom', color='r')
plt.show()
|
对于上图,有几点需要说明:
- matplotlib 中有两个区域,图形区域(整个图形区域,包括灰色和白色两部分);轴域,上图中的白色部分。
- 每个区域有自己的坐标系统,左下角均为 (0, 0),可以使用点或者像素偏移,或者指定 fraction 坐标,此时右上角坐标值为 (1,1),整个区域的坐标用[0-1]之间的小数表示。
- xycoords 值中 ‘figure points’ 和 ‘figure pixels’ 相对于图形区域左下角偏移点和像素数。
- xycoords 值中 ‘figure fraction’ 直接指定图形区域的 fraction 小数坐标 。
- xycoords 值中 ‘axes points’,’axes pixels’ 和 ‘axes fraction’ 类似。
- xycoords 值中 ‘data’ 指定使用轴域数据坐标系。
坐标点注释¶
0 1 2 3 4 5 6 7 8 9 | def scatter_create_annotate_graph():
x = np.array([i for i in range(10)])
y = [0,1,2,3,4,4,3,2,1,0]
plt.figure(figsize=(10,10))
plt.scatter(x, y, marker='s', s = 50)
for x, y in zip(x, y):
plt.annotate('(%s,%s)'%(x,y), xy=(x,y), xytext=(0, -5),
textcoords = 'offset pixels', ha='left', va='top')
plt.show()
|
添加箭头¶
可以通过参数 arrowprops 在注释文本和注释点之间添加箭头。
arrowprops属性 | 描述 |
---|---|
width | 箭头的宽度,以点为单位 |
frac | 箭头的头部所占据的比例 |
headwidth | 箭头的头部宽度,以点为单位 |
shrink | 收缩箭头头部和尾部,使其离注释点和注释文本多一些距离 |
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def annotate_arrow():
plt.figure(dpi=72)
plt.xticks([0, 1, 2, 3], ['width','headwidth','shrink',''], fontsize=16)
plt.yticks([0, 1, 1.4], ['']*3)
ax = plt.gca()
ax.spines['left'].set_color('none')
ax.spines['top'].set_color('none')
ax.spines['bottom'].set_color('none')
ax.spines['right'].set_color('none')
# 调整箭头的宽度
for i in [1, 2, 4, 6, 8, 10]:
plt.annotate('annotate' + str(i), xy=(0, i/8), xycoords='data',
arrowprops=dict(facecolor='black', shrink=0.0, width=i, headwidth=20),
xytext=(50, i/8), textcoords='offset pixels', fontsize=16)
# 调整箭头的箭头宽度
for i in [1, 2, 4, 6, 8, 10]:
plt.annotate('annotate' + str(i), xy=(1, i/8), xycoords='data',
arrowprops=dict(facecolor='r', edgecolor='r', shrink=0.0,
width=3, headwidth=i*2),
xytext=(50, i/8), textcoords='offset pixels', fontsize=16)
# 调整箭头的收缩比
for i in [1, 2, 4, 6, 8, 10]:
plt.annotate('annotate' + str(i), xy=(2, i/8), xycoords='data',
arrowprops=dict(facecolor='m', edgecolor='m', shrink=0.01 * i,
width=3, headwidth=20),
xytext=(50, i/8), textcoords='offset pixels', fontsize=16)
plt.show()
|
绘图风格¶
可以通过 plt.style 设置绘图风格,它们存放在 plt.style.available 列表中。
0 1 2 3 4 5 | print(mpl.__version__)
print(plt.style.available[:5])
>>>
2.0.2
['bmh', 'classic', 'dark_background', 'fivethirtyeight', 'ggplot']
|
在 matplotlib 2.0.2 版本上支持 23 中不同的绘图风格。
如果要恢复默认的绘图风格,请使用 mpl.rcParams.update(mpl.rcParamsDefault)。
0 1 2 3 4 5 6 7 8 9 10 | #plt.style.use('classic') # 定义全局绘图风格
plt.figure(figsize=(16,25), dpi=72)
index = 1
for style in plt.style.available:
with plt.style.context(style): # 使用绘图风格上下文
plt.subplot(6,4,index)
plt.plot([1,2])
plt.scatter(1,2)
plt.title('Style{}:'.format(index) + style)
index+=1
plt.show()
|
如果使用 plt.style.use(style) 则作用到全局,使用绘图风格上下文管理器(context manager) plt.style.context(style) 临时切换绘图风格。
一些知名的常用绘图风格:
- classic,matplotlib 仿照 matlab 的经典风格。
- FiveThirtyEight 风格模仿著名网站 FiveThirtyEight(http://fivethirtyeight.com) 的绘图风格。
- ggplot风格,R 语言的 ggplot 是非常流行的可视化工具。
- bmh风格,源于在线图书 Probabilistic Programming and Bayesian Methods for Hackers(http://bit.ly/2fDJsKC)。整本书的图形都是用 Matplotlib 创建的, 通过一组 rc 参数创建了一种引人注目的绘图风格,它被 bmh 风格继承了。
- dark_background 风格:用黑色背景而非白色背景往往会取得更好的效果。它就是为此设计的。
- grayscale 灰度风格:有时可能会做一些需要打印的图形,不能使用彩色。 这时使用它效果最好。
- Seaborn 系列风格,灵感来自 Seaborn 程序库,Seaborn 程序对 Matplotlib 进行了高层的API封装,从而使得作图更加容易。seaborn-whitegrid 带网格显示。
带网格作图¶
0 1 2 3 | plt.style.use('seaborn-whitegrid')
fig = plt.figure()
ax = plt.axes() # 绘制坐标轴
plt.show()
|
seaborn-whitegrid 风格常用来绘制带网格的图。
绘制散点图¶
plot¶
plt.plot 通常用来绘制线形图,但是它同样可以绘制散点图。
0 1 2 3 4 5 | fig = plt.figure(figsize=(6,4))
x = np.linspace(0, 10, 30)
y = np.sin(x)
# 等价于 plt.plot(x, y, mark='o', color='blue')
plt.plot(x, y, 'ob')
|
这里把 linestyle 参数改为 mark,参考 marker。当然我们依然可以指定线型,这样可以绘制线条和散点的组合图:
0 1 | # 把散点用线条连接
plt.plot(x, y, '-ob')
|
plt.plot 支持许多设置线条和散点属性的参数:
0 1 2 3 4 | plt.plot(x, y, '-H', color='gray', # 线条颜色
markersize=15, linewidth=4, # 标记大小,线宽
markerfacecolor='white', # 标记填充色
markeredgecolor='gray', # 标记边框色
markeredgewidth=2) # 标记边框宽度
|
scatter¶
plt.scatter 与 plt.plot 的主要差别在于, 前者在创建散点图时具有更高的灵活性, 可以单独控制每个散点与数据匹配, 也可以让每个散点具有不同的属性(大小、 表面颜色、 边框颜色等) 。
scatter(x, y, s=None, c=None, marker=None, cmap=None, norm=None, vmin=None, vmax=None,
alpha=None, linewidths=None, verts=None, edgecolors=None,
hold=None, data=None, **kwargs)
scatter() 专门用于绘制散点图,提供默认值的参数可选,各个参数意义如下:
- x, y:array 类型,shape(n,),输入的坐标点。
- s :标量或 array_like,shape(n,),指定绘制点的大小,默认值 rcParams [‘lines.markersize’]^2。
- c:可以为单个颜色,默认:’b’,可以是缩写颜色的字符串,比如 ‘rgb’,或者颜色序列 [‘c’, ‘#001122’, ‘b’],长度必须与坐标点 n 相同。
- marker:默认值:’o’,可以为标记的缩写,也可以是类 matplotlib.markers.MarkerStyle 的实例。参考 marker。
- linewidths:标记外边框的粗细,当个值或者序列。
- alpha:透明度,0 - 1.0 浮点值。
- edgecolors:标记外边框颜色,单个颜色,或者颜色序列。
0 1 2 3 4 5 6 7 8 9 10 | def scatter_create_color_graph():
x = [i for i in range(20)]
y = [i for i in range(20)]
plt.figure(figsize=(10, 8), dpi=72)
plt.xticks(x)
plt.yticks(y)
c = np.linspace(0, 0xffffff, 20, endpoint=False)
plt.scatter(x, y, c=c, s=200, marker='o')
plt.show()
|
0 1 2 3 4 5 6 7 8 9 10 | def scatter_create_markers_graph():
x = np.array([i for i in range(20)])
y = np.array([i for i in range(20)])
plt.figure(1)
plt.xticks(x)
plt.yticks(y)
plt.scatter(x, y, c='orange', s=200, marker='v')
plt.scatter(x + 1, y, c='gray', s=100, marker='^')
plt.show()
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | def scatter_create_size_graph():
x = np.array([i for i in range(10)])
y = np.array([0] * len(x))
plt.figure(1)
plt.ylim(-0.5, 1.5)
plt.yticks([0, 1])
plt.xticks(x)
sizes = [20 * (n + 1) ** 2 for n in range(len(x))]
plt.scatter(x, y, c='m', s=sizes)
sizes = [20 * (10 - n) ** 2 for n in range(len(x))]
plt.scatter(x, y + 1, c='m', s=sizes)
plt.show()
|
0 1 2 3 4 5 6 | def scatter_create_random_graph():
x = np.random.randn(100)
y = np.random.randn(100)
plt.figure(1)
plt.scatter(x, y, c='m', marker='p', s=500, alpha=0.6)
plt.show()
|
0 1 2 3 4 5 6 7 8 9 | def scatter_create_guess_graph():
mu_vec = np.array([0,0])
cov_mat = np.array([[1,0],[0,1]])
X = np.random.multivariate_normal(mu_vec, cov_mat, 1000)
R = X ** 2
R_sum = R.sum(axis = 1)
plt.figure(1)
plt.scatter(X[:,0], X[:,1], color='m', marker='o',
s = 32.*R_sum, edgecolor='black', alpha=0.5)
plt.show()
|
0 1 2 3 4 5 6 7 8 9 10 | def scatter_create_gradual_graph():
plt.figure(1)
c = np.linspace(0xffff00, 0xffffff, 20, endpoint=False)
for i in range(19,-1,-1):
size = i * 10000 + 10
cval = hex(int(c[i]))[2:]
color = "#" + '0' * (6 - len(cval)) + cval
plt.scatter(0, 0, s=size, c=color)
plt.show()
|
由于 plt.scatter 会对每个散点进行单独的大小与颜色的渲染, 因此渲染器会消耗更多的资源。 而在 plt.plot 中, 散点基本都彼此复制,因此整个数据集中所有点的颜色、 尺寸只需要配置一次。当绘制非常多的点时优先选用 plt.plot。
条形图¶
条形图又称为柱状图,是一种直观描述数据量大小的图。
垂直条形图¶
plt.bar 用于画条形图,有以下参数:
- x: 条形图 x 轴坐标,y:条形图的高度
- width:条形图的宽度 默认是0.8
- bottom:条形底部的 y 坐标值 默认是0
- align:center 或 edge,条形图对齐 x 轴坐标中心点还是对齐 x 轴坐标左边缘作图。
0 1 2 3 4 5 6 7 8 | # 条形图宽 0.1,填充色 grey
plt.bar([1], [2], width=0.1, facecolor='grey')
# 条形图宽 0.2,填充色 white,边框颜色 black
plt.bar([2], [3], width=0.2, facecolor='w', edgecolor='black')
# 左对齐
plt.bar([3], [3], width=0.2, align='edge', facecolor='y')
# 画多个条形图,底部抬升 1
plt.bar([4,5], [2,2], bottom=1, width=0.2, facecolor='m')
plt.show()
|
我们可以为条形图添加标签和文本说明:
0 1 2 3 4 5 6 7 8 9 10 11 | name_list = ['John','Lily','Bill','Tom']
score_list = [80, 90, 78, 95]
# tick_label 参数指定标签列表
bars = plt.bar([1,2,3,4], score_list, color='grey', width=0.4, tick_label=name_list)
# plt.text 在指定坐标添加文本,居中标注
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width() / 2, height, str(int(height)),
ha="center", va="bottom")
plt.show()
|
堆叠条形图¶
堆叠的关键操作在 bottom 参数,堆叠在 bottom 之上:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | name_list = ['John','Lily','Bill','Tom']
lang_scores = [80, 90, 78, 95]
math_scores = [92, 88, 90, 93]
x = np.arange(1,5,1)
lang_bars = plt.bar(x, lang_scores, color='y', width=0.4, tick_label=name_list,
label='Language')
math_bars = plt.bar(x, math_scores, bottom=lang_scores, width=0.4,
label='Mathmatics', tick_label = name_list)
for i,j in zip(lang_bars, math_bars):
height = i.get_height() + j.get_height()
plt.text(i.get_x() + i.get_width() / 2, height, str(int(height)),
ha="center", va="bottom")
plt.ylim(0, 220)
plt.legend(loc='upper left')
plt.show()
|
并列条形图¶
并列条形图的关键在于调整第二个条形图的 x 坐标,它等于第一个条形图的坐标加上它的宽度的1/2,再加上自身的宽度的1/2,如果对齐为 edge,则要对应调整坐标:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | lang_bars = plt.bar(x, lang_scores, color='y', width=0.4, tick_label=name_list,
label='Language')
# 调整 x 坐标,为第一个条形图的偏移
math_bars = plt.bar([i + 0.4 for i in x], math_scores, width=0.4,
label='Mathmatics', tick_label = name_list)
for i,j in zip(lang_bars, math_bars):
plt.text(i.get_x() + i.get_width() / 2, i.get_height(), str(int(i.get_height())),
ha="center", va="bottom")
plt.text(j.get_x() + j.get_width() / 2, j.get_height(), str(int(j.get_height())),
ha="center", va="bottom")
plt.ylim(0, 120)
plt.legend(loc='upper left')
plt.show()
|
水平条形图¶
水平条形图使用 plt.barh 作图,其他参数类似,注意文本标注坐标的调整:
0 1 2 3 4 5 6 7 8 9 10 11 | name_list = ['John','Lily','Bill','Tom']
score_list = [80, 90, 78, 95]
# tick_label 参数指定标签列表
bars = plt.barh([1,2,3,4], score_list, color='grey', height=0.4, tick_label=name_list)
# plt.text 在指定坐标添加文本,居中标注
for bar in bars:
height = bar.get_height()
plt.text(bar.get_width(), bar.get_y() + height / 2, str(int(bar.get_width())),
ha="left", va="center")
plt.show()
|
饼图¶
饼图英文学名为 Sector Graph,又名 Pie Graph。常用于统计学。plt.pie 用于绘制饼图。
0 1 2 3 4 5 6 7 8 9 10 | plt.figure()
plt.subplot(2,2,1)
sizes = [1,2]
plt.pie(sizes)
plt.subplot(2,2,2)
plt.axis('equal') #使饼图长宽相等
sizes = [1,1,1]
plt.pie(sizes)
plt.show()
|
观察上图,可以看到 plt.pid 如何使用参数 sizes 的,它把个元素相加求出总和,然后各部分除以总和求出占比,然后按比例切分一个圆(Pie),为了使上面的饼图有意义,我们增加标签说明。
0 1 2 3 4 5 6 7 | labels = ['English', 'Maths', 'Chemistry']
scores = [90, 75, 88]
explode = (0, 0, 0.1)
plt.pie(scores, explode=explode, labels=labels,
autopct='%1.1f%%', shadow=True, startangle=60)
plt.axis('equal')
plt.legend(loc="upper right")
plt.show()
|
一个详细的参数列表如下:
- x :(每一块)的比例,如果sum(x) > 1会使用sum(x)归一化;
- labels :(每一块)饼图外侧显示的说明文字;
- explode :(每一块)离开中心距离;
- startangle :起始绘制角度,默认图是从x轴正方向逆时针画起,如设定=90则从y轴正方向画起;
- shadow : 在饼图下面画一个阴影。默认值:False,即不画阴影;
- labeldistance :label标记的绘制位置,相对于半径的比例,默认值为1.1, 如<1则绘制在饼图内侧;
- autopct :控制饼图内百分比设置,可以使用format字符串,’%1.1f’ 指小数点前后位数(没有用空格补齐);
- pctdistance :类似于labeldistance,指定autopct的位置刻度,默认值为0.6;
- radius :控制饼图半径,默认值为1;
- counterclock :指定指针方向;布尔值,可选参数,默认为:True,即逆时针。将值改为False即可改为顺时针。
- wedgeprops :字典类型,可选参数,默认值:None。参数字典传递给wedge对象用来画一个饼图。例如:wedgeprops={‘linewidth’:3}设置wedge线宽为3。
- textprops :设置标签(labels)和比例文字的格式;字典类型,可选参数,默认值为:None。传递给text对象的字典参数。
- center :浮点类型的列表,可选参数,默认值:(0,0)。图标中心位置。
- frame :布尔类型,可选参数,默认值:False。如果是true,绘制带有表的轴框架。
- rotatelabels :布尔类型,可选参数,默认为:False。如果为True,旋转每个label到指定的角度。
- colors : 自定义颜色表,例如 [‘r’,’g’,’y’,’b’]。
直方图¶
直方图常用于显示数据的区间分布密度,统计概率等。又称为频率直方图。
频率分布直方图中的横轴表示样本的取值,分为若干组距,纵轴表示频率/组距,所谓频率即落在组距上的样本数。
一维频率直方图¶
plt.hist 被用来画频次直方图:
0 1 2 | plt.style.use('seaborn-white')
data = np.random.randn(500)
plt.hist(data, color='gray')
|
hist() 有许多用来调整计算过程和显示效果的选项,例如 histtype 类型对比:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | plt.figure(figsize=(8,4))
plt.subplot(1,2,1)
plt.title('step')
# 因为 step 默认不填充,所以 edgecolor 必须存在
plt.hist(data, bins=50, normed=True, alpha=1,
histtype='step', color='grey')
plt.subplot(1,2,2)
plt.title('stepfilled')
plt.hist(data, bins=50, normed=True, alpha=1,
histtype='stepfilled', color='grey',
edgecolor='none')
|
stepfilled 与透明性参数 alpha 搭配使用的效果非常好:
0 1 2 3 4 5 6 7 8 9 | plt.figure(figsize=(8,4))
x1 = np.random.normal(0, 2, 1000)
x2 = np.random.normal(-2, 1, 1000)
x3 = np.random.normal(2, 2, 1000)
kwargs = dict(histtype='stepfilled', alpha=0.5, normed=True, bins=40)
plt.hist(x1, **kwargs)
plt.hist(x2, **kwargs)
plt.hist(x3, **kwargs)
|
np.histogram() 计算每段区间的样本数:
0 1 2 3 4 5 6 | counts, bin_edges = np.histogram([1,2,3,4,5], bins=5)
print(counts)
print(bin_edges)
>>>
[1 1 1 1 1]
[ 1. 1.8 2.6 3.4 4.2 5. ]
|
二维频率直方图¶
我们先看一个简单示例,来理解二维频率直方图的绘图步骤。
0 1 2 | plt.hist2d([0,1,1,2],[0,2,2,1.5], bins=2, cmap='Blues')
cb = plt.colorbar()
cb.set_label('counts in bin')
|
示例中给定了 4 个坐标,x 坐标范围为 [0-2],y 坐标范围也是 [0-2],bins = 2,表示均分 x 和 y 坐标范围,形成四个区域,然后统计每个区域落入的坐标点数。显然右上方深蓝区域落入 3 个点,所以右方的频率标签最大为 3,同时左下角浅蓝对应频率标签 1 处的颜色。
用一个多元高斯分布(multivariate Gaussian distribution) 生成 x 轴与 y 轴的样本数据并画2D频率图:
0 1 2 3 4 5 6 7 8 | mean = [0, 0]
cov = [[1, 1], [1, 2]]
x, y = np.random.multivariate_normal(mean, cov, 1000).T
# 画点,用于对比直方图颜色深浅
plt.plot(x,y, 'o', color='blue', markersize=1, alpha=0.5)
plt.hist2d(x,y, bins=30, cmap='Blues')
cb = plt.colorbar()
cb.set_label('counts in bin')
|
通过对比点数的密集程度,可以看到点越密集的坐标处,直方图显示越深。
np.histogram2d 实现 2D 分布统计:
0 1 2 3 4 | counts, xedges, yedges = np.histogram2d(x, y, bins=30)
print(counts.shape)
>>>
(30, 30) # 所以 bins=30 将坐标划分成 30*30 个区域
|
六边形区间划分¶
二维频次直方图是由与坐标轴正交的方块分割而成的, 还有一种常用的方式是用正六边形分割。 Matplotlib 提供了 plt.hexbin 满足此类需求, 将二维数据集分割成蜂窝状。
0 1 2 | plt.plot(x,y, 'o', color='blue', markersize=1, alpha=0.5)
plt.hexbin(x, y, gridsize=30, cmap='Blues')
cb = plt.colorbar(label='count in bin')
|
plt.hexbin 同样也有很多有趣的配置选项,包括为每个数据点设置不同的权重,以及用任意 NumPy 累计函数改变每个六边形区间划分的结果(权重均值、 标准差等指标)。
等高线图¶
- plt.contour 画等高线图。
- plt.contourf 画带有填充色的等高线图(filled contour plot) 的色彩。
- plt.imshow 显示图形。
0 1 2 3 4 5 6 7 8 9 | def f(x, y):
return np.sin(x) ** 10 + np.cos(10 + y * x)
plt.style.use('seaborn-white')
x = np.linspace(0, 5, 50)
y = np.linspace(0, 5, 40)
X, Y = np.meshgrid(x, y)
Z = f(X, Y)
plt.contour(X, Y, Z, colors='black');
|
np.meshgrid 从一维数组构建二维网格数据。 生成 shape(x.shape, y.shape) 两个矩阵,一个用 x 填充行,一个用 y 填充列:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | x = np.array([0,1,2])
y = np.array([-2,-1])
xv,yv = np.meshgrid(x,y)
print(xv)
print(yv)
>>>
[[0 1 2]
[0 1 2]]
[[-2 -2 -2]
[-1 -1 -1]]
plt.plot(xv, yv, 'o', c='grey')
|
为了凸显图像的高度和深度,我们可以使用 cmap,并等分更多份的等高线:
0 1 | # 根据高度数据等分为 20 份,并使用 copper 颜色方案
plt.contour(X, Y, Z, 20, cmap='copper')
|
Matplotlib 有非常丰富的配色方案,可以使用 help(plt.cm) 查看它们。
可以通过 plt.contourf() 函数来填充等高线图(结尾有字母f,意味 fill),它的语法和 plt.contour() 一样。plt.colorbar() 命令自动创建一个表示图形各种颜色对应标签信息的颜色条。
0 1 2 | # 亮表示波峰,暗表示波谷,是一个鸟瞰图
plt.contourf(X, Y, Z, 20, cmap='copper')
plt.colorbar()
|
上面的图形是一个“梯度”的颜色填充等高线图,每一个梯度颜色相同。我们可以为梯度图添加等高线和标签:
0 1 2 3 4 5 6 7 8 | # hot 是另一个常用的配色方案,对比度更强烈
plt.contourf(X, Y, Z, 20, alpha=0.75, cmap='hot')
# 画等高线
contours = plt.contour(X, Y, Z, 5, colors='black', linewidth=0.5)
# inlins 表示等高线是否穿过数字标签
plt.clabel(contours, inline=True, fontsize=10)
plt.colorbar()
|
三维图¶
Matplotlib 原本只能画2D图,后来扩展了 mplot3d 工具箱,它用来画三维图。
0 | from mpl_toolkits import mplot3d
|
三维数据点与线¶
最基本的三维图是由 (x , y , z ) 三维坐标点构成的线图与散点图。 与前面介绍的普通二维图类似, 可以用 ax.plot3D 与 ax.scatter3D 函数来创建它们。 由于三维图函数的参数与前面二维图函数的参数基本相同。
下面来画一个三角螺旋线(trigonometric spiral),在线上随机布一些散点:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 生成3d坐标
ax = plt.axes(projection='3d')
# 三维线的数据
zline = np.linspace(0, 15, 1000)
xline = 2 * np.sin(zline)
yline = np.cos(zline)
ax.plot3D(xline, yline, zline, 'r')
plt.ylim(-2, 2)
# 三维散点的数据
zdata = 15 * np.random.random(100)
xdata = 2 * np.sin(zdata) + 0.1 * np.random.randn(100)
ydata = np.cos(zdata) + 0.1 * np.random.randn(100)
ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap='hot')
|
默认情况下,散点会自动改变透明度, 以在平面上呈现出立体感。
三维等高线图¶
mplot3d 也有用同样的输入数据创建三维晕渲(relief) 图的工具。 与二维 ax.contour 图形一样, ax.contour3D 要求所有数据都是二维网格数据的形式, 并且由函数计算 z 轴数值。
生成三维正弦函数的三维坐标点:
0 1 2 3 4 5 6 7 | def f(x, y):
return np.sin(np.sqrt(x ** 2 + y ** 2))
x = np.linspace(-6, 6, 30)
y = np.linspace(-6, 6, 30)
X, Y = np.meshgrid(x, y)
Z = f(X, Y)
|
默认的初始观察角度有时不是最优的, view_init 可以调整观察角度与方位角(azimuthal angle)。 第一个参数调整俯仰角(x-y 平面的旋转角度), 第二个参数是方位角(就是绕 z 轴顺时针旋转的度数)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def draw(ax, X, Y, Z):
ax.contour3D(X, Y, Z, 40, cmap='hot')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
fig = plt.figure(figsize=(10,8))
ax = fig.add_subplot(2, 2, 1, projection='3d')
draw(ax, X, Y, Z)
ax = fig.add_subplot(2, 2, 2, projection='3d')
draw(ax, X, Y, Z)
ax.view_init(60, 35)
ax = fig.add_subplot(2, 2, 3, projection='3d')
draw(ax, X, Y, Z)
ax.view_init(-90, 0)
ax = fig.add_subplot(2, 2, 4, projection='3d')
draw(ax, X, Y, Z)
ax.view_init(-180, 35)
|
线框图和曲面图¶
线框图¶
线框图使用多边形组合成曲面,使用 ax.plot_wireframe 绘制:
0 1 2 3 | fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_wireframe(X, Y, Z, color='black')
ax.set_title('wireframe')
|
可以通过 rstride (row stride)和 cstride (column stride)参数调整 y 轴 和 x 轴上的线的密集程度,默认值均为 1,只接受整数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | def wireframe_draw(ax, X, Y, Z, rstride=1, cstride=1):
ax.plot_wireframe(X, Y, Z,color='black',
rstride=rstride,
cstride=cstride)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
fig = plt.figure(figsize=(8,6))
ax = fig.add_subplot(2, 2, 1, projection='3d', title="rstride=5")
wireframe_draw(ax, X, Y, Z, rstride=5)
ax.view_init(90, 0) # 顶视图,查看行的线密度
ax = fig.add_subplot(2, 2, 2, projection='3d', title="cstride=5")
wireframe_draw(ax, X, Y, Z, cstride=5)
ax.view_init(90, 0) # 顶视图,查看列的线密度
ax = fig.add_subplot(2, 2, 3, projection='3d', title="cstride=5,rstride=5")
wireframe_draw(ax, X, Y, Z, rstride=5, cstride=5)
ax.view_init(90, 0)
ax = fig.add_subplot(2, 2, 4, projection='3d', title="cstride=5,rstride=5")
wireframe_draw(ax, X, Y, Z, rstride=5, cstride=5)
|
对线框图中的多边形使用配色方案进行颜色填充就成为了曲面图。
曲面图¶
使用 ax.plot_surface 绘制曲面图。
0 1 2 3 | fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap='viridis', edgecolor='none')
ax.set_title('surface')
|
plot_surface 同样支持调整 rstride 和 cstride。同时支持设置阴影。
0 1 2 3 4 5 | def surface_draw(ax, X, Y, Z, rstride=1, cstride=1):
ax.plot_surface(X, Y, Z, cmap='viridis', edgecolor='none',
rstride=rstride, cstride=cstride)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
|
极坐标曲面图¶
使用极坐标曲面图,可以产生切片的可视化效果:
0 1 2 3 4 5 6 7 8 | r = np.linspace(0, 6, 20)
theta = np.linspace(-0.9 * np.pi, 0.8 * np.pi, 40)
r, theta = np.meshgrid(r, theta)
X = r * np.sin(theta)
Y = r * np.cos(theta)
Z = f(X, Y)
ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap='viridis', edgecolor='none')
|
曲面三角剖分¶
有时均匀采样的网格数据显得太过严格且不太容易实现,这时可以使用三角剖分图形(triangulation-based plot)。
0 1 2 3 4 5 6 7 8 | def f(x, y):
return np.sin(x) * np.cos(y) * 2
theta = 2 * np.pi * np.random.random(1000)
r = 6 * np.random.random(1000)
x = np.ravel(r * np.sin(theta))
y = np.ravel(r * np.cos(theta))
z = f(x, y)
|
首先生成二维的随机点,然后得到三维数据,接着使用散点图观察大致形状,然后使用 plot_trisurf 绘图,plot_trisurf 使用三角形来构造表面并填充配色。
0 1 2 3 4 5 | fig = plt.figure(figsize=(10,4))
ax = fig.add_subplot(1, 2, 1, projection='3d', title='scatter')
ax.scatter(x, y, z, c=z, cmap='viridis', linewidth=0.5)
ax = fig.add_subplot(1, 2, 2, projection='3d', title='trisurf')
ax.plot_trisurf(x, y, z, cmap='viridis', edgecolor='none');
|
子图¶
已经接触过 subplot 函数来创建子图:在较大的图形(Figure)中同时放置一组较小的坐标轴。这些子图可可以是画中画(inset)、网格图(grid of plots),或者是其他更复 杂的布局形式。
axes 子图¶
axes 子图又称为画中画子图,可以直接在当前 Figure 上生成新的坐标轴,可任意指定位置和大小。
plt.axes¶
Figure 默认会生成一个坐标轴 axes,我们可以使用 plt.axes 手动在 Figure 中创建坐标。
plt.axes 函数默认创建一个标准的坐标轴,并填满整张图。它还有一个可选参数,由图形坐标系统的四个值构成:[bottom, left, width, height](底坐标、 左坐标、 宽 度、 高度),数值的取值范围是一个百分比的小数,左下角(原点)为 0,右上角为 1。
0 1 2 3 4 5 6 7 8 9 10 11 12 | fig = plt.figure(figsize=(6,6))
# print(plt.axes) 可以默认值[0.125, 0.125, 0.775, 0.755]
plt.axes() # 绘制默认坐标
# 在 Figure 原点绘制子坐标 1,高度和宽度分别为 20% 的 Figure 的高和宽
ax1 = plt.axes([0.0, 0.0, 0.2, 0.2])
ax1.plot([0,1], [0,1], c='r')
# 在 Figure 的 60% 处绘制子坐标 1,高度和宽度分别为 20% 的 Figure 的高和宽
ax2 = plt.axes([0.6, 0.6, 0.2, 0.2])
ax2.plot([0,1], [0,1], c='m')
plt.show()
|
本示例的目的在于指明子坐标的位置和默认坐标轴无关,它是相对于 Figure 的。
通过 fig 对象我们可以打印所有当前图像对象上的 axes 坐标对象 :
0 1 2 3 4 5 6 | for i in fig.axes:
print(i)
>>>
Axes(0.125,0.125;0.775x0.755)
Axes(0,0;0.2x0.2)
Axes(0.6,0.6;0.2x0.2)
|
Axes(0.125,0.125;0.775x0.755) 是默认坐标,其中原点为相对于 Figure 左下角 (0, 0) 向右平移画布宽度的 12.5%,向上平移画布宽度的 12.5% 作为默认坐标的原点,0.775x0.755 表示坐标轴大小,表示相对于 Figure 宽度的 77.5% 和高度的 77.5%。
add_axes¶
通过 fig 的方法 fig.add_axes() 也可以添加新坐标轴。 用这个命令创建两个竖直排列的坐标轴:
0 1 2 3 4 5 6 7 8 9 10 | fig = plt.figure(figsize=(6,6))
x = np.linspace(0, 10)
# 创建子图,原点右平移10%,上平移50%(等于 ax2 的原点上平移 0.1+0.4 高度)
ax1 = fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2))
ax1.plot(np.sin(x))
ax2 = fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2))
ax2.plot(np.cos(x));
plt.show()
|
可以看到两个紧挨着的坐标轴(上面的坐标轴没有刻度):上子图(起点 y 坐标为 0.5 位置)与下子图的 x 轴刻度是对应的(起点 y 坐标为 0.1, 高度为 0.4) 。
子图属性¶
- ax.set_title 为子坐标添加标题。
- ax.set_xlim 和 ax.set_xlim 为子坐标指定范围。
- ax.set_xlabel 和 ax.set_ylabel 设置坐标轴标题。
- ax.set_xticks 和 set_yticks 设置坐标轴的标签。
- ax.set_xticklabels 和 ax.set_yticklabels 设置标签文字。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | fig = plt.figure(figsize=(6,6))
plt.axes() # 创建默认坐标
# 创建子坐标
ax1 = plt.axes([0.5, 0.5, 0.2, 0.2])
ax1.plot([0,1], [0,1], c='r')
# 子图标题
ax1.set_title("sub axes", fontsize=16)
# 子图坐标轴的标题
ax1.set_xlabel("x", fontsize=16)
ax1.set_ylabel("y", fontsize=16)
# 设置 x,y 轴范围
ax1.set_xlim(-1,1)
ax1.set_ylim(-1,1)
# 设定 x,y 轴的标签
ax1.set_xticks(range(-1,2,1))
ax1.set_yticks(range(-1,2,1))
# 设定 x 轴的标签文字
ax1.set_xticklabels(list("abc"))
plt.show()
|
可以通过 ax.set 设置多个坐标属性,例如:
0 | ax.set(title='title', xlabel='x' ylabel='y')
|
网格子图¶
plt.subplot¶
最底层的方法是用 plt.subplot() 在一个网格中创建一个子图。这个命令有三个整型参数——将要创建的网格 子图行数、列数和索引值,索引值从 1 开始, 从左上角到右下角依次增大。
0 1 2 3 4 5 6 7 8 9 | fig = plt.figure(figsize=(9,6))
# 把 fig 划分成 2*3 的网格,并一次画图
for i in range(1, 7):
plt.subplot(2, 3, i)
# 文本放置在子图的中心位置
plt.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha='center')
plt.show()
|
plt.subplot 方法对应面向对象方法为 fig.add_subplot,参数一致。
子图间隔调整¶
plt.subplots_adjust 可以调整子图之间的间隔。
0 1 2 3 4 5 6 7 8 | fig = plt.figure(figsize=(9,6))
# 分别设置垂直间隔和水平间隔,数值以子图的高或宽为基准,按百分比生成间隔数据
fig.subplots_adjust(hspace=0.4, wspace=0.2)
for i in range(1, 7):
fig.add_subplot(2, 3, i) # 面向对象方式创建子图
plt.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha='center')
plt.show()
|
示例中垂直间隔为子图高度的 40%,水平间隔为子图高度的 20%。
plt.subplots¶
plt.subplots 与 plt.subplot 不同,它不是用来创建单个子图的,而是用一行代码创建多个子图,并返回一个包含子图的 NumPy 数组。 关键参数是行数与列数,以及可选参数 sharex 与 sharey, 通过它们可以设置不同子图之间的关联关系。
所谓关联关系,即它们可以使用相同的坐标等属性。
0 1 2 3 4 5 6 7 | fig, ax = plt.subplots(2, 3, sharex='col', sharey='row', figsize=(9,6))
print(type(fig).__name__, type(ax).__name__, sep='\n')
print(type(ax[0,0]).__name__)
>>>
Figure
ndarray # ax 是 NumPy 数组,存储了2*3 个的子坐标对象,索引为 [row, col]
AxesSubplot # ax 的每一个成员都是坐标对象
|
通过 NumPy 坐标轴数组来设置文本信息:
0 1 2 3 4 5 6 7 8 9 | for i in range(2):
for j in range(3):
ax[i, j].text(0.5, 0.5, str((i, j)), fontsize=18, ha='center')
# 通过索引引用子坐标对象绘图
ax[0,0].plot([0, 1], [0, 1])
ax[1,2].plot([0, 1], [1, 0])
ax[1,2].set_title("1,2", fontsize=16)
plt.show()
|
注意,plt.subplot() 子图索引从 1 开始,plt.subplots() 返回的 ax 数组索引从 0 开始。
不规则网格子图¶
以上 plt.subplot 和 plt.subplots 示例均自动为子图分配宽和高空间,如果要绘制不规则子图网格,plt.GridSpec() 是最好的工具。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | fig = plt.figure(figsize=(8,6))
# 创建 2 行 3 列网格对象
grid = plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)
# 通过类似 Python 切片的语法设置子图的位置和扩展尺寸
plt.subplot(grid[0, 0]) # 第一个子图占用 1 行 1 列空间
plt.subplot(grid[0, 1:])# 第二个子图占用 1 行 2 列空间
plt.subplot(grid[1, :2])# 第三个子图占用 1 行 2 列空间
plt.subplot(grid[1, 2]) # 第四个子图占用 1 行 1 列空间
# 在最后一个子图中绘制直线
plt.plot([0,1], [0,1])
plt.show()
|
参数2,3 就是创建每行五个,每列五个的网格,最后就是一个 2*3 的画布,相比于其他函数,使用网格布局的话可以更加灵活的控制占用多少空间。
这种灵活的网格排列方式用途十分广泛,可以实现多轴频次直方图(Multi-axes Histogram),seaborn 中封装了相关的 API。
多频次直方图的示例:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | # 创建一些正态分布数据
mean = [0, 0]
cov = [[1, 1], [1, 2]]
x, y = np.random.multivariate_normal(mean, cov, 2000).T
# 设置坐标轴和网格
fig = plt.figure(figsize=(8, 8))
grid = plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)
main_ax = fig.add_subplot(grid[:-1, :-1])
x_hist = fig.add_subplot(grid[-1, :-1], yticklabels=[], sharex=main_ax)
y_hist = fig.add_subplot(grid[:-1, -1], xticklabels=[], sharey=main_ax)
# 主坐标轴画散点图
main_ax.plot(x, y, 'ok', markersize=3, alpha=0.3)
# 次坐标轴画频次直方图
x_hist.hist(x, 40, histtype='stepfilled', orientation='vertical', color='gray')
x_hist.invert_yaxis()
y_hist.hist(y, 40, histtype='stepfilled', orientation='horizontal', color='gray')
plt.show()
|
scipy¶
请移步 cpython scipy 教程,感谢原作者和翻译者提供了这么好的资料。勿用笔者烦扰 ^>^。
sympy¶
SymPy 是 Python的一个数学符号计算(Symbolic computation)库。它目的在于成为一个富有特色的计算机代数系统。它保证自身的代码尽可能的简单,且易于理解,容易扩展。SymPy 完全由Python写成,不需要额外的库。
与基于数值数组的计算相比,符号计算是一种精确计算,例如求导函数,求不定积分,求极限、解方程、微分方程、级数展开、矩阵运算等等计算问题。
实际应用领域通常你可能用不到它,但是在进行基础算法研究时就离不开它了,相关文档:
- Sympy Official Documents : http://docs.sympy.org/latest/tutorial/index.html
- Sympy GitHub Repository : https://github.com/sympy/sympy
- 一个比较老的中文版教程 :https://www.xuebuyuan.com/1241064.html
mahotas¶
Mahotas 是计算机视觉和图像处理 Python 库。它包含大量图像处理算法,使用 C++ 实现,性能很高。完全基于 numpy 的数组作为它的数据类型,有一个非常 Pyhonic 的算法接口。
Mahotas 官方宣称提供超过 100 个算法函数,包含:
- watershed 分水岭。
- convex points calculations 凸点计算。
- hit & miss thinning 击中/击不中,细化算法。
- Zernike & Haralick, local binary patterns, and TAS features,泽尼克&Haralick,枸杞多糖,和TAS的功能。
- morphological processing,形态处理。
- Speeded-Up Robust Features (SURF) 加速的鲁棒特征(SURF)等。
- thresholding 阈值。
- convolution 卷积。
- Sobel edge detection Sobel 边缘检测。
- 多边形绘制
- 距离变换
- 特征计算
- 样条插值
- freeimage & imread 文件读写接口
这些算法有些在 opencv 中实现非常麻烦,可以实现功能互补,了解这些算法对现实应用有很大帮助。
区域标记¶
0 1 2 3 4 5 6 7 8 9 10 11 12 | import matplotlib.pyplot as plt # 使用 matploblib 显示图片
import mahotas as mh
import numpy as np
regions = np.zeros((8, 8), bool)
regions[:3,:3] = 1
regions[6:,6:] = 1
labeled, nr_objects = mh.label(regions)
# imshow 会将标记数据 labeled 映射到整个灰度空间 0-255
plt.imshow(labeled, interpolation='nearest', cmap='gray')
plt.show()
|
为了理解区域(Region)标记的工作原理,就要先理解连通区域,在图片上看来像素是连续的区域,在 x 和 y 方向均没有断点,那么这些像素就组成了一个连通区域。
首先创建一个布尔量(二值)8*8 全 0 数组,左上角 3*3 区域置为 1,右下角 2*2 区域置为 1,那么 regions 数组就成了这样:
0 1 2 3 4 5 6 7 8 9 10 | print(regions)
>>>
[[ True True True False False False False False]
[ True True True False False False False False]
[ True True True False False False False False]
[False False False False False False False False]
[False False False False False False False False]
[False False False False False False False False]
[False False False False False False True True]
[False False False False False False True True]]
|
根据连通区域的定义,左上角和右下角就成了两个区域。返回的 labeled 是标记后的数组,nr_objects 是连通区域的个数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | print(labeled)
print(labeled.dtype)
print(nr_objects)
>>>
[[1 1 1 0 0 0 0 0] # 第一个区域被标记为 1
[1 1 1 0 0 0 0 0]
[1 1 1 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 2 2] # 第二个区域被标记为 2
[0 0 0 0 0 0 2 2]]
int32 # 标记后的数组类型为 int32
2 # 一共 2 个 连通区域
|
可以看出标记从序号 1 开始,到 nr_objects - 1 结束。imshow 指定了 cmap=’gray’,显示时会把 0-2 映射到 0-255 灰度空间。
实际上在背景为 0 的数组上,连通区域的像素可以是任意非 0 值,它们均被标记为统一值。以下代码效果是一样的:
0 1 2 3 4 5 | regions = np.zeros((8,8), np.uint8)
regions[:3,:3] = 1
regions[6:,6:] = 2
labeled, nr_objects = mh.label(regions)
|
扩展连通区域¶
上面的定义提到,只要有一个像素间断就认为是不连通了,我们当然可以设置这个间断的距离:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | regions = np.zeros((8,8), np.uint8)
regions[:3,:3] = 1
regions[5,5] = 1
regions[6:,6:] = 2
plt.figure()
labeled, nr_objects = mh.label(regions)
plt.subplot(1,2,1)
imshow(labeled, interpolation='nearest') # 默认连通区域 1*1 分割
plt.subplot(1,2,2) # 设置连通区域 x>=2 y>=2 时才不连通
labeled1, nr_objects1 = mh.label(regions, np.ones((2,2), bool))
plt.imshow(labeled1, interpolation='nearest')
plt.show()
|
上图为了显示对比更强烈,没有指定灰度显示,此时会使用默认的彩色颜色方案显示图片。
如果一个对象在进行二值处理后出现不连续,那么就可以使用这种方法让区域连通。这在采用遮罩(masking)方式提取图片中物体时非常有用。
统计标记区域¶
labeled_size 方法可以统计每个区域的像素数,返回一个列表,第一个成员是背景所占的像素数,然后是第一个标记区域的像素数:
0 1 2 3 4 5 6 7 | labeled, nr_objects = mh.label(regions)
sizes = mh.labeled.labeled_size(labeled)
print('Background size', sizes[0])
print('Size of first region: {}'.format(sizes[1]))
>>>
Background size 50
Size of first region: 9
|
我们也可以统计每个标记区域的像素值的和:
0 1 2 3 4 5 6 7 | labeled, nr_objects = mh.label(regions)
array = np.ones(regions.shape) * 2
sums = mh.labeled_sum(array, labeled)
print(sums)
>>>
[ 100. 18. 2. 8.]
|
区域过滤¶
示例给出一张微观下的细胞图片,为统计细胞核数量,需要进行预处理:高斯模糊和二值化:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | f = mh.demos.nuclear_image()
f = f[:,:,0] # 只显示 R 通道
plt.figure()
plt.subplot(1,2,1)
plt.imshow(f)
# 进行高斯模糊
f = mh.gaussian_filter(f, 4)
# 二值化处理
f = (f > f.mean())
plt.subplot(1,2,2)
plt.imshow(f)
plt.show()
|
对细胞核个数进行统计:
0 1 2 3 4 | labeled, n_nucleus = mh.label(f)
print('Found {} nuclei.'.format(n_nucleus))
>>>
Found 42 nuclei.
|
可以准确找出 42 个细胞核,但是有些是聚合物,并不是细胞核,可以通过一个细胞核所占像素大小进行过滤,超过 10000 个像素就被过滤掉:
0 1 2 3 4 5 | sizes = mh.labeled.labeled_size(labeled)
too_big = np.where(sizes > 10000)
labeled = mh.labeled.remove_regions(labeled, too_big)
plt.imshow(labeled)
plt.show()
|
看图可以发现所有大于 10000 个像素的聚合物均被去除了:
有些细胞核处于边界上面,也可以去除掉:
0 | labeled = mh.labeled.remove_bordering(labeled)
|
由于有些连通区域被移除了,可以对连通区域进行重新标记,以让区域序号连续:
0 1 2 3 4 | relabeled, n_left = mh.labeled.relabel(labeled)
print('After filtering and relabeling, there are {} nuclei left.'.format(n_left))
>>>
After filtering and relabeling, there are 24 nuclei left.
|
以上过滤可以通过一条语句完成:
0 | relabeled,n_left = mh.labeled.filter_labeled(labeled, remove_bordering=True, max_size=10000)
|
实例应用¶
这里使用区域标记来统计下图中的硬币个数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | image = cv2.imread("coin.jpg")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (11, 11), 0) # 使用较大的高斯模糊
ret,thresh = cv2.threshold(blurred, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
labeled, digits = mh.label(thresh)
# 最小像素至少为 100
relabeled, n_left = mh.labeled.filter_labeled(labeled, min_size=100)
print("Find %d coins" % n_left)
plt.imshow(relabeled)
plt.show()
>>>
Find 5 coins
|
如果硬币之间有交叠,就要先使用分水岭算法把硬币分隔开再统计。
由于我们已经使用比较大的高斯核进行模糊处理,硬币内部的孔隙比较小,硬币间间隔比较大,可以扩大连通区域实现统计:
0 1 | labeled, digits = mh.label(thresh, np.ones((5,5), bool))
print("Find %d coins!" % digits)
|
如果要抠取的物体内部有很大孔隙,那么使用 opencv 的 boundingRect 生成 masking 来抠取是一个更好的选择。
标记区域矩形框¶
0 1 2 3 4 5 | labeled, digits = mh.label(thresh, np.ones((5,5), bool))
bboxes = mh.labeled.bbox(labeled)
print(bboxes[0])
>>>
[ 0 234 0 355]
|
labeled.bbox 方法可以根据 labeled 返回矩形框信息(ndarray),第一个成员为背景矩形框,其余依次为编号为 i 的矩形框。
提取边界信息¶
0 1 2 3 | labeled, digits = mh.label(thresh, np.ones((5,5), bool))
borders = mh.labeled.borders(labeled)
plt.imshow(borders)
plt.show()
|
移除标记区域¶
0 1 | regions = [1,2]
removed = mh.labeled.remove_regions(labeled, regions)
|
mayavi2¶
VTK(visualization toolkit)是一个开源的用于三维计算机图形学、图像处理和可视化的软件库。它是基于面向对象原理的基础上设计和实现的,它的内核使用 C++ 构建。TVTK (T 表示 Traits-based,也即支持 Traits 软件包)对它进行了 Python 封装,提供 Python 风格的接口。
尽管 VTK 和 TVTK 功能强大,但是使用它们直接进行绘图非强具有挑战性,因此基于 VTK 开发了许多可视化软件,例如 ParaView。而 Mayavi2 则是在 TVTK 基础上,使用纯Python 开发的 3D 可视化软件。Mayavi2 中的 mlab 模块类似于 matplotlib 的 pylab 模块,提供面向脚本的快速绘图函数。
为便于测试,我们直接借助 IPython 并使用 QT 作为显示的后端。
0 | ipython --gui=qt
|
或者在 IPython 交互模式中执行魔术命令:
0 | In []: %gui qt
|
Mayavi2 默认背景色为灰色,通过 figure 接口更改前景色和背景色,以及图片大小,默认为 400 x 350。
0 1 2 3 | In [1]: import mayavi.mlab as mlab
# 更改前景色(坐标轴,文字等颜色) 和背景色
In [2]: mlab.figure(fgcolor=(0, 0, 0), bgcolor=(1, 1, 1), size=(400, 350))
|
以上命令将创建一个空白画布,背景为白色,前景为黑色,画布大小为默认,此时将弹出如下窗口:
使用 points3d 方法在原点处绘制一个点:
0 | In [3]: mlab.points3d([0],[0],[0])
|
显然这一点绘制得并不漂亮,但是我们知道 points3d 接受 3 个参数作为 x,y,z 坐标,它们可以是 list 或者 1D ndarray 类型。
0 1 | # 清除绘图对象
In [4]: mlab.clf()
|
mlab.clf() 用于清除所有画布上的绘图对象,它就是一个黑板擦。接下来将使用更丰富的参数来绘制更精美的 3D 图像。大部分的函数接口均直接使用 numpy 的 ndarray 数组对象作为参数。这让我们可以以更直观的方式观察 ndarray 数据特征。
如果不适用 IPython,也可以调用 mlab.show() 来显示图片。
绘图¶
绘制点¶
使用 1 维数组,也即向量可以绘制多个点。mlab.points3d 接口支持多点绘图,每个坐标均支持一个向量,向量的维度就是点的个数:
points3d(x, y, z...)
points3d(x, y, z, s, ...)
points3d(x, y, z, f, ...)
s 可以是一个与 x,y,z 维度一样的向量用于指定显示点的颜色和大小,也可以是一个函数 f,接受 x,y,z 作为参数,返回 s。
0 1 2 | In [55]: mlab.figure(fgcolor=(0, 0, 0), bgcolor=(1, 1, 1), size=(400, 350))
...: x, y, z, value = np.random.random((4, 30))
...: mlab.points3d(x, y, z, value)
|
显然 mayavi 自动使用 value 来显示点的大小和颜色,当然 points3d 支持 colormap 来指定颜色方案:
0 1 2 3 4 5 6 7 8 9 10 11 | accent flag hot pubu set2
autumn gist_earth hsv pubugn set3
black-white gist_gray jet puor spectral
blue-red gist_heat oranges purd spring
blues gist_ncar orrd purples summer
bone gist_rainbow paired rdbu winter
brbg gist_stern pastel1 rdgy ylgnbu
bugn gist_yarg pastel2 rdpu ylgn
bupu gnbu pink rdylbu ylorbr
cool gray piyg rdylgn ylorrd
copper greens prgn reds
dark2 greys prism set1
|
0 | In [56]: mlab.points3d(x, y, z, value, colormap='autumn')
|
如果不指定 s,那么就会使用相同的颜色和大小绘制所有点,例如:
默认情况下,s 中最小的值对应 0 直径的点而不会被画出来,此时可以设置缩放因子为 1.
0 1 2 3 4 | In [74]: x = [1, 2, 3, 4, 5, 6]
...: y = [0, 0, 0, 0, 0, 0]
...: z = y
...: s = [.5, .6, .7, .8, .9, 1]
...: mlab.points3d(x, y, z, s)
|
0 | In [75]: mlab.points3d(x, y, z, s, scale_factor=1)
|
通过 mode 参数可以指定 3D 图像,例如圆柱体,箭头等,默认为 sphere:
样式 说明 2darrow 2D 箭头 2dcircle 2D 圆环 2dcross 十字 2ddash 点划线 2ddiamond 2D 菱形 2dhooked_arrow 钩形箭头 2dsquare 2D 方形 2dthick_arrow 空心箭头 2dthick_cross 空心十字 2dtriangle 三角形 2dvertex 顶点 arrow 3D 箭头 axes 3D 轴线 cone 圆锥 cube 立方体 cylinder 圆柱体 point 点 sphere 球体
当然还可以指定线条宽度,颜色等,具体请参考函数手册。
3D 曲线¶
plot3d(x, y, z, ...)
plot3d(x, y, z, s, ...)
mlab.plot3d 函数用于绘制 3D 曲线。
0 1 2 | In [89]: mlab.clf() # Clear the figure
...: v = np.linspace(0, 4*np.pi, 100)
...: mlab.plot3d(np.sin(v), np.cos(v), 0.1*v, v)
|
使用它我们可以绘制非常酷的曲线效果图,例如洛伦茨吸引子轨迹图:
0 1 2 3 4 5 6 7 8 9 10 11 | # odeint 求解洛伦茨微分方程
In [90]: from scipy.integrate import odeint
In [91]: def lorenz_track(w, t, p, r, b):
...: x,y,z = w
...: return np.array([p*(y-x), x*(r-z)-y, x*y-b*z])
...:
In [92]: t = np.arange(0,50,0.01)
...: track = odeint(lorenz_track, (0.0, 1.00, 0.0), t, args=(10.0,28.0, 3.0))
...: X,Y,Z = track.T
...: mlab.plot3d(X,Y,Z,t,tube_radius=0.5) # tube_radius 设置曲线粗细
|
plot3d 同样提供了丰富的参数,具体参考手册。
图像显示 2D 数组¶
imshow 将 2D 的 ndarray 显示为图片,通常同时显示色标,来查看数值大小与颜色的对应关系。
0 1 2 3 | arr = np.random.rand(10,10)
mlab.imshow(arr)
mlab.colorbar() # 添加色标
mlab.show()
|
imshow 默认使用差值处理以使得图像看起来比较平滑。可以通过 interpolate 关闭它。
0 1 2 3 | arr = np.random.rand(10,10)
mlab.imshow(arr, interpolate=False)
mlab.colorbar()
mlab.show()
|
同样可以使用 colormap 指定颜色映射,例如:
条状图¶
barchart 方法可以绘制 1-3 维数组条状图。
barchart(s, ...)
barchart(x, y, s, ...)
barchart(x, y, f, ...)
barchart(x, y, z, s, ...)
barchart(x, y, z, f, ...)
绘制一维数组条状图:
0 1 2 3 | s = np.array([0,1,2])
mlab.barchart(s)
mlab.colorbar()
mlab.show()
|
同样可以绘制二维和三维数组条状图:
0 1 | mlab.barchart(np.random.rand(3,3))
mlab.barchart(np.random.rand(3,3,3))
|
通过条状图和色标可以直观查看数组大小和数据分布情况。
曲面图¶
surf 方法用于绘制 3D 曲面图:
- x, y 可以是 1D 或 2D 数组(比如 numpy.ogrid 或 numpy.mgrid 返回的数组)
- 如果只传递了数组 s ,那么x, y就被认为是数组 s 的索引值。
surf(s, ...)
surf(x, y, s, ...)
surf(x, y, f, ...)
0 1 2 3 4 | obj = mlab.surf([[0,1],[2,3]])
mlab.axes() # 显示坐标
mlab.outline(obj) # 显示边框
mlab.colorbar()
mlab.show()
|
0 1 2 3 4 5 | x, y = np.mgrid[-10:10:100j, -10:10:100j]
r = np.sqrt(x**2 + y**2)
z = np.sin(r)/r
mlab.surf(x,y,z, warp_scale='auto')
mlab.colorbar()
mlab.show()
|
线框图¶
mesh 用于绘制线框图,x, y, z 都是二维数组,拥有相同的 shape,而且 z 代表了平面坐标 (x,y) 对应下的值,它和 surf 不同之处在于 surf 支持 1D 参数。
mesh(x, y, z, ...)
0 1 2 3 4 5 6 7 8 9 10 11 12 | phi, theta = np.mgrid[0:np.pi:11j, 0:2*np.pi:11j]
x = np.sin(phi) * np.cos(theta)
y = np.sin(phi) * np.sin(theta)
z = np.cos(phi)
# 绘制曲面,与 surf 类似
mlab.mesh(x, y, z)
# 绘制黑色线条
mlab.mesh(x, y, z, representation='wireframe', color=(0, 0, 0))
mlab.colorbar()
mlab.show()
|
等高线¶
contour_surf 方法用于绘制 3D 等高线图。
contour_surf(s, ...)
contour_surf(x, y, s, ...)
contour_surf(x, y, f, ...)
0 1 2 3 4 5 6 7 | def f(x, y):
sin, cos = np.sin, np.cos
return sin(x + y) + sin(2 * x - y) + cos(3 * x + 4 * y)
x, y = np.mgrid[-10:10:0.1, -5:5:0.05]
mlab.contour_surf(x, y, f)
mlab.colorbar()
mlab.show()
|
向量场¶
quiver3d 用于绘制场图:
0 1 2 3 4 5 6 | x, y, z = np.mgrid[0:1:20j, 0:1:20j, 0:1:20j]
u = np.sin(np.pi*x) * np.cos(np.pi*z)
v = -2*np.sin(np.pi*y) * np.cos(2*np.pi*z)
w = np.cos(np.pi*x)*np.sin(np.pi*z) + np.cos(np.pi*y)*np.sin(2*np.pi*z)
mlab.quiver3d(u, v, w)
mlab.outline()
mlab.show()
|
简化向量场图,通过 pipeline 中的 vectors 方法可以简化向量场图:
0 1 | src = mlab.pipeline.vector_field(u, v, w)
mlab.pipeline.vectors(src, mask_points=20, scale_factor=3.)
|
opencv¶
OpenCV 的全称是:Open Source Computer Vision Library,是一个基于BSD许可(开源)发行的跨平台计算机视觉库,可以运行在 Linux、Windows 和 Mac OS 操作系统上。它轻量级而且高效——由一系列 C 函数和少量 C++ 类构成(所以移植到嵌入式平台,例如ARM上当然是可行的 ^.^),同时提供了 Python、Ruby、MATLAB 等语言的接口(所以这就为 Python 在人工智能视觉处理领域提供了底层的强力支撑)。
它实现了图像处理和计算机视觉方面的很多通用算法(据官方出版物《学习 OpenCV》宣称支持多达一千多种算法(头大),并且新算法还在不停从 Paper 中实现验证并加入进来,而老算法在被不停迭代)。
OpenCV 主要用C/C++语言编写,它的主要接口由 C++ 提供,但是依然保留了大量的C语言接口。该库也有大量的Python, Java and MATLAB/OCTAVE 的接口。这些语言的API接口函数可以通过 OpenCV 在线文档 获得。
最新版本的 4.0 文档压缩后竟然还有 78MB,可见信息“熵”之大,兴不兴奋(头不头大)?
安装 opencv¶
这里以 Anaconda 环境安装 opencv 为例。
0 | conda install -c menpo opencv
|
如果安装速度很慢,可以添加 Anaconda 清华镜像源 TUNA,(这里必须给清华点赞,和网易公开课一样都是功德无量的事!)
0 1 2 3 | conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/
conda config --set show_channel_urls yes
|
添加 TUNA 镜像源后再安装 opencv 就会发现速度飞了起来:
0 1 2 3 | ......
opencv-4.0.1-p 100% |###############################| Time: 0:00:43 1.35 MB/s
requests-2.21. 100% |###############################| Time: 0:00:00 2.38 MB/s
conda-4.6.8-py 100% |###############################| Time: 0:00:00 5.72 MB/s
|
通过查看 opencv 版本,验证安装是否成功,同时可以发现 TUNA 镜像源是实时同步的:
0 1 2 3 4 | import cv2
print(cv2.__version__)
>>>
4.0.1
|
TUNA 镜像源还提供了一些 Anaconda 三方源,有兴趣可以移步 Anaconda 清华镜像源 TUNA。
基础操作¶
加载和显示¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | fname = 'beach.jpg'
image = cv2.imread(fname) # 读取图片
if image is None:
print("load image %s failed!" % fname)
else:
print(type(image).__name__, image.dtype)
print(image.shape)
cv2.imshow("Image", image) # 打开新窗口并显示
cv2.waitKey(0) # 等待,直至按键事件发生再继续执行
>>>
ndarray uint8
(333, 500, 3)
|
imread 返回的 image 对象是一个 numpy.ndarray 数组:
- 类型为 uint8,每个通道值范围 0-255
- 333 表示高度为 333 个像素,500 为宽度像素数
- 3 表示 RGB 3 个颜色通道,需要注意的是在 OpenCV 中顺序为 BGR
图像坐标:每张图片左上角为坐标 0,0 点,如果向右为 x 轴,向下为 y 轴。 由于 ndarray 第一维是行,第二维是列,所以 (x,y) 坐标指定的像素对应到 image[y,x]。
注意
image 对象[0,0] 元素对应图片左上角坐标 0,0 点,每个像素值顺序为 BGR。
0 1 2 3 4 5 | # 获取[0,0] 坐标对应的 RGB 值
B,G,R = image[0,0]
print(R,G,B)
>>>
2 51 128
|
将 image 对象保存为图片对应 imwrite 方法:
0 | cv2.imwrite("newbeach.jpg", image)
|
像素操作¶
通过 ndarray 可以读取像素值,当然也可以对数组赋值来更改像素值:
0 1 2 3 | # 截取左上角 100*100 像素并显示
corner = image[0:100, 0:100]
cv2.imshow("Corner", corner)
cv2.waitKey(0)
|
我们可以通过数组操作来更新任意像素点,像素块。
0 1 2 3 | # 左上角 100*100 像素填充为蓝色
image[0:100, 0:100] = (255, 0, 0)
cv2.imshow("Updated", image)
cv2.waitKey(0)
|
绘制棋盘¶
基于像素操作,就很容易绘制一个类似九宫格的棋盘,颜色相互交错:
- 首先构造一个基本的颜色块:base,由于要绘制各种颜色,所以使用 RGB 颜色空间,第三维为 3,为了后序操作方便,所有颜色通道填充为 0,即一个小的黑色块
- 然后构造水平的黑色块和一个指定的颜色色块,共同构造成 block0
- 使用 block0 在水平方向上反向得到 block1
- block0 和 block1 在垂直方向上堆叠,生成一个色块交错的“田”字形
- 在水平和垂直方向对“田”字形各堆叠 size 次,得到最终的棋盘
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 绘制不同颜色的棋盘图
def chessboard(square=10, size=15, color=(255,0,0)):
'''Create a chessboard color means RGB'''
color = color[::-1]
base = np.zeros((square, square, 3), dtype='uint8')
block0 = np.hstack(((base, (base + 1) * color))).astype(np.uint8)
block1 = block0[:, ::-1, :]
canvas = np.vstack((block0, block1))
return np.tile(canvas, (size, size, 1))
cv2.imshow("Red Chessboard", chessboard())
cv2.imshow("White Chessboard", chessboard(color=(255,255,255)))
cv2.waitKey(0)
|
基本绘图¶
像素操作等价于对数组各个元素的操作,那么绘制一个背景为白色的画布,就等于填充一个全 255 的数组。
0 1 2 3 4 5 6 7 8 9 10 11 | import numpy as np
# 创建画布
canvas = np.ones((200, 300, 3), dtype = "uint8") * 255
cv2.imshow("Canvas", canvas)
# 从右上角到右下角画一条绿色直线
green = (0, 255, 0)
cv2.line(canvas, (0, 0), (300, 200), green)
cv2.imshow("Green Line", canvas)
cv2.waitKey(0)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | canvas = np.ones((200, 300, 3), dtype = "uint8") * 255
# 绘制线宽为 3pixels 的红色直线
red = (0, 0, 255)
cv2.line(canvas, (300, 0), (0, 200), red, 3)
# 绘制绿色的矩形
green =(0, 255, 0)
cv2.rectangle(canvas, (10, 10), (60, 60), green)
# 绘制填充蓝色的矩形
blue = (255, 0, 0)
cv2.rectangle(canvas, (200, 50), (240, 100), blue, -1) # -1 表示进行内部填充
cv2.imshow("Rectangle", canvas)
cv2.waitKey(0)
|
0 1 2 3 4 5 6 7 | canvas = np.ones((300, 300, 3), dtype = "uint8") * 255
centerX, centerY = (canvas.shape[1] // 2, canvas.shape[0] // 2)
red = (0, 0, 255)
for r in range(0, 150, 25):
cv2.circle(canvas, (centerX, centerY), r, red, 3)
cv2.imshow("Bulleye", canvas)
|
绘制线宽为 3 的多个圆环,圆心为图形正中心,半径分别为 0,25,50,75,100,125。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # 随机画圆形
canvas = np.ones((300, 300, 3), dtype = "uint8") * 255
for i in range(0,25):
radius = np.random.randint(5, high=100)
color = np.random.randint(0, high=256, size=(3,)).tolist()
centre = np.random.randint(0, high=300, size=(2,))
cv2.circle(canvas, tuple(centre), radius, color, -1)
cv2.imshow("Random Circles", canvas)
# 随机画矩形
canvas = np.ones((300, 300, 3), dtype = "uint8") * 255
for i in range(0, 10):
color = np.random.randint(0, high=256, size=(3,)).tolist()
corner0 = np.random.randint(0, high=200, size=(2,))
corner1 = np.random.randint(50, high=300, size=(2,))
cv2.rectangle(canvas, tuple(corner0), tuple(corner1), color, -1)
cv2.imshow("Random Rectangles", canvas)
|
图像处理¶
平移¶
图像在坐标轴方向平移。涉及到平移齐次坐标变换矩阵,我们构造矩阵 M:
0 1 2 3 4 5 6 7 8 | image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
# 向右平移 50 像素,向下平移 100 像素
M = np.float32([[1, 0, 50], [0, 1, 100]])
shifted = cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
cv2.imshow("Shifted Down and Right", shifted)
cv2.waitKey(0)
|
0 1 2 | ......
M = np.float32([[1, 0, -50], [0, 1, -100]])
......
|
如果要对图像向左,向上平移,将平移参数调整为负数即可。
为了以后方便使用,把它封装为 translation 函数:
0 1 2 3 4 | def translation(image, x, y):
'''move image at x-axis x pixels and y-axis y pixels'''
M = np.float32([[1, 0, x], [0, 1, y]])
return cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
|
旋转¶
与平移类似,我们需要构造旋转矩阵来实现图形的旋转变换:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 以图片中心作为旋转基点
def rotate(image, angle):
'''roate image around center of image'''
h, w = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
return cv2.warpAffine(image, M, (w, h))
image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
rotated = rotate(image, 45)
cv2.imshow("Rotate 45 degree", rotated)
cv2.waitKey(0)
|
如果我们需要顺时针旋转,只需要传入负值即可。
缩放¶
OpenCV 提供了缩放操作(Resizing)接口 resize:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # 按照宽度或高度参数进行线性缩放
def resize(image, width=None, height=None, inter=cv2.INTER_AREA):
'''linear scale with width or height size'''
h, w = image.shape[:2]
if width is None and height is None:
return image
if width:
ratio = width / float(w)
dim = (width, int(h * ratio))
else:
ratio = height / float(h)
dim = (int(w * ratio), height)
return cv2.resize(image, dim, interpolation = cv2.INTER_AREA)
image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
resized = resize(image, 200)
cv2.imshow("Resized width to 200", resized)
resized = resize(image, height=200)
cv2.imshow("Resized height to 200", resized)
cv2.waitKey(0)
|
翻转¶
翻转(Flip)操作又称为镜像操作,图像按照 x 中轴线,或者 y 中轴线进行镜像,实现左右或者上下翻转。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def flip(image, flip='h'):
'''h/H:horizontally; v/V: vertically; b/B:both'''
flip_type = 1
if flip == 'v' or flip == 'V':
flip_type = 0
elif flip == 'b' or flip == 'B':
flip_type = -1
return cv2.flip(image, flip_type)
image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
cv2.imshow("Horizontally flipped", flip(image, 'h'))
cv2.imshow("Vertically flipped", flip(image, 'v'))
cv2.imshow("Both direction flipped", flip(image, 'b'))
cv2.waitKey(0)
|
剪切¶
剪切(Cropping)可以直接通过切片来进行操作,即在图片坐标范围内选择子区域:
0 1 2 3 4 5 6 7 8 | # 传入左上角坐标和右下角坐标
def crop(image, start=(0,0), end=(0,0)):
return image[start[1]:end[1] + 1, start[0]:end[0] + 1]
image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
cv2.imshow("Cropped", crop(image, (200,200),(300,300)))
cv2.waitKey(0)
|
加减运算¶
我们可以对像素进行加减以改变图像的整体颜色强度:变浅或变深。
OpenCV 提供的加减运算方法进行截断操作,也即总是保证数值不大于 255,且不小于 0,这与 numpy 不同,numpy 操作可能会溢出:
0 1 2 3 4 5 6 7 8 9 10 | print("max of 255: {}".format(cv2.add(np.uint8([200]), np.uint8([100]))))
print("min of 0: {}".format(cv2.subtract(np.uint8([50]), np.uint8([100]))))
print("wrap around: {}".format(np.uint8([200]) + np.uint8([100])))
print("wrap around: {}".format(np.uint8([50]) - np.uint8([100])))
>>>
max of 255: [[255]]
min of 0: [[0]]
wrap around: [44]
wrap around: [206]
|
所以通常我们使用 cv2.add 和 cv2.subtract 进行像素加减操作。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def light(image, light):
'''light can be positive or negative'''
if abs(light) > 255:
light = int((light/light) * 255)
if light < 0:
M = np.ones(image.shape, dtype = "uint8") * (-light)
return cv2.subtract(image, M)
else:
M = np.ones(image.shape, dtype = "uint8") * light
return cv2.add(image, M)
image = cv2.imread("beach.jpg")
cv2.imshow("Original", image)
cv2.imshow("Brighten", light(image, 30))
cv2.imshow("Darken", light(image, -30))
cv2.waitKey(0)
|
位操作¶
位操作(Bitwise)主要包括 AND, OR, XOR, 和 NOT 布尔运算。
0 1 2 3 4 5 6 7 8 9 | # 生成矩形
rectangle = np.ones((300, 300), dtype = "uint8") * 255
cv2.rectangle(rectangle, (25, 25), (275, 275), 0, -1)
cv2.imshow("Rectangle", rectangle)
# 生成圆形
circle = np.ones((300, 300), dtype = "uint8") * 255
cv2.circle(circle, (150, 150), 150, 0, -1)
cv2.imshow("Circle", circle)
cv2.waitKey(0)
|
注意图中黑色部分像素值为 0,白色部分像素值为 255。此外两幅进行位运算的图像必须大小相同(宽,高和通道数)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | bitwiseAnd = cv2.bitwise_and(rectangle, circle)
cv2.imshow("AND", bitwiseAnd)
cv2.waitKey(0)
bitwiseOr = cv2.bitwise_or(rectangle, circle)
cv2.imshow("OR", bitwiseOr)
cv2.waitKey(0)
bitwiseXor = cv2.bitwise_xor(rectangle, circle)
cv2.imshow("XOR", bitwiseXor)
cv2.waitKey(0)
bitwiseNot = cv2.bitwise_not(circle)
cv2.imshow("NOT", bitwiseNot)
cv2.waitKey(0)
|
遮罩¶
遮罩又称为蒙版(Masking)或者掩模,基于位操作,常用于提取图片的部分内容。遮罩的基本原理就是布尔运算操作。
首先构造一个遮罩图层,构造需要提取的图层区域,填充为 255,其余区域填充为 0,通过与运算就可以把白色区域的图像提取出来。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | image = cv2.imread("beach.jpg")
cv2.imshow("Orignal", image)
# 创建遮罩图层
mask = np.zeros(image.shape[:2], dtype='uint8')
# 在遮罩图层创建填充矩形
cX, cY = (image.shape[1] // 3, image.shape[0] // 2)
length = 150 >> 1
cv2.rectangle(mask, (cX - length, cY - length), (cX + length , cY + length), 255, -1)
cv2.imshow("Rectangle Mask", mask)
# 在遮罩图层创建填充圆形
radius = 80
cv2.circle(mask, (cX * 2, cY), radius, 255, -1)
cv2.imshow("Circle and Rectangle Mask", mask)
# 遮罩:位与操作
masked = cv2.bitwise_and(image, image, mask=mask)
cv2.imshow("Masked", masked)
cv2.waitKey(0)
|
通道分离和合并¶
如果一张图片有多个通道,它对应到 ndarray 数组的第三维。通常图片使用 RGB 颜色空间,第三个通道分别对应 BGR。
cv2.split 方法实现通道的分离:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | image = cv2.imread("beach.jpg")
cv2.imshow("Orignal", image)
B,G,R = cv2.split(image)
print(image.shape, B.shape)
>>>
(333, 500, 3) (333, 500)
cv2.imshow("Red", R)
cv2.imshow("Green", G)
cv2.imshow("Blue", B)
cv2.waitKey(0)
|
为何分离通道后的图像显示为灰度图?可以发现分离后的 B,G 和 R 没有第三个维度,所以每一通道数据均被解释为了灰度数据:图像越明亮,则该通道颜色分量越大,图像越暗淡,对应通道的颜色分量越小。
示例图中包含了大量的蓝色区域:天空,大海,所以 B 通道看起来就明亮得多,而 R 通道就很暗淡。
通道合并是通道分离的逆操作,通过 cv2.merge 完成。
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 合并三通道,就变成了原始图片
merged = cv2.merge([B, G, R])
cv2.imshow("Merge BGR", merged)
# 合并单个通道,其余通道置为 0
merged = cv2.merge([B * 0, G * 0, R])
cv2.imshow("Merge R", merged)
merged = cv2.merge([B * 0, G, R * 0])
cv2.imshow("Merge G", merged)
merged = cv2.merge([B, G * 0, R * 0])
cv2.imshow("Merge B", merged)
cv2.waitKey(0)
|
颜色空间转换¶
由于不同领域对图像处理的需求侧重点不同,颜色空间有很多种:
- 灰度颜色空间可以降低图片存储大小,在进行模式识别时,降低计算量。
- RGB(red,green,blue) 颜色空间最常用于显示器系统。在RGB颜色空间中,任意色光F都可以用R、G、B三色不同分量的相加混合而成:F=r[R]+r[G]+r[B]。RGB色彩空间还可以用一个三维的立方体来描述。当三基色分量都为0(最弱)时混合为黑色光;当三基色都为k(最大,值由存储空间决定)时混合为白色光。
- HSV(hue,saturation,value) 表示色相、饱和度和亮度。色相是色彩的基本属性,就是平常说的颜色的名称,如红色、黄色等。饱和度(S)是指色彩的纯度,越高色彩越纯,低则逐渐变灰,取0-100%的数值。明度(V),取0-max(计算机中HSV取值范围和存储的长度有关)。
- LAB 颜色空间中的L分量(明度通道)用于表示像素的亮度,取值范围是[0,100],表示从纯黑到纯白;a表示从红色到绿色的范围,取值范围是[127,-128];b表示从黄色到蓝色的范围,取值范围是[127,-128]。LAB中的L 通道专门负责整张图的明暗度,简单的说就是整幅图的黑白基调,a 通道和 b 通道只负责颜色的多少。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | image = cv2.imread("beach.jpg")
cv2.imshow("Orignal", image)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imshow("Gray", gray)
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
cv2.imshow("HSV", hsv)
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
cv2.imshow("L*a*b*", lab)
print(gray.shape)
print(hsv.shape, lab.shape)
cv2.waitKey(0)
>>>
(333, 500) # 灰度颜色空间没有第三维(颜色通道)
(333, 500, 3) (333, 500, 3)
|
直方图¶
直方图常常用于统计特定的数据,并以直观的方式给出特定数据的特征分布。在图像处理领域,常用于统计图像(或感兴趣的区域)的像素分布或者边缘轮廓分布,以用于图像搜索和物体识别。
像素分布¶
直方图的 x 轴被称为 bin,它是一个个统计数据的分类桶,每个桶代表不同的数据分布区间,它的高度就表示落在该区间中的数据个数。
数据分布区间大小可以自由定义,但是如果定义太小,则细节数据增多,不易于发现图像的主要特征,且计算量增大,如果定义太大,就会忽略掉我们关心的细节信息。
cv2.calcHist(images,channels,mask,histSize,ranges)
cv2.calcHist 用于绘制图像的直方图:
- images: 指定处理的图像 ndarray 数组,可以指定多个图像
- channels:颜色通道的索引列表,如果是灰度图,则指定 [0],否则指定 [0,1,2] 表示 BRG 通道
- mask: 指定计算直方图的遮罩数组,如果没有则为 None
- histSize: bins 的数目,也即统计区间的个数,它的数据类型应该和 channels 匹配,如果 channels 指定 [0,1,2],则 bins 可指定为 [16,16,16],通常使用 1D 或者 2D 通道来生成直方图,较少用到 3D。
- ranges:像素值的范围,对于 RGB 空间就是 [0,256]。
灰度直方图¶
0 1 2 3 4 5 6 7 8 9 10 11 12 | import matplotlib.pyplot as plt
image = cv2.imread("beach.jpg")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
plt.figure()
plt.title("Grayscale Histogram")
plt.xlabel("Bins")
plt.ylabel("Pixels")
plt.plot(hist)
plt.xlim([0, 255])
plt.show()
|
首先我们把图像转变为灰度图,然后指定按照 256 个分类桶来对像素值在 [0-256] 的所有像素进行分类统计:
从图中可以 x 轴为分类桶,y 轴为像素值分布,大像素值占比比较大,小像素值占比比较少,整个灰度图像偏明亮。
RGB直方图¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | image = cv2.imread("beach.jpg")
channels = cv2.split(image)
plt.figure(figsize=(12,4))
# 绘制原图
plt.subplot(1,2,1)
plt.title("Original")
plt.imshow(cv2.merge(channels[::-1]))
# 绘制RGB直方图
plt.subplot(1,2,2)
plt.title("RGB Color Histogram")
plt.xlabel("Bins")
plt.ylabel("Pixels")
plt.xlim([0, 255])
for chan, color in zip(channels, 'bgr'):
hist = cv2.calcHist([chan], [0], None, [256], [0, 256])
plt.plot(hist, color=color)
plt.show()
|
图中使用RGB颜色绘制三个通道的像素分布,可以观察到:
- 红色通道在 0 值附近和 255 值附近各出现一个尖峰,对应椰子树的树干枯叶和茅草屋上的枯草
- 绿色区域在 100 处出现一个峰值对应浅绿色的海水,在 200 附近的峰值对应深绿色的椰子树叶
- 蓝色通道在 255 附近有很高的尖峰,对应深蓝色的天空和远处的海水
最终把绘制灰度直方图和RGB彩色直方图封装在一个函数中:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | def histogram_rgbshow(fname, channel=0):
'''channel: 0-> gray, 1->RGB'''
import matplotlib.pyplot as plt
image = cv2.imread(fname)
if image is None:
return
plt.figure(figsize=(12,4))
plt.subplot(1,2,1)
plt.title(fname)
# 转换为灰度图
if channel == 0:
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
channels = [image]
plt.imshow(image, cmap='gray', vmin = 0, vmax = 255)
else:
channels = cv2.split(image)
plt.imshow(cv2.merge(channels[::-1]))
plt.subplot(1,2,2)
plt.title("%s Histogram" % ('Gray' if channel == 0 else 'RGB'))
plt.xlabel("Bins")
plt.ylabel("Pixels")
plt.xlim([0, 255])
colors = (['gray'] if channel == 0 else 'bgr')
for chan, color in zip(channels, colors):
hist = cv2.calcHist([chan], [0], None, [256], [0, 256])
plt.plot(hist, color=color)
plt.show()
# 1: 绘制RGB直方图 0: 绘制灰度直方图
histogram_rgbshow('beach.jpg', 1)
|
2D 直方图¶
我们可以分别统计任意两个颜色通道组成的 2D 直方图,来分析图片中不同颜色之间的关联关系。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | def histogram2d_rgbshow(fname):
'''Draw 2D histogram'''
import matplotlib.pyplot as plt
image = cv2.imread(fname)
if image is None:
print("Failed to open file %s!" % fname)
return
if image.shape[2] != 3:
print("Image %s don't have RGB channels!", fname)
return
plt.figure(figsize=(10, 8))
plt.subplot(2, 2, 1)
plt.title("Original")
chans = cv2.split(image)
plt.imshow(cv2.merge(chans[::-1]))
index = 2
for c0, c1 in zip('GGB', 'BRR'):
chan0 = 'BGR'.index(c0)
chan1 = 'BGR'.index(c1)
hist = cv2.calcHist([chans[chan0], chans[chan1]], [0, 1],
None, [32] * 2, [0, 256] * 2)
ax = plt.subplot(2, 2, index)
index += 1
p = ax.imshow(hist, interpolation="nearest", cmap='Blues')
plt.colorbar(p)
ax.set_title("2D Color Histogram for %s and %s" % (c0, c1))
plt.show()
|
分析 G 和 B 颜色通道可以发现,在 G=30,B=30 附近像素点数分布很多,这一区域对应图中的绿色海洋和蓝色天空。 而分析 G 和 R 颜色通道可以发现,在 G=1,R = 12 附近像素分布很多,这一区域对应茅草屋和椰子树的枯叶部分。
区域直方图¶
更多时候我们只关心图像的某个区域,如果我们已经识别出一张人脸,再去识别这个人的眼睛,那么我们就无需关心其他区域了。
以上我们均是统计的整个图像的直方图,calcHist 提供了 mask 参数,可以用于选取部分区域。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | def histogram_rgbshow(fname, channel=0, mask=None):
'''channel: 0-> gray, 1->RGB'''
import matplotlib.pyplot as plt
image = cv2.imread(fname)
if image is None:
return
plt.figure(figsize=(12,4))
plt.subplot(1,2,1)
plt.title(fname)
if mask is not None:
image = cv2.bitwise_and(image, image, mask=mask)
if channel == 0:
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
channels = [image]
plt.imshow(image, cmap='gray', vmin = 0, vmax = 255)
else:
channels = cv2.split(image)
plt.imshow(cv2.merge(channels[::-1]))
plt.subplot(1,2,2)
plt.title("%s Histogram %s" % ('Gray' if channel == 0 else 'RGB',
'with mask' if mask is not None else ''))
plt.xlabel("Bins")
plt.ylabel("Pixels")
plt.xlim([0, 255])
colors = (['gray'] if channel == 0 else 'bgr') maxy = 0
for chan, color in zip(channels, colors):
hist = cv2.calcHist([chan], [0], mask, [256], [0, 256])
if maxy < np.max(hist):
maxy = np.max(hist)
plt.plot(hist, color=color)
plt.ylim([0, maxy + 1])
plt.show()
|
首先更新 histogram_rgbshow 函数,支持 mask 参数。
0 1 2 3 4 5 | image = cv2.imread('beach.jpg')
# 生成遮罩
mask = np.zeros(image.shape[:2], dtype = "uint8")
cv2.rectangle(mask, (20, 20), (150, 150), 255, -1)
histogram_rgbshow('beach.jpg', channel=1, mask=mask)
|
这里截取了部分蓝色天空,显然这部分的红色分量异常少,高数值的像素多数集中在蓝色和绿色通道。显然如果我们通过某种算法识别出来一张人脸,但是该区域直方图却集中分布在蓝色或者绿色区域,那么很可能就是误识别。
另一方面也说明,如果我们要搜索相似图片,那么它们的直方图分布就是近似的。
直方图均衡¶
直方图均衡常用于提高灰度图的对比度,经过均衡化后的图片看起来更锐利,而直方图分布更均匀。原图向像素分布可能集中分布在某一部分,这样整幅图的灰阶就比较窄,看起来就是模糊一团,均衡化的根本原理就是把原来集中分布在一个范围内的像素均衡到整个灰阶区域,这样整个灰阶空间的对比度就会上升:乌压压的人群挤在一起很难分辨谁是谁,当他们分开散去的时候就很容易认出谁是谁来。
0 1 2 3 4 5 6 7 8 | image = cv2.imread('beach.jpg')
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
eq = cv2.equalizeHist(image)
cv2.imshow("Histogram Equalization", np.hstack([image, eq]))
cv2.imwrite('beach_eq.jpg', eq)
histogram_rgbshow('beach_eq.jpg', channel=0)
cv2.waitKey(0)
|
从两幅图对比中可以发现:右图的黑色更黑,白色更白,也即灰阶向低处和高处空间扩散了,均匀张开到了整个灰度空间。当从模糊图片中识别物体时常常需要进行对比度提升,以突出前景中的物体。
图像平滑¶
与直方图均衡提高图像对比度不同,“平滑处理“(Smoothing)也称“模糊处理”(Bluring),平滑处理常用来减少图像上的噪点或者失真。平滑处理体现在频域上,就是对高频成分进行滤波处理。
在涉及物体边缘检测时,平滑处理是非常重要的方法。平滑或者滤波处理的目的有两个:
- 抽出对象的特征作为图像识别的特征模式
- 是为适应计算机处理的要求,消除图像数字化时所混入的噪声。
同时在滤波处理后不能损坏图像轮廓及边缘等重要信息。典型的,中值滤波常用于去除椒盐噪声,双边滤波可以保边去噪。
可以想见如何对图像进行模糊处理:每个像素用周边像素的均值或者加权值(高斯模糊)替代。OpenCV 提供了四种模糊技术。
均值模糊¶
均值模糊是一种典型的线性滤波算法,它以目标象素为中心的周围 n 个像素,构成一个滤波器,即去掉目标像素本身,用像素窗口中的全体像素的平均值来代替原来像素值。平均由一个归一化卷积框完成的,只是用卷积框覆盖区域所有像素的平均值来代替中心元素。
均值模糊本身存在着固有的缺陷:在图像去噪的同时破坏了图像的细节部分,从而使图像变得模糊,由于噪声点的信息也被平均到周围像素中了,所以它也不能很好地去除噪声。
0 1 2 3 4 5 6 7 | image = cv2.imread('beach.jpg')
blurred = np.hstack([cv2.blur(image, (5, 5)),
cv2.blur(image, (7, 7))])
cv2.imshow("Averaged", blurred)
cv2.waitKey(0)
cv2.imwrite("beach_blur.jpg", cv2.blur(image, (7, 7)))
histogram_rgbshow('beach_blur.jpg', channel=1)
|
通过模糊图像的对比,可以发现均值采用的像素范围越大,图像越模糊,但是均值模糊不改变像素直方图的相对分布。
高斯模糊¶
高斯模糊也是一种线性平滑滤波,适用于消除高斯噪声,它是对整幅图像的像素进行加权平均:每一个像素点的值,都由其本身和邻域内的其他像素值经过加权平均后得到。用一个窗口(或称卷积、掩模)扫描图像中的每一个像素,用邻域内像素的加权平均灰度值去替代模板中心像素点的值,离中心像素越近权重越高。
相对于均值滤波(mean filter)它的平滑效果更柔和,而且边缘保留的也更好。高斯滤波器的窗口尺寸越大,标准差越大,处理过的图像模糊程度越大。
0 1 2 3 4 5 6 7 | image = cv2.imread('beach.jpg')
# 参数 3 设置高斯方差,为 0 则根据高斯窗口尺寸自动计算
blurred = np.hstack([cv2.GaussianBlur(image, (3, 3), 0),
cv2.GaussianBlur(image, (5, 5), 0),
cv2.GaussianBlur(image, (7, 7), 0)])
cv2.imshow("Gaussian", blurred)
|
中值模糊¶
中值(Median)模糊滤波法是一种非线性平滑技术,它将每一像素点的灰度值设置为该点某邻域窗口内的所有像素点灰度值的中值。
中值模糊是基于排序统计理论的一种能有效抑制噪声的非线性信号处理技术,基本原理是把数字图像或数字序列中一点的值用该点的一个邻域中各点值的中值代替,让周围的像素值接近的真实值,从而消除孤立的噪声点,比如椒盐噪声。
它用某种结构的二维滑动窗口,将窗口内像素按照像素值的大小进行排序,生成单调上升(或下降)的为二维数据序列。二维中值滤波输出为 g(x,y)= med{f(x-k,y-l),(k,l∈W)} ,其中 f(x,y),g(x,y) 分别为原始图像和处理后图像。W 为二维窗口,通常为 3*3,5*5 区域,也可以是不同的的形状,如线状,圆形,十字形,圆环形等。
窗口尺寸越大越能有效消除噪声,但是会使边界模糊,因此对窗口的选择直接影响图片的质量。
0 1 2 3 4 5 | image = cv2.imread('beach.jpg')
blurred = np.hstack([cv2.medianBlur(image, 3),
cv2.medianBlur(image, 5),
cv2.medianBlur(image, 7)])
cv2.imshow("Median", blurred)
|
双边模糊¶
双边(Bilateral )模糊是一种非线性的滤波方法,它是结合图像的空间邻近度和像素值相似度的一种折衷处理,同时考虑空域信息和灰度相似性,达到保边去噪的目的。具有简单、非迭代、局部的特点。
双边滤波器的好处是可以做边缘保存(Edge Preserving),维纳(Wiener)滤波或者高斯滤波去降噪,都会较明显地模糊边缘,对于高频细节的保护效果并不明显。
高斯滤波器只考虑像素之间的空间关系,而不会考虑像素值之间的关系(像素的相似度)。所以这种方法不会考虑一个像素是否位于边界。因此边界也被模糊掉,这不是我们想要的。双边滤波同时使用空间高斯权重和灰度值相似性高斯权重。空间高斯函数确保只有邻近区域的像素对中心点有影响,灰度值相似性高斯函数确保只有与中心像素灰度值相近的才会被用来做模糊运算。所以这种方法会确保边界不会被模糊掉,因为边界处的灰度值变化比较大。
双边滤波操作与其他滤波器相比运算量大,处理速度比较慢。
0 1 2 3 4 5 6 7 | image = cv2.imread('beach.jpg')
# 5 表示窗口直径,21 分别是空间高斯函数标准差和灰度值相似性高斯函数标准差
blurred = np.hstack([cv2.bilateralFilter(image, 5, 21, 21),
cv2.bilateralFilter(image, 7, 31, 31),
cv2.bilateralFilter(image, 9, 41, 41)])
cv2.imshow("Bilateral", blurred)
|
0 1 2 3 4 5 6 7 8 9 | def bluring_suit(image):
blurred = np.hstack([cv2.GaussianBlur(image, (5, 5), 0),
cv2.medianBlur(image, 5),
cv2.bilateralFilter(image, 5, 21, 21)])
cv2.imshow("Gauss, Median and Bilateral filter", blurred)
image = cv2.imread('texture.jpg')
bluring_suit(image)
cv2.waitKey(0)
|
正对需要保留边缘信息的图片处理,图中可以看出高斯模糊和中值模糊都不能很好保留边缘信息,双边模糊恰恰相反:
椒盐噪声¶
椒盐噪声(Salt-and-Pepper Noise)是由图像传感器,传输信道,解码处理等产生的黑白相间的亮暗点噪声,也称为脉冲噪声。
胡椒通常是黑色的,盐是白色的,椒盐噪声在图像体现为随机出现黑色白色的像素噪点。它是一种因为信号脉冲强度引起的噪声,成因可能是影像讯号受到突如其来的强烈干扰而产生、类比数位转换器或位元传输错误等。例如失效的感应器导致像素值为最小值,饱和的感应器导致像素值为最大值。
我们可以使用随机算法模拟椒盐噪声:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | def saltnoise_add(image, snr=0.999):
noiseSize = int(image.size * (1 - snr))
for i in range(0, noiseSize):
x = int(np.random.uniform(0, image.shape[1]))
y = int(np.random.uniform(0, image.shape[0]))
if (x + y) % 2:
image[x, y] = 255
else:
image[x, y] = 0
return image
image = cv2.imread('beach.jpg')
cv2.imshow("Saltnoise", saltnoise_add(image))
|
我们分别使用高斯模糊,中值模糊和双边模糊来进行滤波,可以很清晰的看到中值滤波效果最好:
根据卷积原理,通常滤波的窗口尺寸(卷积核)需要设置为奇数,比如中值滤波,如果是偶数取到的中值误差就很大。
阈值化¶
所谓阈值化(Thresholding),简单理解就是针对一数组,当数组元素值在某一范围时给与保留或归零处理。在图像处理领域,就是针对像素值(或者多通道像素值的组合)进行阈值处理。这样做的效果相当于把关心区域或物体从图片中抠取出来。
OpenCV 提供了多种阈值化算法。
二值化图像¶
在介绍二值化图向前,首先生成一个用于测试的渐变灰度图:
0 1 2 3 4 5 6 | def gradual(height=256):
'''Create a gradual gray graph'''
base = np.linspace(0, 255, 256, endpoint=True).astype(np.uint8).reshape(1,256)
return np.tile(base, (height, 1))
image = gradual(256)
hgimg_rgbshow(image, channel=0)
|
下图是一个宽为 256 个像素,并且像素值从0-255递增的渐变灰度图,从直方图可以看出所有像素值点数(图像高度像素数,这里为 256)均匀分布:
cv2.threshold 方法提供对灰度图的阈值化操作,cv2.THRESH_BINARY 指定阈值化类型为二值化。
0 1 2 | # 当像素值 >127 时置为 255,否则置为 0
(ret, thresh) = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY)
hgimg_rgbshow(thresh, channel=0)
|
二值化之后,可以看到直方图中像素集中到 0 和 255 两端处,图像一边为纯黑色(原像素值<=127),一边为纯白色(原像素值>127):
如果我们把示例中的第三个参数改为 200,那么高于 127 的像素就被修改为 200:
0 1 | (ret, threshInv) = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY_INV)
hgimg_rgbshow(threshInv, channel=0)
|
二值化反操作 THRESH_BINARY_INV 与 THRESH_BINARY 正好相反,大于 > 127 则设置为 0,否则设置为 255:
各类阈值化方法如下:
- cv2.THRESH_BINARY : 二值阈值化
- cv2.THRESH_BINARY_INV:反向二值阈值化
- cv2.THRESH_TRUNC: 截断阈值化
- cv2.THRESH_TOZERO:超过阈值被置 0
- cv2.THRESH_TOZERO_INV:低于阈值被置 0
阈值化抠图¶
首先将原图转化为灰度图,然后观察像素分布情况:
0 1 2 | image = cv2.imread('coin.jpg')
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
hgimg_rgbshow(image, channel=0)
|
通过观察可以发现:带抠图区域颜色较深,也即像素值较低,背景颜色相似,像素集中分布在130-200,可以使用阈值化将高亮度像素归 0,然后使用遮罩方式抠取图片。
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 首先进行高斯模糊,抠图更完整
blurred = cv2.GaussianBlur(image, (7, 7), 0)
cv2.imshow("Blurred", blurred)
# 这里的阈值设置为 125
(ret, thresh) = cv2.threshold(blurred, 125, 255, cv2.THRESH_BINARY)
cv2.imshow("Threshold Binary", thresh)
(ret, threshInv) = cv2.threshold(blurred, 125, 255, cv2.THRESH_BINARY_INV)
cv2.imshow("Threshold Binary Inverse", threshInv)
# 使用遮罩方式抠取图片
cv2.imshow("Coins", cv2.bitwise_and(image, image, mask=threshInv))
|
对比两幅抠取到的图像,采用高斯模糊抠取的图像更完整,孔洞较少。
同时注意到阈值的设置对图像的抠取至关重要,但是在机器视觉领域要为每一张图片都人为设置阈值进行区域提取(ROI,Region Of Interest)是不现实的。
自适应阈值¶
上例中我们在抠取图片时整幅图像采用同一个数作为阈值:全局阈值。如果同一幅图像上的不同部分具有不 同亮度时,这种方法就不适用了。此时就要采用自适应阈值(Adaptive Thresholding)。此时阈值需要根据图像上的每一个小区域计算得到。
因此在同一幅图像上的不同区域采用的是不同的阈值,这就可以在亮度不同的情况下得到期望的效果。
Adaptive Thresholding 指定计算阈值的方法:
- cv2.ADPTIVE_THRESH_MEAN_C:阈值取自相邻区域的平均值。
- cv2.ADPTIVE_THRESH_GAUSSIAN_C:阈值取值相邻区域的加权和,权重为一个高斯窗口。
- Block Size:邻域大小(用来计算阈值的窗口大小)。
- C:常数,阈值等于平均值或者加权平均值减去这个常数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | image = cv2.imread('coin.jpg')
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(image, (5, 5), 0)
cv2.imshow("Blurred", blurred)
thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
cv2.THRESH_BINARY_INV, 11, 5)
cv2.imshow("Mean Thresh", thresh)
thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 15, 5)
cv2.imshow("Gaussian Thresh", thresh)
cv2.waitKey(0)
|
对比自适应阈值和全阈值方法的效果,可以发现,自适应方法可以在不同明暗情况下很好地保留物体边缘信息,并且在相同参数时高斯方式能去除更多的噪点,图像更干净。 通常需要提取的物体越大,那么窗口尺寸也应越大,保留细节越少则 C 常数越大。
Otsu’s 二值化¶
在二值化阈值中,通过查看直方图的方式来猜测应该设置的阈值。但是我们不知道选取的这个参数的好坏,只能不停尝试。如果在直方图上是一副双峰图像(图像直方图中存在两个峰)呢?应该怎样选择这个阈值?Otsu 二值化自动对一副双峰图像根据其直方图自动计算出一个阈值。(对于非双峰图像,这种方法得到的结果可能会不理想)。
注意到前面在使用 cv2.threshold 方法时会返回两个值,其中的 ret 没有用到。它就是用于返回最优阈值的。此时传入参数需附加上 cv2.THRESH_OTSU 标志,且阈值设置为 0。 如果不使用 Otsu 二值化,返回的 ret 值与设定的阈值相等。
0 1 2 3 4 5 6 7 8 9 10 11 | image = cv2.imread('coin.jpg')
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 全局阈值,返回 125
ret1,thresh = cv2.threshold(image, 125, 255, cv2.THRESH_BINARY_INV)
# Otsu's 自动阈值,传入阈值必须设置为 0
ret2,thresh = cv2.threshold(image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
print(ret1, ret2)
>>>
125.0 112.0
|
仔细观察硬币图片,在 50 处有一峰值(对应前景中的硬币),在 150 处有一峰值(对应占据图像大面积的灰色背景),Otsu 可以找到更优化的阈值:
0 1 2 3 4 5 6 7 | # global thresholding
ret1,thresh = cv2.threshold(image, 125, 255, cv2.THRESH_BINARY_INV)
cv2.imshow("Global 125 Thresh", thresh)
# Otsu's thresholding
ret2,thresh = cv2.threshold(image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
cv2.imshow("OTSU Thresh", thresh)
cv2.waitKey(0)
|
图像切割¶
分水岭算法¶
任何一副灰度图像都可以被看成拓扑平面,灰度值高的区域可以被看成是山峰,灰度值低的区域可以被看成是山谷。我们向每一个山谷中灌不同颜色的水。随着水的位的升高,不同山谷的水就会相遇汇合,为了防止不同山谷的水汇合,我们需要在水汇合的地方构建起堤坝。不停的灌水,不停的构建堤坝知道所有的山峰都被水淹没。我们构建好的堤坝就是对图像的分割。这就是分水岭算法的背后哲理。
图像梯度¶
梯度的方向是函数 f(x, y) 变化最快的方向。在图像处理领域,当图像中存在边缘时,一定有较大的梯度值,处于边缘上的像素只与邻近的边缘像素差别小,而与任何一个非边缘方向像素值差别很大,特别是垂直方向,差别最大,此时梯度最大,当图像中有比较平滑的部分时,像素值变化较小,则相应的梯度也较小,图像处理中把梯度的模简称为梯度。
经典的图像梯度算法是考虑图像的每个像素的某个邻域内的灰度变化,利用边缘临近的一阶或二阶导数变化规律,对原始图像中像素某个邻域设置梯度算子,通常我们用小区域模板进行卷积来计算,有Sobel算子、Robinson算子、Laplace算子等。
Sobel Scharr 算子¶
Sobel,Scharr 其实就是求一阶或二阶导数。Scharr 是对 Sobel(使用小的卷积核求解求解梯度角度时)的优化。
Sobel 算子是高斯平滑与微分操作的结合体,所以它的抗噪声能力很好。可以设定求导的方向(xorder 或 yorder)。还可以设定使用的卷积核的大小(ksize)。
如果 ksize=-1,会使用 3x3 的 Scharr 滤波器,它的的效果要比 3x3 的 Sobel 滤波器好(而且速度相同,所以在使用 3x3 滤波器时应该尽量使用 Scharr 滤波器)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | def sobel(fname, scharr=0):
image = cv2.imread(fname)
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imshow("Blurred", image)
if (scharr): # ksize = -1 使能 scharr 算法
sobelX = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=-1)
sobelY = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=-1)
else:
# 参数 1,0 为只在 x 方向求一阶导数,最大可以求 2 阶导数
sobelX = cv2.Sobel(image, cv2.CV_64F, 1, 0)
# 参数 0,1 为只在 y 方向求一阶导数,最大可以求 2 阶导数
sobelY = cv2.Sobel(image, cv2.CV_64F, 0, 1)
# 类型转回 uint8
sobelX = np.uint8(np.absolute(sobelX))
sobelY = np.uint8(np.absolute(sobelY))
sobelCombined = cv2.bitwise_or(sobelX, sobelY)
if (scharr):
cv2.imshow("Scharr X", sobelX)
cv2.imshow("Scharr Y", sobelY)
cv2.imshow("Scharr Combined", sobelCombined)
else:
cv2.imshow("Sobel X", sobelX)
cv2.imshow("Sobel Y", sobelY)
cv2.imshow("Sobel Combined", sobelCombined)
cv2.waitKey(0)
sobel("dave.jpg")
|
可以看到对 x,y 方向求导分别提取垂直和水平线条:
0 1 2 3 | def scharr(fname):
sobel(fname, scharr=1)
scharr("dave.jpg")
|
对比可以发现 Scharr 算子保留了更多细节轮廓:
cv2.CV_64F 设置输出图像的深度:一个从黑到白的边界的导数是整数,而一个从白到黑的边界点导数却是负数。如果原图像的深度是 np.uint8 时,所有的负值都会被截断变成 0,换句话说就是把把边界丢失掉。
所以如果这两种边界都想检测到,最好的的办法就是将输出的数据类型设置的更高,比如 cv2.CV_16S, cv2.CV_64F 等。取绝对值然后再把它转回到 cv2.CV_8U。
Laplacian 算子¶
拉普拉斯算子使用二阶导数的形式定义,可假设其离散实现类似于二阶 Sobel 导数,我们看一下它的处理效果:
0 1 2 3 4 5 6 7 8 9 10 | def laplacian(fname, ksize=3):
image = cv2.imread(fname)
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imshow("Blurred", image)
lap = cv2.Laplacian(image, cv2.CV_64F, ksize=ksize)
lap = np.uint8(np.absolute(lap))
cv2.imshow("Laplacian", lap)
cv2.waitKey(0)
laplacian("dave.jpg")
|
可以发现这些算子在滤波后,都会将非边缘区域转为黑色,边缘区域转为白色(饱和色),但是,噪声也很容易被错误地识别为边缘轮廓,可以在处理前考虑加入模糊处理。
边缘检测¶
边缘检测通常使用 Canny 算法。它是 John F.Canny 在1986 年提出的。它是一个有很多步构成的算法:包括噪声去除,计算图像梯度,非极大值抑制(NMS)和滞后阈值几部分。
在 OpenCV 中只需 cv2.Canny() 一个函数就可以完成以上几步:
- 第一个参数是输入图像。
- 第二和第三个分别是 threshold1 和 threshold2,大于 threshold2 的值被认为是边缘,小于 threshold1 的值不被认为是边缘 。位于中间的像素由连接性判断是否为边缘。
- 第三个参数设置用来计算图像梯度的 Sobel卷积核的大小,默认值为 3。取值为 3-7,越大边缘细节越多。
- 最后一个参数是 L2gradient,它可以用来设定求梯度大小的方程,默认为 False。
0 1 2 3 4 5 6 7 8 9 10 | def canny(fname, ksize=3):
image = cv2.imread(fname)
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imshow("Original", image)
canny = cv2.Canny(blurred, 30, 150, apertureSize = ksize)
cv2.imshow("Canny", canny)
cv2.waitKey(0)
canny("dave.jpg", 3)
|
Canny 两个阈值的设置对结果影响很大,如何自动适配这两个阈值呢?
边缘自动检测¶
基于对多数图像的统计计算来得出通用的边缘上下限计算规律。
识别和绘制轮廓¶
轮廓可以简单认为成将连续的点(连着边界)连在一起的曲线,具有相同的颜色或者灰度。轮廓在形状分析和物体的检测和识别中很有用。
- 为了更加准确,要使用黑白二值图。在寻找轮廓之前,要进行阈值化处理或者 Canny 边界检测。
- 查找轮廓的函数会修改原始图像。如果在找到轮廓之后还想使用原始图像的话,应该将原始图像存储到其他变量中。
- 在 OpenCV 中,查找轮廓就像在黑色背景中找白色物体:要找的物体应该是白色而背景应该是黑色。
cv2.findContours 用于在黑白二值图中查找轮廓,它接受三个参数:输入图像(二值图像),轮廓检索方式和轮廓近似方法:
轮廓检索方式 描述 cv2.RETR_EXTERNAL 只检测外轮廓 cv2.RETR_LIST 检测的轮廓不建立等级关系 cv2.RETR_CCOMP 建立两个等级的轮廓,上面一层为外边界,里面一层为内孔的边界信息 cv2.RETR_TREE 建立一个等级树结构的轮廓
轮廓近似办法 描述 cv2.CHAIN_APPROX_NONE 存储所有边界点 cv2.CHAIN_APPROX_SIMPLE 压缩垂直、水平、对角方向,只保留端点 cv2.CHAIN_APPROX_TX89_L1 使用teh-Chini近似算法 cv2.CHAIN_APPROX_TC89_KCOS 使用teh-Chini近似算法
在 OpenCV 3.4 以后版本返回两个参数:轮廓(list 类型)和轮廓的层析结构。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def edges(fname):
image = cv2.imread(fname)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (9, 9), 0)
canny = cv2.Canny(blurred, 50, 180, apertureSize=3)
cv2.imshow("canny", canny)
cnts, hierarchy = cv2.findContours(canny, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
print(cnts) # 打印找到的轮廓数目
image = cv2.drawContours(image, cnts, -1, (0,255,0), 2)
cv2.imshow("Coins", image)
cv2.waitKey(0)
edges("coin.jpg")
>>>
6 # 第二枚硬币有 2 个不连续的轮廓
|
仔细观察发现,我们已经制定了 cv2.RETR_EXTERNAL 以只检测外轮廓,但是第二个硬币的轮廓明显不正确,仔细放大 canny 返回的二值图像,可以发现外层边界是有缺口的,也即不是闭合的。此种情况可以考虑腐蚀膨胀来使得轮廓联通,或者通过计算外接圆(依具体情况选择形状)是否重合来消除。
cv2.drawContours 用于在原始图像上绘制轮廓。五个输入参数:原始图像,轮廓(数组列表),轮廓的索引(当设置为 -1 时,绘制所有轮廓),画笔颜色,线条宽度,返回绘制轮廓后的图像。
机器学习实战¶
本部分实战用例主要是对 Adrian Rosebrock博客 pyimagesearch,OpenCV 官网,19Channel 等提供实例的总结和验证,主要集中在计算机视觉领域。
实战主要聚焦在如下几个部分: - 模型的应用(目标检测(Object Detection),多目标检测,实时检测) - 模型的训练(数据收集,提取,归一化,训练,各类网络的识别) - 模型性能对比和算法改进(比较耗时,占比较少) - 嵌入式应用(树莓派/BeagleBoard,手机应用)
环境安装¶
caffe¶
尽管 tensorflow 和 pytorch 渐渐成为深度学习框架的主流,如果你拿到一个模型是基于其他框架训练而来的,如果要进行验证就需要相应的环境。好在跨平台的 Anaconda 提供了这一方便(令人稍许轻松)。
和其他计算机应用领域类似,配置环境这种体力密集型劳动在人工智能领域也不能幸免(AI 就是 AI,不是真正的I ^>^),甚至更甚(由于AI的快速发展,硬件算力不停升级,驱动不停更新,各类算法也层出不穷,所以软件框架也就不停更新,同时类似 Python 的胶水语言也在不停变动,导致版本依赖很强)。
Caffe(Convolutional Architecture for Fast Feature Embedding)是一种常用的深度学习框架,主要应用在视频、图像处理方面的应用上。
这里以 WIN10 上的 Anaconda 为例(强烈建议使用 Linux 操作系统,特别是 Ubuntu,大部分开源社区的成员对 Opensource 系统怀有异常的热情,你将能得到更好的帮助),在 Python 环境中配置 caffe。
- 使用 conda 创建 caffe 的 Python 应用环境,由于 caffe 指定依赖 Python 2.7 或者 Python 3.5,所以要为它另起炉灶(这也是为什么推荐 Anaconda 的原因:支持不同 Python 版本环境,且提供了各类机器学习库的源)。cmd 窗口查看当前 conda 的环境:
0 1 2 3 | > conda env list
# conda environments:
#
base E:\Anaconda3
|
笔者环境存在 base 环境,支持较新的 Python3.6。所以不满足 caffe 对 Python 版本的需求。创建 caffe-py3.5 环境:
0 1 2 3 4 5 6 | > conda create -n caffe-py3.5 python=3.5
> conda env list
# conda environments:
#
base E:\Anaconda3
# 新建的caffe-py3.5 环境,路径放置在 envs 目录下
caffe-py3.5 * E:\Anaconda3\envs\caffe-py3.5
|
在环境创建过程中,会安装一些最基本的程序包。成功后切换到新建的环境 Python3.5 环境:
0 1 2 3 | > activate caffe-py3.5
> python --version
Python 3.5.4 :: Continuum Analytics, Inc.
|
- 安装 caffe 依赖,必须要注意 protobuf==3.1.0 版本:
0 | > conda install --yes cmake ninja numpy scipy protobuf==3.1.0 six scikit-image pyyaml pydotplus graphviz
|
- 安装Windows 版 git 以下载 caffe 源码,注意源码放置为位置不要过深,也不要包含特殊字符,比如空格或者 . 之类字符,为了避免陷入奇怪编译的问题,建议放置在系统盘根目录下:
0 1 2 3 4 5 6 7 8 9 10 11 12 | > d:
> git clone https://github.com/BVLC/caffe.git
> git branch -a
* master
remotes/origin/HEAD -> origin/master
remotes/origin/gh-pages
remotes/origin/intel
remotes/origin/master
remotes/origin/opencl
remotes/origin/readme_list_branches
remotes/origin/tutorial
remotes/origin/windows
> git checkout windows # 切换到 windows 分支
|
切换到 windows 分支非常重要,否则根本无法编译。
- 打开 VS2015 x86 x64 兼容工具命令提示符,并使用 conda 切换到caffe-py3.5环境。进入 caffe 目录,执行 cmake .,配置编译环境。
0 1 | > cd caffe
> cmake .
|
cmake 默认使用 Ninja 编译器(速度比较快),但是可能出现找不到头文件的问题。笔者就遭遇了这种陷阱。
- 编译,进入 caffe 下的 scripts 目录,执行 build_win.cmd 。如果使用默认的 Ninja 编译器遭遇 ninja: build stopped: subcommand failed.
0 1 2 3 4 | 编辑 build_win.cmd 将所有
if NOT DEFINED WITH_NINJA set WITH_NINJA=1
替换为
if NOT DEFINED WITH_NINJA set WITH_NINJA=0
|
然后删除掉 scripts 目录下的 build 和 caffe 下的 CMakeFiles 和 CMakeCache.txt 文件,重新执行第 4 步。
- 编译完毕后,执行 caffe 依赖的其他安装包,requirements.txt 位于 caffepython 目录:
0 | > pip install -r requirements.txt
|
安装出现 leveldb 无法编译,可以在 requirements.txt 删除它,该库用于读取 Matlab 数据库文件,如果确实需要则需要手动编译安装。
- 安装 caffe 到 Anaconda 环境。 复制 pythoncaffe 文件夹到 E:Anaconda3envscaffe-py3.5Libsite-packages。书写 test.py 引用 caffe 进行测试。
不建议使用老版本或者不稳定版本的数据包,除非迫不得已。requirements 中需要 >= 版本都应该取等于,否则会出现依赖循环问题。
conda¶
conda 用于管理 Anaconda3 科学计算环境软件包。
环境管理¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # 环境相关
# 下面是创建python=3.6版本的环境,取名叫py36
conda create -n py36 python=3.6
# 删除环境
conda remove -n py36 --all
# 激活 py36 环境,windows 无需 source 命令前缀
activate py36
# 退出当前环境
deactivate
# 复制(克隆)已有环境
conda create -n py361 --clone py36
# 查看当前所有环境
conda env list
|
创建的环境路径位于 Anaconda 安装文件的 envs 文件夹下。
软件包管理¶
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # 查看当前环境信息
conda info
# 查看安装软件列表
conda list
# 查看软件包信息,软件包名称支持模糊查询
conda list python
# 查找软件包通道 channel
anaconda search -t conda pyqt5
# 安装软件包到 py36 环境,如果不指定环境,则作用到当前环境
conda install --name py36 numpy -c 指定通道
# 删除软件包,如果不指定环境,则作用到当前环境
conda remove --name py36 numpy
# 查询 conda 版本号
conda --version
|
在启动 Anaconda Navigator 或者 Sypder 遇如下问题时:
0 1 | # ModuleNotFoundError: No module named 'PyQt5.QtWebKitWidgets'
conda update -c conda-forge qt pyqt
|
cuda¶
一些深度神经网络可以使用 GPU 加速,例如 TensorFlow,它们底层会调用 NVIDA 的 cuda 计算库。
在 CUDA安装包归档 可以找到所有版本,在 CUDA使用文档 <https://docs.nvidia.com/cuda/index.html>_ 中查看CUDA的使用说明。 在Windows 上安装CUDA <https://docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/index.html#axzz410A2xbq6>_ 一文对安装环境要求和开发环境(Visual Studio)均进行了详细说明。
安装步骤如下:
- 在 支持 CUDA 的 GPU列表 <https://developer.nvidia.com/cuda-gpus>_ 中查看所用机器的 GPU 是否支持 CUDA。
- 在 CUDA安装包归档 可以找到所需 CUDA 版本
- 根据操作系统类型和版本下载并安装 CUDA
TensorFlow¶
通常使用 Keras 作为前端,TensorFlow 作为后端。Keras 提供了统一封装的API,以快速建模并验证。
tensorflow 分为 CPU 版本和 GPU 版本,这里以 Anaconda 环境安装 tensorflow-cpu 版本为例。
首先查看硬件显卡版本,选择安装 Cuda 版本。 然后根据 版本兼容性 安装 CuDNN。笔者 Notebook 为 NVIDA 940MX,选择安装 Cuda 8.0 和 CuDNN 6.0,接着安装 tensorflow-gpu 1.4.0 版本:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | # 创建python=tf36版本的环境,取名叫tf36
conda create -n tf36 python=3.6
activate tf36
# pip 安装 tensorflow,注意在WIN 环境不要使用 conda 命令安装,否则无法使用GPU加速
# 实际对应 tensorflow_gpu-1.4.0-cp36-cp36m-win_amd64.whl
pip install tensorflow-gpu==1.4.0
# 不要使用 conda 安装,否则会覆盖 tensorflow-gpu 环境
# 实际对应 Keras-2.2.4-py2.py3-none-any.whl
pip install keras
# 如果是已有环境,则需要列出 tensoflow 版本,进行 uninstall 卸载,然后使用 pip 重新安装
conda list tensorflow
|
在 Linux 的 Anaconda 环境可以直接使用 conda 安装 tensorflow-gpu 和 keras-gpu,看来问题出在 Anaconda 官方没有适配 Windows 相关依赖。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | def test_tf():
import tensorflow as tf
with tf.device('/gpu:0'):
# Creates a graph.
a = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[2, 3], name='a')
b = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[3, 2], name='b')
c = tf.matmul(a, b)
# Creates a session with log_device_placement set to True.
sess = tf.Session(config=tf.ConfigProto(log_device_placement=True))
# Runs the op.
print(sess.run(c))
if __name__ == "__main__":
test_tf()
|
如果环境配置成功,应该会得到类似的输出:
0 1 2 3 4 5 | ......
Found device 0 with properties:
name: GeForce 940MX major: 5 minor: 0 memoryClockRate(GHz): 1.2415
pciBusID: 0000:02:00.0
totalMemory: 2.00GiB freeMemory: 1.66GiB
......
|
keras¶
结合 Cuda,通过安装 pip install keras 后,可能需要配置 keras,文件位于 ∼/.keras/keras.json:
0 1 2 3 4 5 | {
"floatx": "float32",
"epsilon": 1e-07,
"backend": "tensorflow",
"image_data_format": "channels_last"
}
|
其中 backend 用于设置后端引擎,image_data_format 用于指定颜色通道顺序,对于 tensorflow 它就是 channels_last,而对于 Theano 则对应 channels_first。
在 keras 结合 tensorflow 应用时可能会遇到如下错误错误,说明版本不兼容:
0 | TypeError: softmax() got an unexpected keyword argument 'axis'
|
可以降低 keras 的版本:
0 | pip install --upgrade keras==2.1.3
|
或者更改代码为:
0 1 2 | import tensorflow as tf
# model.add(Activation("softmax"))
model.add(Activation(tf.nn.softmax))
|
另一种比较 hacking 的做法是,直接修改 tensorflow_backend.py 代码,找到 softmax 函数 axis 参数改为 dim 参数:
0 1 | # return tf.nn.softmax(x, axis=axis)
return tf.nn.softmax(x, dim=axis)
|
Numba¶
Numba 是一个优化计算密集型 Python 代码的软件包,和 Anaconda 师出同门,基于 LLVM(Low Level Virtual Machine)编译器在运行时(JIT,Just in time)将 Python 代码编译为本地机器指令,而不会强制大幅度的改变普通的Python代码(使用装饰器修饰即可)。
Numba 的核心应用领域是 math-heavy(强数学计算领域)和 array-oriented(面向数组)功能,它们在 Python 中执行相当缓慢(实际上它是多层 for 循环的强力克星)。如果在 Python 中编写一个模块,必须循环遍历一个非常大的数组来执行一些计算,而不能使用向量操作来加速。所以“通常”这类库函数是用 C,C ++ 或Fortran编写的,编译后,在Python中作为外部库使用。Numba 使得这类函数也可以写在普通的 Python 模块中,而且运行速度的差别正在逐渐缩小(官方宣称可以达到原生代码的效率)。
0 | conda install numba
|
Numba 的使用异常简单,只需要在需要优化的函数前添加函数装饰器,Numba 提供多种装饰器和装饰器参数,最简单的应用如下所示:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # 导入运行时优化装饰器 jit
from numba import jit
@jit
def test_numba(size=10000):
total = 0.0
bigmatrix = np.ones((size,size))
start = time.time()
for i in range(bigmatrix.shape[0]):
for j in range(bigmatrix.shape[1]):
total += bigmatrix[i, j]
print("bigmatrix sum cost walltime {:.02f}s".format(time.time()-start))
return total
test_numba()
|
对比结果令人印象深刻,大约有100倍的时间差距,Numba 非常适用于优化大量 for 循环的情况,更深入的参数使用参考 Numba 用户指南 。
0 1 | bigmatrix sum cost walltime 44.37s
bigmatrix sum cost walltime 0.41s
|
注意被 Numba 修饰器修饰的函数中不可使用 import 或者 from 语句导入第三方软件包。
numexpr¶
安装 numexpr 非常简单,它是专门针对 numpy 表达式的加速包。
0 | conda install numexpr
|
numexpr 的使用也很简单:
0 1 2 3 4 5 6 | import numpy as np
import numexpr as ne
# 矩阵越大效果越好
a = np.arange(1e6)
>>> ne.evaluate("a + 1")
|
numpy 对矢量操作优化的一个缺陷是它一次只能处理一个操作。这意味着,当我们对 numpy 矢量进行 A * B + C 这样的操作时,首先要等待 A * B 操作完成, 数据保存在一个临时矢量中,然后将这个新的矢量和 C 相加。
numexpr 模块可以将整个矢量表达式编译成非常高效的代码,可以将缓存失效以及临时变量的数量最小化。
深入使用 numexpr 参考 numexpr 官方指南 。
写在前面¶
相关软硬平台¶
Intel OpenVINO /RealSense / Movidius ARM Tengine
NumPy 可以配置为使用线程数字处理器库(如MKL)。
移动端迁移学习方案 Apple turicreate CoreML ->iOS Google Tensorflow -> Android Facebook PyText(文本分类),ParlAI(智能会话)
PyText是基于NLP深度学习技术、通过Pytorch构建的建模框架。PyText解决了既要实现快速实验又要部署大规模服务模型的经常相互冲突。它主要通过以下两点来实现上面的需求:
- 通过为模型组件提供简单且可扩展的接口和抽象,
- 使用PyTorch通过优化的Caffe2执行引擎导出模型,进行预测推理。
加速:cython or OpenMP https://www.openmp.org/
流程和优化¶
使用模型训练(深度学习神经网络)的流程:采集数据,尽可能多的采集广泛的数据(采集范围根据需求确定,根据需要进行精确处理:数据清洗),并准确标注。训练,可以多模型调参,并对比性能,导出模型。在实际应用环境,采集到的数据必须进行同样的精确预处理,通过模型进行识别,大体流程:
- 数据采集,通常由程序自动完成,比如从大量不同类型的视频中采集人脸,然后通过人工剔除错误信息(否则再多数据都白给),关键点标注(关键点也可以由程序完成,但需要人工进行后期的精确调整)
- 数据处理,采集到的样本可能大小,颜色,所占图片位置不同,所以要进行精确处理。
- 选择合适的模型,或者多个模型以进行效果对比
- 实际应用场景进行验证,性能,效果,然后把错误数据继续反馈到模型继续训练,提高模型的鲁棒性。
性能不达标:
- 错误率高 1.软调节:数据是否准确,规模是否足够大到能满足需求,训练数据够好,则更新算法 2.硬调节,更换更高更好的传感器,提高分辨率和响应速度
- 速度慢 1.软调节:升级模型算法(需要有所突破)或者根据具体场景,来缩小图片尺寸,代价是距离远了,识别率变差;或者并行改串行,多线程处理;硬调节,增加多传感器,对应多线程处理;升级CPU,升级GPU,升级DSP,升级FPGA,根据SOC厂家解决方案来定(工程量不小,开始原型预研就要估计好数据量,莫盲目乐观)。
关于”AI应用”的歪思考¶
我所居住的小区后面有个地铁口,巧合的是在北阳台透过窗户,就可以完全看到它,于是我就把一个摄像头对准了这个出入口,并统计从早上 6:00 到晚上 6:00 出入该地铁口的人流,尽管有些距离,通过调焦还是可以看清进出的每个人,这对于识别人群的个体很有帮助。通过收集的数据,可以轻松的获取这入口人流数据,可以想象如果可以统计多个地段出入口数据就可以大体估计出这个城市的通勤情况。如果有长期的数据统计,那么可以得到很多更有趣的统计信息,比如人流的潮汐现象,每天或者每个月不同时期进出人流情况。顺便可以分析下男女占比,甚至着装颜色,只要发挥想象力,甚至可以统计下多少人是从地铁口的早餐摊买食物,进而分析下这个摊点的盈利状况。
周末带着四螺旋桨遥控飞机陪着小朋友玩,无意中发现很多楼房的顶层都装有太阳能热水器,不妨统计下热水器的品牌分布。由于这一片都是新小区,所以这个分布能在一定程度上反应该品牌在该城市的受欢迎程度。如果能够对城市的不同区域的小区进行采样,这个数据的分布就要正确得多。
晚上带着小朋友在车库玩滑板车,通过遥控飞机在车库来回飞行,进行车辆品牌的识别,甚至车辆的型号,非常容易统计出各个品牌在该片区的销售情况,如果能把数据扩大到多个小区,那么这个分布就非常可信了。
突然湖边有一群野鸟从树丛中飞起,并向着对岸飞去,掏出手机拍照上传到我的微信小程序,它的后端就是云服务器,服务器上的识别程序告诉我一共有18只,虽然无法识别这是什么鸟类,却告诉了这一群飞鸟的数目,这在生态学研究中很重要(人工去统计种群数目成本昂贵)。如果要对一片野生动物栖息地里的动物进行数量统计,特别是草原地区,那么使用遥控飞机拍照识别是没有再简单省事的了。
远处是串流不息的大运河,并且过往船只繁多,在高楼上也可以看到,把数据采样分析,就可以知道这条水运路线的繁忙程度以及船只吨位的分布了,如果视频数据够清晰,还可以识别所载货物种类。我现在才知道很多加油站的燃油均是通过水路运输的。长期的数据积累将会反应出更有趣的真相,如果可以分析每条船的所属地区,那么就可以大概知道货物去往了哪里……
如果把这种应用放在人造卫星上,用途就更是大得多了(可以想见人造卫星上的大数据所能揭露的真相有多么惊人!)。当然在微观领域,也有很大的用途,比如识别和统计显微镜下的细胞或者细菌数量。
简单的颜色,形状甚至运动物体的识别无需人工智能的加持也可以工作得很好,但是复杂的事物识别就需要在大型机上训练好分类算法模型,比如手势,脸部识别,甚至表情识别,动态物体跟踪等等。更复杂环境下的识别就需要愈加复杂的模型和算力支持,并且要考虑实时性和耗能,比如智能驾驶和机器人领域。
当前阶段的人工智能远飞人们想象的智能,并且还相当遥远。大多数据的模型算法都是通过大量数据分析出其中的规律,所以只能算是统计模型。并且严重依赖严谨的准确的数据,而数学模型简单还是复杂对预测准确性并没有直接关系,只要模型正确,结果一定相差不大,都能正确反映出训练数据的模式规律。
无论简单还是复杂的人工智能算法都无法从不准确的大数据中分析出准确的规律,也不可能从准确大数据中分析出离谱的预测模型,否则这种模型早就被淘汰了。一定要相信能够在学术和应用领域流传至今的知名算法都是经过长期验证的。同时不要盲信那些准确率高达吓人地步的模型,没有透明的训练数据,测试数据,训练耗时以及算法的可控性,复杂度的同时对比,只有一个准确率有什么意义。
实践证明,不同的算法模型在准确性上除了与一些模型参数有关外,在相同的训练数据基础上,结果都是大同小异。很多准确率宣称 99% 的模型一旦拿到实际的应用环境,其结果就连作者自己都大跌眼镜。为什么会出现这种情况?它与训练数据的真实的有效值(ground truth)到底是多少有关。一个数据集常常使用相同的方式(局限于特定的采集软件或者人工来采样生成)来获取,一部分用来训练,一部分用来验证,其结果只在这非常局限的缺乏真实应用环境的有效值上表现很好,有什么用呢?
可以看到无数人拿 mnist 或者 kaggle 数据集来练手,并且得出很好的结果,但是很少人拿训练模型去真实环境去测试验证,其正确性能有 80% 都不错了。为什么?不同地域,人们的书写习惯会不同,同时书写习惯也会随时间而改变,不同年龄段的人书写的规范程度也不一样,这些还只是真实环境错误预测的一小部分因素。现实中的人类可以根据数字所处的上下文来猜测模糊数字,或者不同格式的数字,例如 2^3,不会被认为是 2 和 3 而是 2 的立方。如果数字序列 3 5 7 9 中的 5 模糊掉了,那么人可以通过常识规律推测 5,而这种数学模型通过图像的特征进行识别就无能为力了。
所以人工智能在现实应用中既有非常大的限制,又有很大的用途。总结下来有几点:必须限制应用环境,复杂的应用环境准确性将严重下降,直至不可接受。其次必须是接受预测误差的应用场景,如果要求百分百准确,那么人工智能应用就只可以作为辅助(即便是作为辅助,它的威力依然惊人,如果在某种工作环节上它的准确性可以达到98%,那么这个工种环节就可以节约 98% 的人力费用,原来需要 100 个人的工作只需要 2 个人专门处理低置信度的未决预测就可以了,并且可以把这些错误预测收集归纳来训练新的模型,这样错误率就会越来越低,直至错误率低到无需人工干预也是可以接受的了)。 此外要认识到训练数据的准确性极其重要,不要期望通过调整模型来从不准确的数据中得出准确的预测结果。另外如果需要人工介入,就使用人工介入,人机交互中,人类具有一定的容忍度:比如谷歌搜索引擎会提示用户你要找是不是“xxx”,而不是在那里胡乱用复杂算法去猜测用户的想法,那样只会让体验愈加糟糕。
算法不能产生不存在的信息,Data talks。
迁移学习的思考¶
如果已经训练过一些模型,比如人脸识别,而要识别驴脸(纳尼,什么应用?),可能就麻烦了。人脸图片容易找,狗脸数据还能马马马虎凑合找到,更复杂的要识别驴脸麻烦就大了。另一特殊的样本采集起来可能非常麻烦,比如野生动物,或者特殊应用领域:微观领域(细胞,比如饮用水水质监测),宏观领域(航空,深空)。
还有上文的示例:现实中的人类可以根据数字所处的上下文来猜测模糊数字,或者不同格式的数字,例如 2^3,不会被认为是 2 和 3 而是 2 的立方。人类识别一样物品,例如狗狗,并不需要看太多狗的图片,而能从已有知识来加速学习:动物,有毛,四条腿,有尾巴,有耳朵,比马小,比猫大,叫起来汪汪。
迁移学习的本质就是基于已建立的深度神经网络模型对其中的部分层使用新数据集调节部分网络层权重(再训练)。这一技术从根本上解决了增量分类的重复训练问题。
Google 发布的 Inception 或 VGG16 这样成熟的物品分类的网络,只训练最后的 softmax 层,你只需要几千张图片,使用普通的 CPU 就能完成,而且模型的准确性不差。 Apple Turicreate 也是基于迁移学习,从而可以快速训练 CoreML 模型并部署到 iOS 上。
尽管如此,一堆所谓的有向无环图的“节点”(神圣地被称为“神经元”)组成的网络离真正意义上的“智能”还差得太远。
如果最终高效的人工智能算法模型被少数大公司垄断,只提供一些 API 接口(基本上这是一个趋势),那么人工智能的未来又该如何发展?
一些有趣的实践¶
尽管机器学习和深度学习被大多应用于计算机视觉和自然语言(NLP)领域,但是如果把它放在其它领域其结果也会令人感到不可思议:
最近在从某网抽取数据来分析招聘信息,只从非常宏观的角度,就可以明显看出一个地区的产业分布(企业),人才层次分布,从这一分布就不难预测未来该地区的发展趋势。(政策层面如何量化?这确实是一个很大的变数,从各大官媒新闻报道中提及某些关键词频率入手?)。稍微细致分析,就可以看出某些公司的发展方向,人才储备的趋势变化。跟踪特定地区和公司的招聘变化相信将会有更大的发现。
再从雪球网抽取证券相关的评论信息(个人认为对于金融相关的预测过于关心过去的指数变化意义不大,反而可能从人的言行情绪上是一个不错的切入点),发现在负面情绪(负面分词占比很大)非常严重时,市场就开始具有不错的参与度(在不就的将来的收益很可能是超预期的),当然还要结合实际的宏观经济数据模型,不过至少它可以作为一个不错的特征指标,来衡量市场的冷热度。
当前阶段,人工智能领域最应该关注的趋势就是,算法模型向实际应用场景的落地。过多资源流向了算法研究,耗费在一堆参数上,而这些算法模型如何应用在各行各业,各个细分领域来产生实际的价值?
实战¶
令人印象“深刻”的示例¶
人脸识别¶
有一次和一个朋友一起坐火车,入站的验票口不知被何时升级成了人脸自动识别系统,作为非计算机领域工作的朋友自然大为惊讶,一直在感叹世界变化太快!
opencv 源码中自带了一些人体识别的相关模型(人脸,身体或者眼球),它们位于 Library/etc/haarcascades 文件夹下,格式为 xml 文件。 haarcascade_frontalface_default.xml 就是较常使用的人脸识别模型之一。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | # face_detect_haar.py
# load opencv to handle image
import cv2
# load haar model and get face classifier
faceModel = FaceDetector(r"models/haarcascades/haarcascade_frontalface_default.xml")
faceClassifier = cv2.CascadeClassifier(faceModel)
# load jpg file from disk
image = cv2.imread("imgs/face.jpg")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# get all faces returned in rects
faceRects = faceClassifier.detectMultiScale(gray,
scaleFactor=1.5,
minNeighbors=5,
minSize=(30,30))
print("I found %d face(s)" % (len(faceRects)))
# draw rects on image and show up
for x,y,w,h in faceRects:
cv2.rectangle(image, (x,y), (x+w, y+h), (0, 255, 0), 2)
cv2.imshow("Faces", image)
cv2.waitKey(0)
|
短短几行代码就可以实现图片中人脸的识别:
- 首先导入 opencv,这里使用的版本为 4.0.1。这里 cv2 用于图片加载和保存,它是一个非常强大的图像处理库。
- 加载模型文件,并获取人脸分类器 faceClassifier。
- 从磁盘加载图片文件,由于 opencv 自带的人脸分类器只支持灰度图,这里先把 RGB 彩图转换为灰度图
- 使用分类器的 detectMultiScale 方法检测人脸,这里暂不讨论这些参数
- 打印识别到的人脸数目,同时在图像上绘制矩形并弹出显示窗口。
执行以上脚本:
0 1 | $ python face_detect_haar.py
I found 2 face(s)
|
初次看到这类效果的人一定大为惊讶,并赞叹人工“智能”的神奇。
但是且慢,我们尝试对图片做一个最基本的缩放操作,再看看效果如何,为此我们增加一个缩放函数,并重新调整代码框架。
0 1 2 3 4 | def img_resize(img, ratio=0.5, inter=cv2.INTER_AREA):
w = img.shape[1] * ratio
h = img.shape[0] * ratio
return cv2.resize(img, (int(w), int(h)), interpolation=inter)
|
以上定义了一个缩放函数,ratio 指定了宽高缩放比,如果它小于1,图像将被缩小,否则将被放大。
接着定义处理参数的相关函数,以便传递参数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import cv2
import argparse
def args_handle():
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=False,
default=r"imgs/face.jpg",
help="path to input image")
ap.add_argument("-m", "--model", required=False,
default=r"models/haarcascades/haarcascade_frontalface_default.xml",
help="path to opencv haar pre-trained model")
return vars(ap.parse_args())
g_args = None
def arg_get(name): # 获取参数
global g_args
if g_args is None:
g_args = args_handle()
return g_args[name]
|
这里的参数列表只定义了名为 –image 和 –model 的两个参数,分别指定要进行人脸识别的图像路径和模型路径。接着封装一个用于人脸识别的 FaceDetector 类:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | class FaceDetector():
def __init__(self, model):
self.faceClassifier = cv2.CascadeClassifier(model)
# handle cv2 image object
def detect_img(self, img, gray=1):
gray = img if gray == 1 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return self.faceClassifier.detectMultiScale(gray,
scaleFactor=1.5,
minNeighbors=5,
minSize=(30,30))
# handle image file
def detect_fimg(self, fimg, verbose=0):
# load jpg file from disk
image = cv2.imread(fimg)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
faceRects = self.detect_img(gray, 1)
# draw rects on image and show up
for x,y,w,h in faceRects:
cv2.rectangle(image, (x,y), (x+w, y+h), (0, 255, 0), 2)
return image
def show_and_wait(self, image, title=' '):
cv2.imshow(title, image)
cv2.waitKey(0)
|
在 face_batchdetect_haar 中通过 img_resize 调整缩放比例从 10% 到 200% 以 10% 步长循环处理,然后对缩放过的图像进行人脸识别。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def face_batchdetect_haar_size(model_path, fimg):
img = cv2.imread(fimg)
FD = FaceDetector(model_path)
for i in range(1, 21, 1):
ratio = i * 0.1
newimg = img_resize(img, ratio, inter=cv2.INTER_AREA)
faceRects = FD.detect_img(newimg, gray=0)
faces = len(faceRects)
print("I found {} face(s) of ratio {:.2f} with shape{}".format(faces,
ratio, newimg.shape))
for x,y,w,h in faceRects:
cv2.rectangle(newimg, (x,y), (x+w, y+h), (0, 255, 0), 2)
if faces != 2 and faces != 0:
FD.show_and_wait(newimg)
model_path = arg_get('model')
face_batchdetect_haar(model_path, 'imgs/face.jpg')
|
迫不及待等待结果。很可惜这个结果令人大跌眼镜,如果缩小图片另识别率降低可以情有可原(因为很小的图片,人眼也难以识别物体),竟然放大后的图片也会有问题,而且问题是各种各样,以示例图片的结果对此模型说明:
- 太小的分辨率无法识别图片,缩放到 20% 以下的图片已经无能为力
- 缩放到 50% 和 110% 的图片竟然能识别出 4 张人脸?
- 缩放到 80%,120%,160% 和 180% 的图片更神奇,识别出 3 张脸
不过可以看到图片的分辨率越小,越难以识别人脸,而不适当的分辨率也会导致识别出错,分辨率越大越不会丢失人脸,但是不要指望能保证正确率。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | $ python face_detect_haar.py
I found 0 face(s) of ratio 0.10 with shape(29, 60, 3)
I found 0 face(s) of ratio 0.20 with shape(59, 120, 3)
I found 2 face(s) of ratio 0.30 with shape(89, 180, 3)
I found 2 face(s) of ratio 0.40 with shape(118, 240, 3)
I found 4 face(s) of ratio 0.50 with shape(148, 300, 3)
I found 2 face(s) of ratio 0.60 with shape(178, 360, 3)
I found 2 face(s) of ratio 0.70 with shape(207, 420, 3)
I found 3 face(s) of ratio 0.80 with shape(237, 480, 3)
I found 2 face(s) of ratio 0.90 with shape(267, 540, 3)
I found 2 face(s) of ratio 1.00 with shape(297, 600, 3)
I found 4 face(s) of ratio 1.10 with shape(326, 660, 3)
I found 3 face(s) of ratio 1.20 with shape(356, 720, 3)
I found 2 face(s) of ratio 1.30 with shape(386, 780, 3)
I found 2 face(s) of ratio 1.40 with shape(415, 840, 3)
I found 2 face(s) of ratio 1.50 with shape(445, 900, 3)
I found 3 face(s) of ratio 1.60 with shape(475, 960, 3)
I found 4 face(s) of ratio 1.70 with shape(504, 1020, 3)
I found 3 face(s) of ratio 1.80 with shape(534, 1080, 3)
I found 2 face(s) of ratio 1.90 with shape(564, 1140, 3)
I found 2 face(s) of ratio 2.00 with shape(594, 1200, 3)
|
到此我们对该模型的处理机制一无所知,它首先带来了惊喜,当然更多的是失望。这一模型被大家所诟病的问题不仅如此:它还会误识别,也即把根本不是人脸的图像识别为人脸;当人脸不是正面时,稍有角度不同识别率极度下降,正如模型的名称 frontalface 所讲。
不过从无到有总是困难的,这一模型至少说明人脸是可以通过计算机识别出来是可行的,而正确率是可以通过各种方式改善的。暂时忘记正确率吧,我们还可以在它上面继续挖掘一些有用的东西。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | def face_batchdetect_haar(model_path, fimg):
import time
img = cv2.imread(fimg)
FD = FaceDetector(model_path)
for i in range(1, 21, 1):
ratio = i * 0.1
newimg = img_resize(img, ratio, inter=cv2.INTER_AREA)
# time cost
start = time.process_time()
for i in range(0, 10):
faceRects = FD.detect_img(newimg, gray=0)
end = time.process_time()
faces = len(faceRects)
print("I found {} face(s) of ratio {:.2f} with shape{} cost time {:.2f}".format(faces,
ratio, newimg.shape, end - start))
'''
for x,y,w,h in faceRects:
cv2.rectangle(newimg, (x,y), (x+w, y+h), (0, 255, 0), 2)
if faces != 2 and faces != 0:
FD.show_and_wait(newimg, "{:.2f}".format(ratio))
'''
|
以上代码对不同的图像大小统计人脸识别的耗时,这在实时处理的应用场景非要重要。对每种大小图片统计处理 10 次的时间:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | $ python face_detect_haar.py
I found 0 face(s) of ratio 0.10 with shape(29, 60, 3) cost time 0.00
I found 0 face(s) of ratio 0.20 with shape(59, 120, 3) cost time 0.03
I found 2 face(s) of ratio 0.30 with shape(89, 180, 3) cost time 0.02
I found 2 face(s) of ratio 0.40 with shape(118, 240, 3) cost time 0.11
I found 4 face(s) of ratio 0.50 with shape(148, 300, 3) cost time 0.09
I found 2 face(s) of ratio 0.60 with shape(178, 360, 3) cost time 0.33
I found 2 face(s) of ratio 0.70 with shape(207, 420, 3) cost time 0.25
I found 3 face(s) of ratio 0.80 with shape(237, 480, 3) cost time 0.12
I found 2 face(s) of ratio 0.90 with shape(267, 540, 3) cost time 0.53
I found 2 face(s) of ratio 1.00 with shape(297, 600, 3) cost time 0.62
I found 4 face(s) of ratio 1.10 with shape(326, 660, 3) cost time 0.55
I found 3 face(s) of ratio 1.20 with shape(356, 720, 3) cost time 0.86
I found 2 face(s) of ratio 1.30 with shape(386, 780, 3) cost time 1.03
I found 2 face(s) of ratio 1.40 with shape(415, 840, 3) cost time 0.84
I found 2 face(s) of ratio 1.50 with shape(445, 900, 3) cost time 1.03
I found 3 face(s) of ratio 1.60 with shape(475, 960, 3) cost time 1.14
I found 4 face(s) of ratio 1.70 with shape(504, 1020, 3) cost time 1.41
I found 3 face(s) of ratio 1.80 with shape(534, 1080, 3) cost time 1.58
I found 2 face(s) of ratio 1.90 with shape(564, 1140, 3) cost time 1.64
I found 2 face(s) of ratio 2.00 with shape(594, 1200, 3) cost time 1.80
|
上面的结果很令人满意:清楚的规律是,图像越大处理的耗时越长。笔者的笔记本 CPU 主频为 2.6GHz,常见的摄像头分辨率为 640*480,帧率 25-30,对应到上面的数据不难猜测大约为 1s,也即 1s 内处理 10 张 640*480 分辨率的图片,这似乎不是一个好消息。也即我们要丢到一半的帧率,如果对实时性要求很高,且不能丢帧,即便不从正确性上考虑,那么这个模型也有点悬。
如果要在嵌入式平台运行以上代码,并达到实时性要求,那么由于 ARM 之类的芯片主频没有笔记本主频这么高,那么就要考虑从硬件(DSP,FPGA,GPU)和软件(使用更高性能的编程语言/并行/图像缩小)两方面进行性能提升。
视频中识别人脸¶
如果能从图片中识别出人脸,那么从视频数据中识别出人脸就不会很困难:由于视频流就是有多幅图片“组成的”,所以只要针对视频中的每一幅图片处理就可以达到目的了。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | def face_detect_camera(model_path, show=0):
import time
frames = 0
camera = cv2.VideoCapture(0)
start = time.process_time()
FD = FaceDetector(model_path)
while(camera.isOpened()):
grabbed, frame = camera.read()
if not grabbed:
print("grabbed nothing, just quit!")
break
faceRects = FD.detect_img(frame, gray=0)
frames += 1
fps = frames / (time.process_time() - start)
print("{:.2f} FPS".format(fps), flush=True)
if not show: # show video switcher
contine
cv2.putText(frame, "{:.2f} FPS".format(fps), (30, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
cv2.imshow("Face", frame)
if cv2.waitKey(1) & 0xff == ord('q'):
break
camera.release()
cv2.destroyAllWindows()
model_path = arg_get('model')
face_detect_camera(model_path)
|
我们从摄像头抓取视频帧,然后进行处理,首先跳过所有不必要的处理(这些处理我们可以放在其它线程或者进程中):
0 1 2 3 4 5 | 32.43 FPS
32.00 FPS
32.07 FPS
32.14 FPS
31.92 FPS
......
|
在最理想的情况下我们得到了以上结果,但是如果把笔记本的 2.6GHz 的算力换算到嵌入式平台上,情况依然不容乐观。到此为止我们打开视频流相关的代码,看看会发生什么:
0 1 | model_path = arg_get('model')
face_detect_camera(model_path, show=1)
|
帧率大约是 16 FPS,当然我们可以从软件层面挽回这一大约一倍的时间损失。
haar 模型的进一步思考¶
既然可以从图片尺寸和耗时上来考虑一个算法模型,那么我们不妨走得更远一些,看看会发生什么。
我们可以将图片围绕中心旋转,这是非常容易做到的。另外为了在旋转时头像始终处在图片之中,这里使用只有一张梦露脸的图片,且脸部基本位于图片中央。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | def rotate(image, angle):
'''roate image around center of image'''
h, w = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
return cv2.warpAffine(image, M, (w, h))
# rotate a picture from 0-180 angle to check accuracy
def face_batchdetect_haar_rotate(model_path, fimg):
import time
img = cv2.imread(fimg)
FD = FaceDetector(model_path)
for angle in range(0, 190, 10):
newimg = rotate(img, angle)
# time cost
start = time.process_time()
for i in range(0, 10):
faceRects = FD.detect_img(newimg, gray=0)
end = time.process_time()
faces = len(faceRects)
print("I found {} face(s) of rotate {} with shape{} cost time {:.2f}".format(faces,
angle, newimg.shape, end - start))
for x,y,w,h in faceRects:
cv2.rectangle(newimg, (x,y), (x+w, y+h), (0, 255, 0), 2)
if faces != 1 and faces != 0:
FD.show_and_wait(newimg, "Rotate{}".format(angle))
model_path = arg_get('model')
face_batchdetect_haar_rotate(model_path, arg_get('image'))
|
结果令人大跌眼镜,旋转超过 10 度以后再难以识别出人脸,这令人不禁怀疑为何此模型的泛化能力如此之差?如果尝试在 -10到10度之间旋转,模型还是可以识别出人脸,这说明模型在训练之初使用的数据很可能没有考虑这种特殊情况。
0 1 2 3 4 5 6 7 8 9 10 | $ python face_detect_haar.py -i imgs/Monroe.jpg
I found 1 face(s) of rotate 0 with shape(480, 640, 3) cost time 0.84
I found 0 face(s) of rotate 10 with shape(480, 640, 3) cost time 0.48
I found 0 face(s) of rotate 20 with shape(480, 640, 3) cost time 0.61
......
I found 0 face(s) of rotate 130 with shape(480, 640, 3) cost time 0.53
I found 1 face(s) of rotate 140 with shape(480, 640, 3) cost time 0.53
I found 0 face(s) of rotate 150 with shape(480, 640, 3) cost time 0.50
I found 0 face(s) of rotate 160 with shape(480, 640, 3) cost time 0.67
I found 0 face(s) of rotate 170 with shape(480, 640, 3) cost time 0.52
I found 0 face(s) of rotate 180 with shape(480, 640, 3) cost time 0.73
|
如果我们只是对图片进行水平和垂直方向的平移,那么识别率会怎么变化?理论上应该不会有影响。事实却非如此。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def translation(image, x, y):
'''move image at x-axis x pixels and y-axis y pixels'''
M = np.float32([[1, 0, x], [0, 1, y]])
return cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
def face_batchdetect_haar_move(model_path, fimg):
import time
img = cv2.imread(fimg)
FD = FaceDetector(model_path)
for move in range(0, 100, 10):
newimg = translation(img, move, move)
# time cost
start = time.process_time()
for i in range(0, 10):
faceRects = FD.detect_img(newimg, gray=0)
end = time.process_time()
faces = len(faceRects)
print("I found {} face(s) of move {} with shape{} cost time {:.2f}".format(faces,
move, newimg.shape, end - start))
for x,y,w,h in faceRects:
cv2.rectangle(newimg, (x,y), (x+w, y+h), (0, 255, 0), 2)
#if faces != 1 and faces != 0:
FD.show_and_wait(newimg, "Move{}".format(move))
model_path = arg_get('model')
face_batchdetect_haar_move(model_path, arg_get('image'))
|
结果还是令人大跌眼镜,将图像向右下方以 10 像素每步移动,有时可以识别,有时失败,毫无规律可循。这说明此模型对背景敏感,由于我们在旋转和平移时背景均被填充为了黑色,这与原图的背景色并不完全一致。笔者尝试在识别前进行高斯模糊,效果就出现了改善。
0 1 2 3 4 5 6 7 8 9 10 | $ python face_detect_haar.py -i imgs/Monroe.jpg
I found 1 face(s) of move 0 with shape(480, 640, 3) cost time 0.66
I found 1 face(s) of move 10 with shape(480, 640, 3) cost time 0.48
I found 0 face(s) of move 20 with shape(480, 640, 3) cost time 0.53
I found 1 face(s) of move 30 with shape(480, 640, 3) cost time 0.52
I found 1 face(s) of move 40 with shape(480, 640, 3) cost time 0.48
I found 0 face(s) of move 50 with shape(480, 640, 3) cost time 0.45
I found 0 face(s) of move 60 with shape(480, 640, 3) cost time 0.64
I found 1 face(s) of move 70 with shape(480, 640, 3) cost time 0.56
I found 0 face(s) of move 80 with shape(480, 640, 3) cost time 0.55
I found 0 face(s) of move 90 with shape(480, 640, 3) cost time 0.52
|
经历了漫长的测试验证,我们将该模型最为黑盒使用,依然对模型本身不甚了解,但是至少可以知道不要轻易对一个看起来令人“惊喜”的模型太过乐观,对它们的使用常常是有严格限制条件的。好吧,就从这里开始人工智能的实战之路。
距离和kNN分类¶
勾股定理(毕达哥拉斯定理)是数学史上最伟大定理之一,除了因为它引入了无理数,还因为它使得几何距离在坐标中可以计算,它把坐标张开成面和3维空间,甚至高维空间。
人类生活的3维世界被形形色色的物体充满,有些还无色无味,为了描述这些物体,区分和应用,从感官层面人类发展出各类描述词汇,形状,颜色,味道,密度,重量等等。
所有事物似乎都可以用一棵树一样的形状进行从粗到细的分类,比如生物学上的界门科目属种。离根越近的分类,它们的共同点就越接近本质,而离树梢越近的分类就只有细微的区别,同一个末梢上的分支也就具有更多的相同特征,比如哈士奇和萨摩耶。人类在描述相近事物时彼此已经建立了共同的理解基础,所以只要说是犬类,大家都明白毛茸茸,有四条腿,有耳朵,有尾巴,叫起来汪汪。没有人会描述这些共同的特征来介绍一只狗,而是直接说出区别于其他犬种的细节,比如体型小,善狩猎等等。
我们不想一开始就区分两种犬类的图片,而是从更少特征值的区分上进行入手。
考虑数字 1 和 2,以及 10000,我们自然认为 1 和 2 非常接近,但是计算机没有这种感觉,它没法感觉远近,只不过是内存中存储的二进制而已。在计算机中所有的数据都是二进制数据,要感知距离就需要给计算机规则,从计算上来区分距离。
从主观猜测开始¶
计算机中的数与数之间的距离可以用减法定义,而一组数和另一组数之间的距离就可以用向量距离来定义(这就用到了勾股定理)。一张图片就是一组像素值,是否可以把像素值直接展成一维向量,来计算它们之间的距离,如果对两张复杂图片适用,那么对于最简单的二值图像更会适用。这里不妨拿出最简的四个像素来组成一幅二值图。
只有 4 个像素的二值图图片依然可以表达非常丰富的信息,因为有 2^4 = 16 种组合。可以想见人们在一个 20*20 的像素方格内书写 0-9,相对于整个组合的情况是多么地稀疏。我们只使用了像素空间的很小部分,以便于人眼的识别,所以这里我们使用四个像素生成 3 幅图,分别对应符号 “-|_”,这对于人眼一目了然。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | import numpy as np
import cv2
def bitwise(imga, imgb=None, opt='not'):
'''bitwise: and or xor and not'''
if opt != 'not' and imga.shape != imgb.shape:
print("Imgs with different shape, can't do bitwise!")
return None
opt = opt.lower()[0]
if opt == 'a':
return cv2.bitwise_and(imga, imgb)
elif opt == 'o':
return cv2.bitwise_or(imga, imgb)
elif opt == 'x':
return cv2.bitwise_xor(imga, imgb)
elif opt == 'n':
return cv2.bitwise_not(imga)
print("Unknown bitwise opt %s!" % opt)
return None
def vector_dist(V0, V1):
from numpy import linalg as la
V0 = np.array(V0).astype('float64')
V1 = np.array(V1).astype('float64')
return la.norm(V1 - V0)
def show_simple_distance():
gray0 = np.array([[0,0],[255,255]], dtype=np.uint8)
gray1 = gray0.transpose()
cv2.imshow('-', gray0)
cv2.imshow('|', gray1)
gray2 = bitwise(gray0, None, opt='not')
cv2.imshow('_', gray2)
g01 = vector_dist(gray0, gray1)
g02 = vector_dist(gray0, gray2)
print("distance between -| is {}, distance between -_ is {}".format(int(g01), int(g02)))
cv2.waitKey(0)
show_simple_distance()
|
四像素的二值图无法表示复杂的数字形状,但是可以表示一横和一竖,从这个角度看左边两幅图应该距离更近,上边的两幅图应该距离更远,然而通过展开 2*2 的四像素成为 4 维向量,然后求取它们的向量距离:
0 1 | $ python vector_distance.py
distance between -| is 360, distance between -_ is 510
|
显然左边两幅图距离为 510,比上边的两幅图距离更远,这不是我们所期待的,难道通过这种向量方式的距离求取来分类像素组成的几何形状根本不可行?
在人类的世界里面不存在任何像素,而只有事物映射到大脑的信息:大小,形状,颜色。如果看到一个数字,基于过往的视觉经验,首先人脑会不自主得进行中心视觉的处理:如果两个数字是黏连的,人脑会主动分割;如果数字是模糊的人脑也会根据边界自动区分;如果数字是歪斜的,甚至颠倒的,人脑会自动纠正(过滤干扰)。人脑对每一个数字形成一个完整的标准的数字形象,当视觉神经细胞接收一个类似数字的符号后,人脑自动与标准数字形象进行比较,哪个最相像,哪一个就是要识别的数字。
这一过程,计算机是完全无知的,但是可以从算法上模拟。如果只有 4 个像素,那么考虑“中心视觉”就不现实了,这犹如人眼盯着放大数字的一角。在一个 20*20 的像素空间内计算机就可以形成“中心视觉”了(此时的向量距离就能反馈数字相似性的信息),例如 1 的像素值总是集中在 7-12 列上,且前几行和后几行像素通常都是空白的。
mnist 数据集上的试验¶
这里借用 mnist 手写数据集,每个数字由 28*28 个像素组成。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import dbload
# imgs with shape(count,height,width)
def show_gray_imgs(imgs, title=' '):
newimg = imgs[0]
for i in imgs[1:]:
newimg = np.hstack((newimg, i))
cv2.imshow(title, newimg)
cv2.waitKey(0)
train,labels = dbload.load_mnist(r"./db/mnist", kind='train', count=20)
num1 = train[labels==1]
print(len(num1))
show_gray_imgs(num1, '1')
>>>
4
|
首先读取训练集中的前 20 个样本,然后取数数字 1,可以看到有 4 个数字 1 被取出,打印出来看看效果:
尽管对于人脑来说上面的数字(除非不限定在数字范围来考虑这些符号)一目了然,并且可以轻易的得出这四个1之间的“距离”(相似度),第一个 1 向左倾斜一个很大角度,和其他三个 1 距离最远,最后两个 1 之间距离最小。如果把问题聚焦在第一个1和其余三个1的距离比较上,显然距离第二个1距离最大,距离最后边的两个1距离差不多:
0 1 2 3 4 5 6 | for i in range(1, len(num1)):
print("distance between 0-{} {}".format(i, vector_dist(num1[0], num1[i])))
>>>
distance between 0-1 2354.3323894471655
distance between 0-2 2152.188885762586
distance between 0-3 2114.714401520924
|
结果和我们的预测如此吻合,很令人惊讶。如果第一个1是靠近左上角,或者右下角,或者某一侧,那么计算机就无法再形成“中心视觉”了,可以想见它距离中心视觉的1的距离就会很远。如何克服这一问题?符号处于空间的位置不影响人脑识别出这一符号,也即人脑能很好得过滤这些干扰,计算机无法自动识别(在这一简单的距离模型下)这一干扰,需要人为来构造建立“中心视觉”的环境。
可以想见这一“环境”是怎样的————令待识别的图像最接近理想的标准的数字形象:
- 位置:数字位置应该处于图像中心,以最完整的方式清晰展现出来
- 角度:数字不应该有较大的倾斜角度,而是端端正正的
- 扭曲:数字不应该有较大的扭曲,比如 1 应该是一条直线,而不是竖起来的波浪线
- 大小:数字所占的整个比例应该和整个画布比例一致,不应该太小或太大
- 亮度:对于灰度图,需要考虑亮度的影响,而对于二值图就可以忽略虑亮度的影响
尽管还有一些其它的次要因素,比如边缘应该平滑无毛刺,但这些不是主要因素。事实上 mnist 数据集在采集时已经做了这些处理,每一个数字看起来都能很好得获取到“中心视觉”。这也就是为何 mnist 数据集在很多简单的模型上都能获取很高的识别率的重要因素,如果使用这些模型来验证其他渠道采集来的数字图像,并且这些数字图像不进行以上处理,结果就会令人大跌眼镜。
我们继续验证第一个数字 1 和其他数字的距离:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | for i in range(1, len(train)):
print("distance between 0-{} {}".format(labels[i], vector_dist(num1[0], train[i])))
>>>
distance between 0-1 0.0
distance between 0-9 2388.816652654615
distance between 0-2 2525.059603256921
distance between 0-1 2354.3323894471655
distance between 0-3 2604.63471527199
distance between 0-1 2152.188885762586
distance between 0-4 2397.628203037327
distance between 0-3 2499.4817462826168
distance between 0-5 1916.8805387921282
distance between 0-3 2850.328402131937
distance between 0-6 2611.602190227294
distance between 0-1 2114.714401520924
distance between 0-7 2411.6311907088943
distance between 0-2 2491.427703145327
distance between 0-8 1914.8302796853825
distance between 0-6 2259.1578076796673
distance between 0-9 2019.5298957925827
|
这里的 0-x 中的 x 不再是其他 1 的索引,而是换成了数字的下标。这里与训练集中的 20 个数字进行了距离计算。
很容易看出来,1 与 其他数字的距离都比较远,离其他 1 距离较近。此时不难想出一个简单的数字分类算法:在样本上计算距离,找出最近的几个样本,查看它们的标签,最多标签标示的数字就是最可能的数字。
注意:此时的计算机无法识别大角度旋转甚至倒立的数字,这需要数据的预处理。数字图像叠加然后取平均,就是高维空间中的中心投影,显然使用训练数据越多,这个投影越能表示数字的特征:
在前 10000 个训练数据集上进行数字叠加的效果已经相当完美,更多的采样已经无法提高数字的核心特征。
kNN 邻近算法¶
K 最近邻(kNN,k-NearestNeighbor)分类算法是数据挖掘分类技术中最简单的方法之一。相对于其他复杂的多参数机器学习模型,它非常简单,无需学习,直接通过强力计算来进行分类。
上一节已经揭示了 K 最邻近算法的本质:计算与已知样本的距离,选取 k 个距离最小(最邻近)的样本,统计这些最邻近样本的标签,占比最大的标签就是预期值。显然最邻近的 k 个样本具有投票权,哪种标签票数多,哪种标签就获胜。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # knn_mnist.py
def kNN_predict(train, labels, sample, k=5):
import operator
# 使用矩阵方式计算 sample 和训练集上的每一样本的向量距离
diff = train.astype('float64') - sample.astype('float64')
distance = np.sum(diff ** 2, axis=2)
distance = np.sum(distance, axis=1) ** 0.5
# 对向量距离排序,获取排序索引,进而获取排序标签
I = np.argsort(distance)
labels = labels[I]
max_labels = {}
if len(train) < k:
k = len(train)
# 统计前 k 个投票的标签信息
for i in range(0,k):
max_labels[labels[i]] = max_labels.get(labels[i], 0) + 1
# 返回从大到小票数排序的元组
return sorted(max_labels.items(), key=operator.itemgetter(1), reverse=True)
|
kNN 算法实现非常简单,计算待预测样本与训练集上每一样本的向量距离,提取前 k 个距离最近的标签信息,统计标签列表,返回从大到小票数排序的元组。
从程序实现上可以感觉到,kNN 的计算非常耗时,训练集越大,计算量将线性增加,当然这可以通过多线程/进程采用分治法降低计算复杂度;但是另一个问题却无法解决,算法对磁盘空间和内存空间的占用。训练集越大,占用的磁盘空间和内存空间就越大,如果采用缓存方式就牺牲了计算性能。
实际验证可以发现,kNN 算法的效果非常好,可以轻易达到 98% 以上的准确度,且无需训练。当然准确度依赖性也很强,采用的训练集的样本数和分布,k 值的选择都对结果有影响。可以通过交叉验证来选择一个比较优的 k 值,默认值是5。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | def kNN_test(train_entries=10000, test_entries=10000):
k = 5
train,labels = dbload.load_mnist(r"./db/mnist", kind='train', count=train_entries)
test,test_labels = dbload.load_mnist(r"./db/mnist", kind='test', count=test_entries)
error_entries = 0
start = time.process_time()
for i in range(0, test_entries):
max_labels = kNN_predict(train, labels, test[i], k=k)
predict = max_labels[0][0]
if(predict != test_labels[i]):
error_entries += 1
#print(predict, test_labels[i], flush=True)
#cv2.imshow("Predict:{} Label:{}".format(predict, test_labels[i]), test[i])
print("Average cost time {:.02f}ms accuracy rate {:.02f}% on trainset {}".format(
(time.process_time() - start) / test_entries * 1000,
(test_entries - error_entries) / test_entries * 100,
train_entries), flush=True)
#cv2.waitKey(0)
def kNN_batch_test():
for i in range(10000, 70000, 10000):
print("trains {}".format(i), flush=True)
kNN_test(i, 1000)
|
采用批量方式在测试集上验证 1000 个样本,训练集从 10000-60000 以 10000 步递进:
0 1 2 3 4 5 6 | $ python knn_mnist.py
Average cost time 135.38ms accuracy rate 92.00% on trainset 10000
Average cost time 283.84ms accuracy rate 93.80% on trainset 20000
Average cost time 417.44ms accuracy rate 94.40% on trainset 30000
Average cost time 575.08ms accuracy rate 96.30% on trainset 40000
Average cost time 722.20ms accuracy rate 98.00% on trainset 50000
Average cost time 847.16ms accuracy rate 98.20% on trainset 60000
|
从结果上不难看出,数字识别平均耗时,与训练集的大小成线性增加,准确度在达到一定程度后就难以提升,但是输出预测结果很稳定,我们可以查看这些识别错误的字符,来分析一下可能性:两个数字看起来很像,体现在像素分布上应该差不多。
观察这些被错误识别的数字很有趣。我们可以把错误情况分为两类:
- 情有可原的一类,这类数字即便人工也难以辨别。上面的大部分情况属于这类。如果要对这类数字进行优化,可以想见将影响其他已经正确识别的数字的正确率。
- 右下角的 6 尽管书写很不规范,但是人脑很容易就识别出来,算法将它识别为 1, 显然是符合像素组成的向量距离最优的,但是这种最优和人脑识别数字的准确性出现了明显偏差。
经过以上分析,可能会意识到,人脑识别数字并不是靠像素构成的向量距离来判断相似性这么简单,而是使用更深层次的特征。人类认识 0-9 个符号,不需要看大量的图片,也不需要进行大量计算,而是会在大脑中形成标准的数字图像符号,此外人脑具有很行的过滤干扰的能力。这一切“智能”都是朴素的 kNN 算法所不具备的。
scikit-learn kNN算法¶
scikit-learn 模块实现了传统机器学习的各类算法,并进行了大量优化,借此无需再制造不好用的轮子。这里对 scikit-learn kNN算法进行定量的性能分析。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def kNN_sklearn_predict(train, labels, test):
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier()
knn.fit(train, labels)
return knn.predict(test)
def kNN_sklearn_test(train_entries=10000, test_entries=1000):
train,labels = dbload.load_mnist(r"./db/mnist", kind='train', count=train_entries)
test,test_labels = dbload.load_mnist(r"./db/mnist", kind='test', count=test_entries)
train = train.reshape((train_entries, train.shape[1] * train.shape[2]))
test = test.reshape((test_entries, test.shape[1] * test.shape[2]))
start = time.process_time()
predict = kNN_sklearn_predict(train, labels, test)
error = predict - test_labels
error_entries = np.count_nonzero(error != 0)
print("Average cost time {:.02f}ms accuracy rate {:.02f}% on trainset {}".format(
(time.process_time() - start) / test_entries * 1000,
(test_entries - error_entries) / test_entries * 100,
train_entries), flush=True)
def kNN_sklearn_batch_test():
for i in range(10000, 70000, 10000):
kNN_sklearn_test(i, 1000)
kNN_sklearn_batch_test()
|
采用同样的批量测试方法,来对比 scikit-learn 封装的 kNN 算法的性能,需要注意到 scikit-learn 对 kNN 算法进行了大量的技巧性的扩展:
- 距离度量 metric :通常使用欧氏距离,默认的 minkowski 距离在 p=2 时就是欧氏距离
- algorithm :4 种可选,‘brute’对应蛮力计算,‘kd_tree’对应 KD树 实现,‘ball_tree’ 对应球树实现, ‘auto’则会在上面三种算法中做权衡,选择一个拟合最好的最优算法。需要注意的是,如果输入样本特征是稀疏的时候,无论我们选择哪种算法,最后scikit-learn都会去用蛮力实现‘brute’。
- 并且处理任务书 n_jobs:用于多核CPU时的并行处理,加快建立KNN树和预测搜索的速度。一般用默认的 -1 就可以了,即所有的CPU核都参与计算。
- n_neighbors:最近邻个数,通常选择默认值 5。
- 近邻权 weights :’uniform’ 意味着最近邻投票权重均等。”distance”,则权重和距离成反比例,即距离预测目标更近的近邻具有更高的权重,更近的近邻所占的影响因子会更加大。
0 1 2 3 | # 默认 scikit-learn 封装的 kNN 算法参数
KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
metric_params=None, n_jobs=1, n_neighbors=5, p=2,
weights='uniform')
|
scikit-learn 封装的 kNN 算法计算速度有了很大的提升,比自实现算法速度快大约 7-8 倍。准确率上有所降低,但基本不相上下。
0 1 2 3 4 5 6 | $ python knn_mnist.py
Average cost time 18.14ms accuracy rate 91.60% on trainset 10000
Average cost time 36.64ms accuracy rate 93.70% on trainset 20000
Average cost time 51.38ms accuracy rate 94.70% on trainset 30000
Average cost time 77.83ms accuracy rate 96.00% on trainset 40000
Average cost time 93.25ms accuracy rate 95.70% on trainset 50000
Average cost time 109.94ms accuracy rate 96.10% on trainset 60000
|
kNN 并行参数¶
在以上的各类参数中,有一个很吸引人的参数 n_jobs,它的默认值为 1,只使用了一个 CPU 核,在多核心的CPU上,这个参数对性能影响巨大。scikit-learn 并行操作使用 Joblib 的 Parallel 类实现。当笔者打开该参数时,发现性能不仅没有提升还略有降低,实际上是统计时间的代码问题。
time.process_time() 方法返回本进程或者线程的所有 CPU 核的占用时间,包括用户时间和系统时间,不包含 sleep 时间。所以算上启动多进程,以及数据多核心的分割和结果合并处理时间,占用的所有 CPU 核的时间就会略有上升。该函数对于性能瓶颈分析很有用。
统计相对于真实世界的耗时可以采用墙上时间函数 time.time(),修改代码如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | def kNN_sklearn_test(train_entries=10000, test_entries=1000):
train,labels = dbload.load_mnist(r"./db/mnist", kind='train', count=train_entries)
test,test_labels = dbload.load_mnist(r"./db/mnist", kind='test', count=test_entries)
train = train.reshape((train_entries, train.shape[1] * train.shape[2]))
test = test.reshape((test_entries, test.shape[1] * test.shape[2]))
stime = time.process_time()
wstime = time.time() # 显示墙上时间
predict = kNN_sklearn_predict(train, labels, test)
error = predict.astype(np.int32) - test_labels.astype(np.int32)
error_entries = np.count_nonzero(error != 0)
print("Average cost cpu time {:.02f}ms walltime {:.02f}s"
" accuracy rate {:.02f}% on trainset {}".format(
(time.process_time() - stime) / test_entries * 1000,
(time.time() - wstime),
(test_entries - error_entries) / test_entries * 100,
train_entries), flush=True)
# Joblib 启动多线程时会检查脚本是否为主程序调用
if __name__ == '__main__':
kNN_sklearn_batch_test()
|
n_jobs = -1 使用所有核,可以通过 Windows 资源监视器查看 CPU 使用情况。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # n_jobs = 1 时使用一个 CPU 核
$ python knn_mnist.py
Average cost cpu time 17.94ms walltime 17.97s accuracy rate 91.60% on trainset 10000
Average cost cpu time 36.03ms walltime 36.08s accuracy rate 93.70% on trainset 20000
Average cost cpu time 50.50ms walltime 50.52s accuracy rate 94.70% on trainset 30000
Average cost cpu time 76.39ms walltime 76.51s accuracy rate 96.00% on trainset 40000
Average cost cpu time 99.47ms walltime 99.74s accuracy rate 95.70% on trainset 50000
Average cost cpu time 115.23ms walltime 115.41s accuracy rate 96.10% on trainset 60000
# n_jobs = -1 使用所有核,笔者环境为 8 核心
$ python knn_mnist.py
Average cost cpu time 22.64ms walltime 4.58s accuracy rate 91.60% on trainset 10000
Average cost cpu time 47.11ms walltime 10.15s accuracy rate 93.70% on trainset 20000
Average cost cpu time 67.48ms walltime 16.25s accuracy rate 94.70% on trainset 30000
Average cost cpu time 96.39ms walltime 23.10s accuracy rate 96.00% on trainset 40000
Average cost cpu time 119.05ms walltime 30.40s accuracy rate 95.70% on trainset 50000
Average cost cpu time 144.48ms walltime 41.26s accuracy rate 96.10% on trainset 60000
|
对比以上两组数据,可以非常清晰地看到,墙上时间(现实世界中的耗时)明显降低,大约降低了 3 倍。n_jobs 参数在多核环境是非常有效的提速工具。
kNN 近邻权参数¶
另一个令人关注的参数是近邻权 weights。思考待识别样本距离更近的样本点的投票权重更大,而不是简单的取平均,将会校正这样一个错误:由于书写的扭曲,模糊,等等不规范问题导致某个数字应该分布在距离很近的一个范围内,可以想象成大部分样本点聚集在一个圆内,现在某个待测样本落在了圆外,并且靠近(还未落入)了另外一个数字聚集的圆,这个圆内有很多样本具有了表决权,如何才能把它拉回正确的圆内?
显然只能增加正确的少数派的投票权重,当然这是一种人为干预:主观认为距离越近就越加相似(这也是 kNN 算法的思想,既然整体上是对的,那么它在细节上应该也是对的)。
0 1 2 3 4 5 | def kNN_sklearn_predict(train, labels, test):
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier(algorithm='auto', n_jobs=-1, weights='distance')
knn.fit(train, labels)
return knn.predict(test)
|
更新 kNN_sklearn_predict 函数,设置 weights 参数为 distance。来看一下效果,大约有 0.2%-0.4% 的微弱提升。
0 1 2 3 4 5 6 | $ python knn_mnist.py
Average cost cpu time 22.12ms walltime 4.40s accuracy rate 91.90% on trainset 10000
Average cost cpu time 44.66ms walltime 9.03s accuracy rate 93.80% on trainset 20000
Average cost cpu time 65.02ms walltime 14.29s accuracy rate 94.50% on trainset 30000
Average cost cpu time 94.42ms walltime 22.28s accuracy rate 96.30% on trainset 40000
Average cost cpu time 118.08ms walltime 30.93s accuracy rate 96.30% on trainset 50000
Average cost cpu time 142.30ms walltime 41.20s accuracy rate 96.40% on trainset 60000
|
算法特征¶
蛮力计算(brute):计算预测样本和所有训练集中的样本的距离,然后计算出最小的k个距离即可,接着多数表决。这个方法简单直接,在样本量少,样本特征少的时候很有效。比较适合于少量样本的简单模型的时候用。
brute 算法在 mnist 数据集上,速度很快:
0 1 2 3 4 5 | def kNN_sklearn_predict(train, labels, test):
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier(algorithm='brute', n_jobs=-1)
knn.fit(train, labels)
return knn.predict(test)
|
0 1 2 3 4 5 6 | $ python knn_mnist.py
Average cost cpu time 1.08ms walltime 7.77s accuracy rate 91.60% on trainset 10000
Average cost cpu time 1.05ms walltime 5.62s accuracy rate 93.70% on trainset 20000
Average cost cpu time 1.50ms walltime 4.61s accuracy rate 94.70% on trainset 30000
Average cost cpu time 2.28ms walltime 18.43s accuracy rate 96.00% on trainset 40000
Average cost cpu time 2.94ms walltime 22.10s accuracy rate 95.70% on trainset 50000
Average cost cpu time 3.73ms walltime 16.73s accuracy rate 96.10% on trainset 60000
|
KD树(k-dimensional树的简称),是一种分割 k 维数据空间的数据结构,主要应用于多维空间关键数据的近邻查找(Nearest Neighbor)和近似最近邻查找(Approximate Nearest Neighbor)。本质上 KD 树就是二叉查找树(Binary Search Tree,BST)的变种。KD树实现和球树实现原理大体相同,均是对数据进行预分类。
更改参数 algorithm 分别为 “kd_tree” 和 “ball_tree”,以下是两种算法的效果对比,两者的预测准确率完全一致(在 mnist 数据集上),ball_tree 算法速度稍快:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # kd_tree 算法效果
Average cost cpu time 22.58ms walltime 4.36s accuracy rate 91.60% on trainset 10000
Average cost cpu time 44.25ms walltime 8.23s accuracy rate 93.70% on trainset 20000
Average cost cpu time 64.89ms walltime 13.19s accuracy rate 94.70% on trainset 30000
Average cost cpu time 96.47ms walltime 22.21s accuracy rate 96.00% on trainset 40000
Average cost cpu time 120.00ms walltime 28.58s accuracy rate 95.70% on trainset 50000
Average cost cpu time 143.03ms walltime 37.58s accuracy rate 96.10% on trainset 60000
# ball_tree 算法效果
Average cost cpu time 17.91ms walltime 3.65s accuracy rate 91.60% on trainset 10000
Average cost cpu time 38.00ms walltime 7.35s accuracy rate 93.70% on trainset 20000
Average cost cpu time 59.30ms walltime 12.50s accuracy rate 94.70% on trainset 30000
Average cost cpu time 84.50ms walltime 21.21s accuracy rate 96.00% on trainset 40000
Average cost cpu time 110.95ms walltime 29.79s accuracy rate 95.70% on trainset 50000
Average cost cpu time 133.73ms walltime 37.34s accuracy rate 96.10% on trainset 60000
|
kNN 算法启示¶
下图可以看出错误率(评估算法准确性常用这一指标)随着训练集的样本的增大,在不停降低,但是下降速度越来越慢:
为何下降速度越来越慢,一个启发性解释:训练样本的像素的向量终点在高维空间落在不同的区域,相同数字的向量终点会聚集在一个小的范围内(距离近,夹角小),这一范围内的点如果映射到平面上,就可以想象成一个圆形(当然也可以是其他可以描述一片聚集区域的图形)区域,越靠近圆心训练样本越密集,越靠近边界分布越稀少(如果从像素的直方图上统计相同数字的分布符合正态分布,那么映射到高维空间不会改变这一分布特性)。当训练样本很少时,这个圆的形状就不能完全体现出来,当样本越多,那么这个圆形就越加完美的展现出来,当到达一定程度后,更密集的训练样本就很难对圆形的表达力进行提高了。
使用正态分布(高斯分布)来模拟这种情况:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def draw_normal_distribution(points=100):
import matplotlib.pyplot as plt
np.random.seed(0)
rand_num = np.random.normal(0, 1, (4, points))
Ax, Ay = rand_num[0] - 3, rand_num[1] - 3
Bx, By = rand_num[2] + 3, rand_num[3] + 3
plt.figure()
plt.title("Normal Distribution with {} points".format(points))
plt.xlim(-10, 10)
plt.ylim(-10, 10)
plt.scatter(Ax, Ay, s=5, c='black')
plt.scatter(Bx, By, s=5, c='black')
plt.show()
|
这里为了模拟分类,分别绘制两个点聚集的区域:
当样本点比较少的时候,我们不易观察出这种分布的聚集规律,当样本点从100个增大100倍到10000个点时,就非常显著了:
通常人书写时有某种倾向,比如向左倾斜,那么图形看起来就不会是正圆,就会被拉长成椭圆,当然其他倾向会对聚集的空间形状也有扭曲影响。如果我们把这种人书写的各种倾向进行泛化,比如对图片统一进行左倾,右倾,或者扭曲,抖动处理,那么这个圆形就接近正圆了。(这里假设人手写数字符合正态分布,当然也可以是其他分布,只是形状不同)。
经过优化的算法库的性能要远远优于未优化的代码,尝试不同软件包提供的同种算法,会发现性能上有很大区别。
另外从矩阵计算向量距离的方式上可以看到,使用任何一种方式把图像向量化(二维变一维)都是等价的,无论是从左上角开始,按行变换,还是按列或者 zig-zag,只要所有样本均进行这种处理,它们都是等价的,不会改变向量距离,也即单个点像素距离的累积。
这种二维变一维的转换丢失了很多二维信息,比如水平或垂直方向上像素之间的关系(例如轮廓信息),这与人识别数字的方式是本质不同的,人脑可以把握更本质的图像特征。
数据处理¶
特征缩放(peature scaling)是数据预处理过程中至关重要的一步。决策树和随机森林是机器学习算法中为数不多的无需数据特征缩放处理的算法。对于大多数机器学习和优化方法,将特征的值缩放到相同的区间可以使其性能更佳,它加速梯度下降,并能有效防止数据溢出。
特征缩放的重要性使用一个简单例子描述:假定某种微观粒子有两个特征:一个特征为速度,值的范围为1000~10000 米/秒;另一个特征为重量,值的范围为1e-10~1e-9 克。如果使用平方误差函数,算法将主要根据第二个特征上较大的误差进行权重的优化。另外还有k-近邻(k-nearest neighbor,KNN)算法,它以欧几里得距离作为相似性度量,样本间距离的计算也以第二个特征为主。
通常将不同的特征缩放到相同的区间有两个常用的方法:归一化(Normalization)和标准化(Standardization),数据标准化包括中心化和标准差归一两个步骤。
归一化¶
归一化(Normalization)指的是将特征的值缩放到区间[0,1],当然也可以是其他小区间,比如[-1, 1]。它是最小最大缩放的一个实例。为了对数据进行规范化处理,可以简单地在每个特征列上使用min-max缩放:
0 1 2 3 4 5 6 | # normalization into scope [0-1]
def normalize(X):
'''Min-Max normalization :(xi - min(xi))/(max(xi) - min(xi))'''
min = np.min(X, axis=0) * 1.0
max = np.max(X, axis=0) * 1.0
return (X * 1.0 - min) / (max - min)
|
下图可以看出,归一化处理后,数据集中在半径为 1 的第一象限内,也即所有特征值被压缩到了[0-1]范围内。
中心化¶
数据中心化(Zero-Centered 或 Mean-subtraction)每个样本的特征值的均值为 0,也即原始样本的每个特征值减去所有样本在该特征上的均值。 反映在坐标上就是一个平移过程,平移后中心点是(0,0)。同时中心化后的数据对向量也容易描述,因为是以原点为基准的。
数据标准化处理中的第一步就是数据中心化,然后再进行标准差归一,所以通常数据中心化不单独使用。
0 1 2 | # Mean-subtraction, move data around origin (0,0...)
def zero_centered(self, X):
return X - np.mean(X, axis=0)
|
中心化处理后,样本点整体被平移到原点周围,样本点相对距离不会改变:
标准化¶
标准化(Standardization)处理包含两部,第一步进行中心化去均值,然后将中心化后的数据标准差归1,得到标准正态分布的数据,此时每个维度上的尺度是一致的(效果与归一化类似),各指标处于同一数量级,适合进行综合对比评价。
0 1 2 3 | # Mean is 0, σ is 1
def standard(X):
assert(np.std(X, axis=0).any())
return zero_centered(X) / np.std(X, axis=0)
|
当遇到需将数值限定在一个有界区间的情况时,常采用最小最大缩放来进行有效的规范化。
但在大部分机器学习算法中,标准化的方法却更加实用。这是因为:许多线性模型,比如逻辑斯谛回归和支持向量机,在对它们进行训练的最初阶段,即权重初始化阶段,可将其值设定 0 或是趋近于 0 的随机的极小值。通过标准化,可以将特征列的均值设为0,方差为1,使得特征值的每列呈标准正态分布,这更易于权重的更新。此外,与最小最大缩放将值限定在一个有限的区间不同,标准化方法保持了异常值所蕴含的有用信息,并且使得算法受到这些值的影响较小。
随机化¶
训练集的数据是否充分随机化对随机梯度下降影响很大,通常在模型训练前进行随机化,以获取更好的训练速度和预测结果。
0 1 2 3 4 5 6 | def shuffle(X, y, seed=None):
idx = np.arange(X.shape[0])
np.random.seed(seed)
np.random.shuffle(idx)
return X[idx], y[idx]
|
sklearn 数据处理¶
sklearn 中的预处理 preprocessing 软件包中包含了 MinMaxScaler 和 StandardScaler 模块,分别对数据进行归一化和标准化处理。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class DataScaler():
def __init__(self, X_train):
from sklearn.preprocessing import MinMaxScaler
from sklearn.preprocessing import StandardScaler
# 使用训练集适配参数
self.mms = MinMaxScaler()
self.mms.fit(X_train)
self.scaler = StandardScaler()
self.scaler.fit(X_train)
def sklearn_normalize(self, X):
return self.mms.transform(X)
def sklearn_standard(self, X):
return self.scaler.transform(X)
|
在进行数据转化前,需要使用训练集进行适配 fit,以提取数据的特征,例如均值和标准差,以备后续对训练集,校验数据集和预测数据集进行处理。
注意
如果对训练集进行了归一化或者标准化处理,那么一定要对校验数据集,测试数据集和实际应用中的数据进行相同处理。
分类数据的可视化¶
在验证一些分类算法效果时,通过可视化可以以更直观的方式观察分类效果,以及算法的特性,所以提供一个绘制分类算法的通用函数是必要的。
sklearn 官方文档中提供了很多类似源码,它是一个不折不扣的宝库,这里参考 sklearn 的实现。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | # drawutils.py
# resolution is step size in the mesh
def plot_decision_regions(X, y, clf, test_idx=None, resolution=0.02):
from matplotlib.colors import ListedColormap
# setup marker generator and color map
markers = ('s', 'x', '^', 'v')
colors = ('red', 'blue', 'lightgreen', 'cyan')
cmap = ListedColormap(colors[:len(np.unique(y))])
# create a mesh to plot in
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, resolution),
np.arange(y_min, y_max, resolution))
Z = clf.predict(np.array([xx.ravel(), yy.ravel()]).T)
Z = Z.reshape(xx.shape)
plt.title("Decision surface of multi-class")
plt.contourf(xx, yy, Z, alpha=0.3, cmap=cmap)
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
for idx, cl in enumerate(np.unique(y)):
plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1], alpha=0.8, c=colors[idx],
marker=markers[idx], label=cl, s=50,
edgecolor='black')
if test_idx is None:
return
# plot all samples with cycles
X_test = X[test_idx, :]
plt.scatter(X_test[:, 0], X_test[:, 1], c='', edgecolor='black',
alpha=1.0, linewidth=1, marker='o', s=50,
label='test dataset')
|
其中 X,y 表示绘制分割区域图形的数据,clf 是分类器,test_idx 是一个 range 类型,包含测试数据的索引,用于在图中标记训练集,resolution 是生成网格的精度。 这里使用鸢尾花数据集进行多分类的绘图:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def test_plot_decision_regions():
import dbload
from sklearn.linear_model import Perceptron
from sklearn.metrics import accuracy_score
X_train, X_test, y_train, y_test = dbload.load_iris_mclass()
ppn = Perceptron(max_iter=100, eta0=0.01, random_state=1)
ppn.fit(X_train, y_train)
predict = ppn.predict(X_test)
print("Misclassified number {}, Accuracy {:.2f}%".format((predict != y_test).sum(),
accuracy_score(y_test, predict)*100))
X_all = np.vstack((X_train, X_test))
y_all = np.hstack((y_train, y_test))
print(y_all[0:20])
plot_decision_regions(X_all, y_all, clf=ppn,
test_idx=range(X_train.shape[0], X_all.shape[0]))
plt.xlabel('petal length [standardized]')
plt.ylabel('petal width [standardized]')
plt.legend(loc='upper left')
plt.tight_layout()
plt.show()
if __name__ == "__main__":
test_plot_decision_regions()
|
感知器和梯度下降¶
感知器原理¶
感知器(Perceptron)除在介绍神经网络时被偶然提到,很少再被郑重对待。然而对感知器的理解决定了对机器学习中的基本概念,比如代价函数,梯度下降和深度学习中的神经网络的理解。是机器学习领域的一块名副其实的基石(Footstone)。
弗兰克·罗森布拉特(Frank Rossenblatt)在1958年提出了感知器模型。这一模型的亮点在于他提出了一个自学习算法,此算法可以自动通过优化得到权重系数(Weights,简写为 w,向量形式,维数对应输入值x的个数),此系数与输入值(x,向量,表示输入可由多个特性组成)的乘积决定了神经元是否被激活,所谓激活在这个感知器模型中就是指:激活表示输出 1,未激活表示输出 -1,这样就实现了分类。
这幅图看起来很复杂,可本质上只是一个线性表达式,线性表达式可以方便地使用向量点乘(点积 Vector Dot Product)来实现。计算点乘的函数被称为净输入函数(本质就是输入向量 x 和权重向量 w 的点乘),使用 Z 表示,净输入就是对激活函数的激励,它被送到激活函数(阶跃函数)φ(z),当φ(z)>=0 时输出 1,否则输出 0。
注意到输入中总是有 1 存在,它的权重是 w0,它的输入分量就是 w0,被高深地称为神经元阈值,实际上就是线性表达式的常数项,看起来是非常数项 >= -w0 时,也即激励输入 >= 0 就被激活,看做阈值也是很形象的。
一个线性表达式如果是 2 元的(输入 x 有2个特征值,x向量维度为2),那么令这个表达式等于0,就成了平面坐标上直线方程,可以绘制出一条直线,直线上的所有点代入这个表达式结果就是 0,法线一侧代入结果 > 0,另一侧代入结果 < 0,所以感知器只能解决线性可分的问题。如果 x 维度为3,则是一个空间平面,维度再高就被称为超平面(因为已经超出人脑可以想象的平面了)。
实际上整个神经网络的基石就是建立在感知器模型叠加各式各样的激活函数(通常是光滑可微容易实现梯度下降的非线性函数,实际上大部分神经网络的发展历史就是围绕这个激活函数的不停优化在前进)上,每每想到这点就感觉人工智能的真正实现还可能非常遥远,不免有些沮丧!
输入点乘权重代表线性部分,激活函数代表非线性部分,多层的线性和非线性函数的叠加组合就构成了所谓的进行深度学习的神经网络,理解起来很简单,但是理论证明这个网络可以以任意精确度模拟任一复杂的多元连续函数。
感知器的亮点在于在给定的输入样本上,可以自动通过算法寻找实现分类的权重,也即向量 w。
这里选取两个输入的或运算为例,之所以选择它,不是随意的,而是基于如下考量:
- 它可以转化为一个线性分类问题,由于输出只有 0(用输出-1表示) 或 1,也即是分为 2 类
- 它的输入只有 2 个参数,也即特征值 x 向量参数只有 2 个,这可以很好地从图像上描绘分类的状况
0 1 2 3 4 | # Bool or Train x1 x2 y
BoolOrTrain = np.array([[0, 0, -1],
[0, 1, 1],
[1, 0, 1],
[1, 1, 1]])
|
训练集简单到可以直接手写出来,这里使用矩阵表示,前两列表示 x 的输入向量(可以看做四个行向量组成,矩阵中实现向量行列变换非常容易,也不费解),并且可以在平面上将这些输入特征点画出来:
直觉上就可以看到左下角原点处的点和其他三个点分为两类,这两类之间存在无数条从左上角到右下角的方向的直线可以把它们分割开。
为了理解感知器对权重的更新过程,图中只画了简单的一个正样本的点,来分析下这个点对随机初始化权重 \(w_{old}\) 的更新过程。
- \(w_{old}\) 是直线的法向量,且方向指向直线正分类一侧(代入直线表达式 > 0),显然图中样本点被错误分类了。
- \(w_{old}\) 加上错误分类样本点的向量,就得到了新的权重,实际编码中这个错误样本点的向量还要乘上一个被称为学习率的 \(\eta\), 取值范围 (0-1)。
- 显然此时直线的法线变成了 \(w_{new}\) ,指向了样本点所在的一侧,此时样本点就被正确分类了。
实际上考虑到常数项 \(w_{0}\) 的存在,直线在旋转时会向某个方向平移(由w0, w2的改变值决定)。如果是负样本点,就要减去错误分类样本点的向量,正样本对直线有顺时针旋转的吸引力,负样本对直线由逆时针旋转的推动力(斥力),在经过所有错误样本点的这种吸引和推动力作用下,直线最终会落在所有样本点均正确分类的位置上。无论是正样本还是负样本对权重的更新可以总结为:
其中:
- \(y^{i}\) 是标签值,也即真实的样本分类值
- \({\hat{y}}^i\) 是预测值,也即净输入值对激励函数的作用后的输出
到这里就明白为何感知器在误分类时为何要求输出是 -1,而不是 0 了,这样才能保证实际值和预测值的差要么是 2 要么是 -2,否则正负样本对权重更新就是不平衡的了。
如果是线性不可分问题,那么算法将无法收敛,总是不停抖动(不停顺时针逆时针转动),无法得到准确结果。
上面的或运算例子可以使用手算来进行,步骤如下:
- 假设初始化时的权重为向量 [1,1,1],第一个元素对应阈值,所以法向量为 [1,1],它是一条位于所有点左下方的直线,
- 使用初始化权重计算所有样本点,找出所有错误分类样本点。由于(0,0)位于正分类一侧,所以是错误分类点
- 使用错误分类点[1,0,0],第一个元素对应阈值的输入,总是 1,代入上面的公式计算调整量,设学习率为 0.2,则为 0.2 * [1,0,0] * (-1 - 1) = [-0.4,0,0],新的法向量为 [1,1,1] + [-0.4,0,0] = [0.6,1,1]
- 使用新权重量计算所有样本点,找出所有错误分类样本点。继续调整,直至所有点被正确分类
- 最终会得到一个法向量 [-0.2,1,1] 使得所有样本点被正确分类
从以上的计算过程中可以发现,由于每次调整可能把已经正确分类的点分到错误一侧,每次都要对所有样本点计算错误分类;此外最终找到的直线不是最优的,只是刚刚好能完全分类的直线,这与初始化的权重值,学习率,也即训练时的样本点选取顺序都有关。
感知器实战¶
感知器的关键实现就在于权重的调整,可以每次调整一个错误的分类样本,直至分类正确,这被称为 Online 在线训练。注意权重向量被初始化为浮点型向量。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | perceptron.py
''' Rosenblatt Perceptron Model '''
class Perceptron(object):
def __init__(self, eta=0.05, n_iter=100):
self.eta = eta
self.n_iter = n_iter
self.complex = 0 # Statistical algorithm complexity
def errors(self, X, y):
'''Statistics all errors into self.errors_'''
predicts = self.appendedX_.dot(self.w_)
diffs = np.where(predicts >= 0.0, 1, -1) - y
errors = np.count_nonzero(diffs)
self.errors_.append(errors)
return errors, diffs
def fit_online(self, X, y):
self.w_ = np.array([0, -1, 1]) * 1.0
samples = X.shape[0]
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
self.errors_ = []
# record every w during whole iterations
self.wsteps_ = []
self.wsteps_.append(self.w_.copy())
errors, diffs = self.errors(X, y)
if errors == 0:
return
for _ in range(self.n_iter):
# pick all wrong predicts row (1 sample features)
errors_indexs = np.nonzero(diffs)[0]
for i in errors_indexs:
xi = X[i, :]
target = y[i]
fitted = 0
# try to correct the classificaton of this sample
while True:
delta_w = self.eta * (target - self.predict(xi))
if (delta_w == 0.0):
break
fitted = 1
self.w_[1:] += delta_w * xi
self.w_[0] += delta_w * 1
self.complex += 1
if fitted == 1:
self.wsteps_.append(self.w_.copy())
errors, diffs = self.errors(X, y)
if errors == 0:
return
if len(self.errors_) and self.errors_[-1] != 0:
print("Warn: didn't find a hyperplane in %d iterations!" % self.n_iter)
return self
|
类方法中的加权值计算和阶跃函数都非常简单:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 | # X is a vector including features of a sample
def net_input(self, X):
'''Calculate net input'''
return np.dot(X, self.w_[1:]) + self.w_[0] * 1
# X is a vector including features of a sample
def sign(self, X):
'''Sign function'''
return np.where(self.net_input(X) >= 0.0, 1, -1)
# X is a vector including features of a sample
def predict(self, X):
'''Return class label after unit step'''
return self.sign(X)
|
另一种更常用的方法称为批量训练,在所有错误分类上对权重进行调整,在上面的感知器类中添加如下函数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | def fit_batch(self, X, y):
self.w_ = np.array([1, 1, 1]) * 1.0
self.errors_ = []
# record every w during whole iterations
self.wsteps_ = []
for _ in range(self.n_iter):
errors = 0
self.wsteps_.append(self.w_.copy())
# pick every row (1 sample features) as xi and label as target
for xi, target in zip(X, y):
delta_w = self.eta * (target - self.predict(xi))
if delta_w == 0.0:
continue
# although update all w_, but for correct-predicted the delta_wi is 0
self.w_[1:] += delta_w * xi
self.w_[0] += delta_w * 1
self.complex += 1
errors += int(delta_w != 0.0)
self.errors_.append(errors)
if errors == 0:
break
if len(self.errors_) and self.errors_[-1] != 0:
print("Warn: didn't find a hyperplane in %d iterations!" % self.n_iter)
return self
|
这里在代码中嵌入了一些统计用的成员,比如 wsteps_ 记录了每一步调整的权重值,errors_ 则记录每一次调整后的错误分类的数目。complex 则用于统计算法的复杂度。这有助于对感知器的收敛速度提供量化的观察窗口:使用它们通过 matplotlib 作图可以得到非常直观的感受。
这里使用批量权重调整,并初始化权重为 [1,1,1],学习率 eta 为 0.2,观察是否和手动计算得出的权重值相同:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | def boolOrTrain():
# Bool or Train x11 x12 y1
BoolOrTrain = np.array([[0, 0, -1],
[0, 1, 1],
[1, 0, 1],
[1, 1, 1]])
X = BoolOrTrain[:, 0:-1]
y = BoolOrTrain[:, -1]
BoolOr = Perceptron(eta=0.2)
BoolOr.fit_batch(X, y)
print('Weights: %s' % BoolOr.w_)
print('Errors: %s' % BoolOr.errors_)
boolOrTrain()
>>>
Weights: [-0.2 1. 1. ]
Errors: [1, 1, 1, 0]
|
最终的权重和手算是一致的,一共调整了四次,最后一次错误分类数为 0,从坐标图中可以看到整个收敛过程:
图中分割平面的直线颜色由浅变深,反应了直线调整的过程,黑色实线就是完成分类的最终直线,这里一并把调整的向量也画出来:
由于每次均是对阈值权重调整,这里无法看出二维向量的变换,它们总是 [1,1],相互重合了。
尽管如此,为何这种收敛似乎带有偶然性,实际上 Block & Novikoff 定理证明了感知器在线性可分问题上的收敛性,并且计算是有限次的:感知器的收敛速度与样本数无关,而与两分类间的最小距离的平方成反比,与样本向量离原点最大距离的平方成正比。
Adaline 感知器¶
从感知器模型机制可知,由于预测差值总是 2 或者 -2,它的权重调整值 \(\Delta w\) 总是离散的。所有的权重组合可以看成是一个连续的平面,而阶跃函数使得输出不再连续,这使得输出的代价函数 (通常使用所有标签值和预测值的误差平方和表示)是跳跃的,也即若干个 0,-2 或者 2 的平方和,反馈到参数的调整上就是不停的跳来跳去,而不是连续的缓慢移动。在线性不可分时就无法收敛。
自适应线性神经网络(Adaptive Linear Neuron,Adaline)改变了这一情况。Adeline 算法相当有趣,它阐明了代价函数的核心概念,并且对其做了最小化优化(梯度下降),这是理解逻辑回归(logistic regression)、支持向量机(support vector machine)以及神经网络的基础。
自此开辟了机器学习新的方向:引入代价函数,并使用梯度下降法调优。
所谓的“自适应线性神经元”,其实就是把返回调整值从激励值上取,而不是激活函数的输出上,它更合适的称谓是Adaline 感知器。
机器学习中监督学习算法的一个核心组成在于:在学习训练阶段定义一个待优化的目标函数。这个目标函数通常是需要寻找最小化处理的代价函数,此时的参数就是要找的权重值。在Adaline中,可以将代价函数(Cost Function)J 定义为通过模型得到的输出与实际标签之间的误差平方和(即和方差,Sum of Squared Error,SSE):
通过公式可以看到,对于连续的权重组合(代价函数的自变量),代价函数的输出也是连续的,平面上它是一个U形的抛物线,三维空间则是下凹的凸面。无论是低维度还是高维度空间它都是连续的光滑的曲面,处处可微。
另一种常见的代价函数是标准差(即均方差 Mean squared error,MSE,也称为平方损失 Square loss,或二次项误差 Quadratic error),MSE = SSE / n,其中 n 为样本数,由于训练样本数为常数,实际上效果和SSE 一样。均方差对应了平均欧氏距离,基于 MSE 的最小化来进行模型求解的方法称为“最小二乘法”(LSM,Least square method)。线性回归中就使用了 LSM ,它最终找到一条直线,使得所有样本到直线的欧氏距离最小。
可以发现使用 LSM 时多采用 MSE,这是因为均方和相加可能会很大,取平均可以防止上溢出。而后面提到的逻辑回归中的最大似然函数就不再取平均,因为 sigmoid 函数将输出结果压缩到了 (0-1),所以不用担心上溢出,且省去了一次除运算。
要找到 w 使得代价函数 J 取得最小值,显然就是从任一点沿着梯度反方向(对w中各个分量的偏导数)进行迭代,这就是梯度下降的本质,而要用梯度下降就要寻找一个光滑可微的函数。它们是相辅相成的。注意到上面公式中的1/2,它在求偏导时被消去,只是为了方便计算而已。此时的 \(\Delta w\) 表示为:
众所周知梯度方形是函数增长最快的方向,所以反方向就是学习速率最快的方向。
注意权重的更新是基于训练集中所有样本完成的(而不是每次一个样本渐进更新权重,理解这一点非常重要,注意代价函数中的求和符号,否则就不是批量梯度下降了),这也是此方法被称作“批量”梯度下降的原因。
Adaline 感知器实战¶
Adaline 模型和感知器模型的区别很小,只需要更新相关的 fit 函数即可。另外让标签不再被限制在 -1 和 1,这里需要对阶跃函数相关的函数进行更新。
正负类标签必须关于 0 对称,例如 1 和 -1, 2 和 -2,本质上只是直线(超平面)方程两侧同时乘以一个非 0 常数,w 实际上是直线的法向量,也即 w 同时乘以一个非 0 常数不影响直线在空间中的位置。
为了要关于 0 对称?实际上和感知器原因是一致的,直线(超平面,术语叫做决策边界(decision boundary))法线的转动幅度由固定值系数(2或-2乘以学习率)变成了实际误差乘以学习率来调节,正负分类应该对法线的驱动力是对等的,如果正类标签变为 0,负类标签还是 -1,那么分离直线就会向正类标签靠拢,从而无法得到最优的分离直线。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | # adaline.py
class AdalineGD(object):
#......
def errors(self, X, y):
predicts = self.appendedX_.dot(self.w_)
diffs = np.where(predicts >= 0.0, self.positive, self.negtive) - y
errors = np.count_nonzero(diffs)
self.errors_.append(errors)
return errors, diffs
# 更新标签到正分类和父分类成员
def update_labels(self, y):
# for sign and predict
self.positive = np.max(y)
self.negtive = np.min(y)
# 使用正负分类标签处理阶跃函数
def sign(self, X):
'''Sign function'''
return np.where(self.net_input(X) >= 0.0, self.positive, self.negtive)
def fit_adaline(self, X, y):
samples = X.shape[0]
x_features = X.shape[1]
self.w_ = 1.0 * np.zeros(1 + x_features) + 1
self.update_labels(y) # 更新标签信息
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
self.errors_ = []
self.costs_ = []
# record every w during whole iterations
self.wsteps_ = []
self.steps_ = 100 # every steps_ descent steps statistic one cose and error sample
while(1):
if (self.complex % self.steps_ == 0):
errors, diffs = self.errors(X, y)
self.wsteps_.append(self.w_.copy())
cost = 1 / 2 * np.sum((y - self.appendedX_.dot(self.w_)) ** 2)
self.costs_.append(cost)
self.complex += 1
# minmium cost function with partial derivative wi
deltaw = (y - self.appendedX_.dot(self.w_))
deltaw = -self.eta * deltaw.dot(self.appendedX_)
if np.max(np.abs(deltaw)) < 0.0001:
print("deltaw is less than 0.0001")
return self
self.w_ -= deltaw
if(self.complex > self.n_iter):
print("Loops beyond n_iter %d" % self.n_iter)
return self
return self
|
这里增加了 costs_ 成员用于统计代价函数值的变换。通常一个正确实现的算法,它应该具有下降的趋势。这里依然使用或运算进行测试,为了和感知器比较,注意到权重向量初始化为全 1。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | def boolOrTrain():
# Bool or Train x11 x12 y1
BoolOrTrain = np.array([[0, 0, -1],
[0, 1, 1],
[1, 0, 1],
[1, 1, 1]])
X = BoolOrTrain[:, 0:-1]
y = BoolOrTrain[:, -1]
BoolOr = AdalineGD(0.01, 1000)
BoolOr.fit_adaline(X, y)
print('Weights: %s' % BoolOr.w_)
print('Errors: %s' % BoolOr.errors_)
boolOrTrain()
>>>
deltaw is less than 0.00001
Weights: [-0.49840705 0.99865703 0.99865703]
Errors: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
这里当 \(\Delta w\) 很小时就退出迭代, 通过作图可以查看看出误差函数下降非常之快:
Adaline 感知器获得的分割平面要比原始的阶跃激活函数感知器精确得多,应该观察到,从最初的直线(最浅色)到最后的黑色直线变化,开始变化很大,也即误差函数值下降很快,慢慢变慢,直至停止,也即基本达到了最小值。
需要注意到不合适的学习率不仅不会让误差缩小还会导致无法收敛而产生震荡并扩散。通常使用较小的学习率来验证算法的正确性,然后不停调整学习率,在速度和正确性上获得平衡。
随机梯度下降¶
Adaline 算法采用批量梯度下降 BGD(Batch Gradient Descent),BGD的特点是总是综合所有数据的梯度,下降过程很平滑。但是计算量比较大。
当数据上千万量级时可以随机取出部分数据进行随机梯度下降 SGD (Stochastic Gradient Descent),每次使用一个样本计算梯度值,下降曲线看起来弯弯曲曲,具有较多噪声。但是随着当训练周期越来越大,在整体趋势上,它依然会收敛到最优值附近。这是由大数定律保证的。这样就可以方便查看中间的结果,否则采用 BGD 一个计算周期就耗费很长时间。
实际上要得到相同的精度结果,SGD 并不会比 BGD 降低整体的计算量,在训练周期上基本上是等价的。只是在大数据集上,我们并不需要得到最优值,而是使用 SGD 得到一个可以接受(可满足分类精度)的中间值,从而避免耗费大量时间去寻找最优值。
这里重新实现 AdalineSGD 类,shuffle 随机选择样本数据的开关,否则顺序选择(如果样本分布已经是比较随机的,那么这个操作是不需要)。random_state 用于设置随机种子,以便重现结果。
新增了 shuffle 函数用于对样本乱序处理。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | # adaline.py
class AdalineSGD(object):
def __init__(self, eta=0.001, n_iter=1000,
shuffle=True, random_state=None):
self.eta = eta
self.n_iter = n_iter
self.w_initialized = False
self.shuffle_flag = shuffle
if random_state:
np.random.seed(random_state)
self.complex = 0 # Statistical algorithm complexity
def errors(self, X, y):
'''Statistics all errors into self.errors_'''
predicts = self.appendedX_.dot(self.w_)
diffs = np.where(predicts >= 0.0, self.positive, self.negtive) - y
errors = np.count_nonzero(diffs)
self.errors_.append(errors)
self.wsteps_.append(self.w_.copy())
cost = 1 / 2 * np.sum((y - self.appendedX_.dot(self.w_)) ** 2)
self.costs_.append(cost)
return errors, diffs
def shuffle(self, X, y):
'''Shuffle training data'''
r = np.random.permutation(X.shape[0])
return X[r], y[r]
def update_weights(self, xi, target):
'''Apply Adaline learning rule to update the weights'''
deltaw = self.eta * (target - self.net_input(xi))
self.w_[1:] += xi.dot(deltaw)
self.w_[0] += deltaw * 1
return deltaw
# 用于SGD的在线学习
def partial_fit(self, X, y):
'''Online update w after first training'''
if not self.w_initialized:
self.w_ = 1.0 * np.zeros(1 + X.shape[1])
self.w_initialized = True
for xi, target in zip(X, y):
self.update_weights(xi, target)
def update_labels(self, y):
# for sign and predict
self.positive = np.max(y)
self.negtive = np.min(y)
def fit_sgd(self, X, y):
samples = X.shape[0]
x_features = X.shape[1]
self.w_ = 1.0 * np.zeros(1 + x_features)
self.w_initialized = True
self.update_labels(y)
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
self.errors_ = []
self.costs_ = []
# record every w during whole iterations
self.wsteps_ = []
self.steps_ = 1 # every steps_ descent steps statistic one cose and error sample
while(1):
self.complex += 1
if(self.complex > self.n_iter):
print("Loops beyond n_iter %d" % self.n_iter)
return self
deltaws = []
for xi, target in zip(X, y):
deltaw = self.update_weights(xi, target)
deltaws.append(deltaw)
if (self.complex % self.steps_ == 0):
errors, diffs = self.errors(X, y)
if np.max(np.abs(np.array(deltaws))) < 0.0001:
print("deltaw is less than 0.0001")
self.wsteps_.append(self.w_.copy()) # record last w
return self
if self.shuffle_flag:
X, y = self.shuffle(X, y)
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
return self
# X is a vector including features of a sample
def net_input(self, X):
'''Calculate net input'''
return np.dot(X, self.w_[1:]) + self.w_[0] * 1
# X is a vector including features of a sample
def sign(self, X):
'''Sign function'''
return np.where(self.net_input(X) >= 0.0, self.positive, self.negtive)
# X is a vector including features of a sample
def predict(self, X):
'''Return class label after unit step'''
return self.sign(X)
|
为了比较 BGD 和 SGD 在训练时的效果,这里使用比较大的数据集: Iris 鸢尾花数据集。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | def irisTrainSGD(type=1):
import pandas as pd
'''
Columns Information:
1. sepal length in cm
2. sepal width in cm
3. petal length in cm
4. petal width in cm
5. class:
-- Iris Setosa
-- Iris Versicolour
-- Iris Virginica
'''
df = pd.read_csv('db/iris/iris.data', header=0)
# get the classifications
y = df.iloc[0:100, 4].values
y = np.where(y == 'Iris-setosa', 1, -1)
# get samples' features 2(sepal width) and 4(petal width)
X = df.iloc[0:100, [1,3]].values
if type:
irisPerceptron = AdalineSGD(0.001, 40, 0) # 关闭 shuffle
irisPerceptron.fit_sgd(X, y)
else:
irisPerceptron = AdalineGD(0.001, 40)
irisPerceptron.fit_adaline(X, y)
print('Weights: %s' % irisPerceptron.w_)
irisTrainSGD(1)
irisTrainSGD(0)
>>>
LastCost: 9.009440
Weights: [ 0.39266545 0.1224748 -1.06803989]
LastCost: 7.638876
Weights: [ 0.39475523 0.16713579 -1.12436288]
|
注意这里关闭了随机梯度下降的 shuffle,也即按样本顺序学习,每次选取一个样本更新权重。
对比两个曲线,它们的同样迭代 40 次,但是计算量是相同的,因为在 SGD 中我们循环处理了样本。SGD 允许我们在大数据时快速得到每次迭代的结果,由于 SGD 权重更新更频繁,通常它能更快收敛。由于梯度的计算是基于单个训练样本来完成的,因此其下降曲线不及批量梯度下降的平滑,另外在同样计算量下通常不能提供比 SGD 更精确的结果。这是一种折中。
重复强调:实际上要得到相同的精度结果,SGD 并不会比 BGD 降低整体的计算量,在训练周期上基本上是等价的。只是在大数据集上,我们并不需要得到最优值,而是使用 SGD 得到一个可以接受(可满足分类精度)的中间值,从而避免耗费大量时间去寻找最优值。
如果打开 SGD 的 shuffle 选项,则会得到一个比较平滑的曲线,与此同时也得到了更精确的权重值。为了通过随机梯度下降得到更加准确的结果,让数据以随机的方式提供给算法是非常重要的,这也是每次迭代都要打乱训练集以防止进入循环的原因。
0 1 | LastCost: 8.044033
Weights: [ 0.39865223 0.15436865 -1.08668558]
|
SGD 最大的优点是它的在线学习特性。即在对输入进行处理的时候,也可以不断学习,相比于之前的BGD,只是在训练的时学习并修正权重,等到对实际的预测数据进行处理时,是不能改变权重参数的。
但是由于 SGD 一次使用一个样本更新权重,使得输入很容易受噪声(错误样本)影响,这可以算缺点,也可以算优点,是缺点是因为噪声使得每次迭代不一定朝最优的方向,是优点是因为这样的情况有时可以避免网络(更复杂网络的代价函数可能是非凸函数)陷入局部最优。
SGD不会收敛到最小值,它只会在最小值附近不断的波动,它最大的缺点,就是不能通过向量化(并行化)来进行加速,因为每次都必须基于上个样本更新权重后才能进行下一个样本的迭代。
mini-batch 梯度下降¶
- 当每次是对整个训练集进行梯度下降的时候,就是 BGD 梯度下降。
- 当每次只对一个样本进行梯度下降的时候,是 SGD 梯度下降。
- 当每次处理样本的个数在上面二者之间,就是 MBGD (mini batch)梯度下降,也即小批量梯度下降。
当数据集很大时,BGD 训练算法是非常慢的,使用 MBGD 梯度下降更新参数更快,有利于更鲁棒地收敛,避免局部最优。 和 SGD 梯度下降相比,使用 MBGD 的计算效率更高,可以帮助快速训练模型。
实现小批量梯度下降,只需要在类中添加 fit_batch 类方法即可:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | def fit_mbgd(self, X, y, batchn=8):
samples = X.shape[0]
x_features = X.shape[1]
self.w_ = 1.0 * np.zeros(1 + x_features) + 1
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
self.errors_ = []
self.costs_ = []
# record every w during whole iterations
self.wsteps_ = []
self.steps_ = 1 # every steps_ descent steps statistic one cose and error sample
if samples <= batchn:
batchn = samples
elif batchn <= 0:
batchn = 1
batch_index = 0
batches = samples // batchn
print("Fit MBGD with batchn %d, batches %d." % (batchn, batches))
while(1):
self.complex += 1
if(self.complex > self.n_iter):
print("Loops beyond n_iter %d" % self.n_iter)
return self
# minmium cost function with partial derivative wi
for i in range(batchn):
start_index = batch_index * i
batchX = self.appendedX_[start_index : start_index + batchn, :]
batchy = y[start_index : start_index + batchn]
deltaw = -self.eta * ((batchy - batchX.dot(self.w_))).dot(batchX)
if np.max(np.abs(deltaw)) < 0.0001:
print("deltaw is less than 0.0001")
self.wsteps_.append(self.w_.copy()) # record last w
return self
self.w_ -= deltaw
if (self.complex % self.steps_ == 0):
errors, diffs = self.errors(X, y)
# must do shuffle, otherwise may lose samples at tail
X, y = self.shuffle(X, y)
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
return self
|
修改测试函数添加 fit_mbgd 测试分支:
0 1 2 3 4 5 6 7 8 9 10 11 12 | def irisTrainSGD(type=0):
......
elif type == 2:
irisPerceptron = AdalineSGD(0.001, 40)
irisPerceptron.fit_mbgd(X, y)
irisTrainSGD(2)
>>>
Fit MBGD with batchn 8, batches 12.
Loops beyond n_iter 40
LastCost: 6.712262
Weights: [-0.12631779 0.28915588 -1.14584922]
|
结合下图可以看出曲线具有大波动特征,这就是小批量的典型的波浪特征,结果要批量下降要好,在于在小批量更新权重之后进行了样本的随机处理。
MBGD 的 batch 大小也是一个影响着算法效率的参数:
- 如果训练集较小,一般小于 2K,就直接使用 BGD。
- 一般 MBGD 的批量选择 2 的 n 次幂会运行得相对快一些。这个值设为 2 的 n 次幂,是为了符合 CPU/GPU 的内存访问的对齐要求。
Adaline 本质分析¶
对比感知器和 Adaline,它们在算法思想上是非常一致的,仅有的区别在于:
- 一个使用离散值调整权重,一个使用连续值调整权重,这使得 Adaline 下降比较平滑
- 感知器只采用错误样本调整权重,所以每次调整权重,所有样本对权重的影响不同,单个样本对权重影响很大,而 Adaline 每次调节权重都是从所有样本出发(尽管 sgd 和 mbgd 不是每次都考虑所有样本,但是由于随机调整,效果是一致的)。
综上分析,感知器在线性可分问题上总是能找到完全正确的分类权重,Adaline 模型找到的是全局的最优权重,但是有可能不会将所有样本全部分类正确。
另外感知器计算量不依赖于数据集大小,所以感知器可以用来对大数据快速验证数据集是否线性可分。
示例中使用不同参数的正态分布生成两组线性可分的坐标点,并人为调整其中某个负分类样本点靠近正分类样本点,可以发现一个样本点对权重的影响将会变弱,以至于在训练时也未被正确分类,这类样本点常常被认为是异常样本点。
Adaline 显然对异常样本点具有很好的过滤作用,而不会像感知器一样很容易过拟合。
Adaline 模型表面上是寻找一组参数使得预测值与标签值最接近(实际是差的绝对值的平方最小,不过平方值和绝对值在整个实数域上拥有相同的极值点和增长趋势,直接以绝对值讨论不失准确性),实际上还可以在数学层面揭示更深刻的本质。
为了理解 Adaline 模型的 SSE 梯度下降的本质,首先画出 2 个样本点的情况,此时梯度下降很容易找到最优点,且代价函数为 0,显然就是经过两点连线间的中垂线,此时两点距离分割直线有着最大距离。我们把正负样本点分别扩大到 20 个点,会发生什么?
图中红色点是正负样本点的坐标均值处,我们看到分离直线两侧灰色直线试图穿过均值坐标点(代价函数越小,越接近),为什么会有如此规律?显然这不是偶然的。我们尝试从数学的角度分析:
式 (0) 是分割直线(高维度为超平面)方程式,式 (1) 对应穿越正样本的灰色直线,而式 (2) 则对应穿越负样本的灰色直线。由代价函数可以知道,梯度下降的目标是找到一组参数 w,使得正样本与直线 (1) 的距离最小,负样本与直线 (2) 的距离最小,此时这两条直线中间的平行线就是 (0),它使得正负样本与它的距离尽力保持(也即最优化)相等,等于:
如果对支持向量机 SVM 有所了解,那么就更容易理解支持向量机的本质了。Adaline 模型使用所有点计算分离超平面,而 SVM 使用支持向量来寻找分离超平面,这样计算量就大大降低了(算法实现的复杂度上升了)。
0 1 2 3 4 5 6 7 8 9 10 | def update_weights(self, xi, target):
if target > 0 and self.net_input(xi) > target:
return 0
if target < 0 and self.net_input(xi) < target:
return 0
deltaw = self.eta * (target - self.net_input(xi))
self.w_[1:] += xi.dot(deltaw)
self.w_[0] += deltaw * 1
return deltaw
|
实际上我们可以在 Adaline 模型上稍微改动,由于我们是希望两个样本分类距离分离平面最大,那么对于正(负)样本预测值大于 1(小于-1 )的点可以不再用于更新权重,只使用其余点(距离分离平面距离在1/|w|之内的点)更新权重,那么将会获得与 SVM 类似的分离超平面:
此时只要是线性可分的样本点就一定会找到全部正确分类的平面,但是却存在过拟合可能。这里的灰色直线靠近分离直线,是因为靠近分离直线的样本点权重更大了(也即实际的平均值向分离平面靠近了,由于正负样本靠近分离平面的点数不同,距离不同,平均值的移动大小也就不同了,看起来分离平面向某一分类的样本点靠拢了)。
同样如果正负样本点数量不是均衡的,而是有着较大的悬殊,那么较小样本点对权重的调整机会占比就会变小,从而误差增大,例如在一个正负样本6:1的分类情况如图所示:
数据标准化和梯度下降¶
数据标准化令特征值在各个维度上尺度基本一致,并且整体围绕原点,方差为 1,这时的代价函数曲面(这里以单变量情况为例)看起来是一个最优值在原点附近,在 x1 和 x2 特征上都对称的抛物面,对于任意初始化值都能保证较快的下降速度,且下降曲线很平滑。标准化之后的数据围绕在原点,方差为1,所以权重不会是一个很大的值,否则很难满足点乘和为 1 或 -1,通常取 w 的初始化值为 0 附近的随机值。
注意:权重的初始化值意味着从代价函数曲面上选择一个点然后进行梯度下降。
对比大部分的教科书示例,这两幅图更有说明意义,数据标准化并不能保证所有初始化值都能比未标准化数据更高效,而是能保证在标准化之后,可以将权重初始化为 0 附近的值,并确保能得到一个性能较好的梯度下降。
如果数据量很大,特征值维数很高,那么未标准化的数据就很难找到一个像示例中下降如此之快的初始化值了,这完全取决于运气。
数据集划分和交叉验证¶
scikit-learn 中的 model_selection(原 cross_validation,0.20版本将被移除)模块提供了 train_test_split 函数,可以随机将数据样本集 X 与标签向量 y 按照比例划分为测试数据集和测试数据集。
0 1 2 3 | from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, \
y, test_size=ratio, random_state=random_state)
|
test_size 是测试数据集的百分比,random_state 随机选择开关,random_state = 0 表示每次都随机取值,如果要复现结果,则需要设置为固定值的非 0 值。 这里把它封装为 data_split 函数中,并放在 crossvalid.py 文件中,方便使用:
0 1 2 3 4 5 6 | # crossvalid.py
def data_split(X, y, ratio=0.3, random_state=0):
from sklearn.model_selection import train_test_split
# 'X_train, X_test, y_train, y_test = '
return train_test_split(X, y, test_size=ratio, random_state=random_state)
|
sklearn 感知器模型¶
Perceptron 模块位于 sklearn 的线性模型 linear_model 中。
在 sklearn 的线性模型中,w0(阈值或者截距)对应模型的 intercept_ 成员,而其他权重对应 coef_ 成员。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | def sklearn_perceptron_test():
from sklearn.linear_model import Perceptron
from sklearn.metrics import accuracy_score
import pandas as pd
import crossvalid,scaler
# 加载鸢尾花数据
df = pd.read_csv('db/iris/iris.data', header=0)
y = df.iloc[0:100, 4].values
y = np.where(y == 'Iris-setosa', 1, -1)
# get samples' features 2(sepal width) and 4(petal width)
X = df.iloc[0:100, [1,3]].values
# 分割数据集:测试集和验证集
X_train, X_test, y_train, y_test = crossvalid.data_split(X, y, ratio=0.3, random_state=0)
# 标准化数据(使用梯度下降算法模型均需要标准化)
ds = scaler.DataScaler(X_train)
X_train = ds.sklearn_standard(X_train)
X_test = ds.sklearn_standard(X_test)
# 在数据初级训练阶段,可以把 random_state 设置为 0,以便不同算法的效果对比
clf = Perceptron(max_iter=10, n_jobs=1, eta0=0.001, random_state=0)
# 训练模型
clf.fit(X_train, y_train)
# 预测结果,并打印准确度
predict = clf.predict(X_test)
print("Misclassified number {}, Accuracy {:.2f}%".format((predict != y_test).sum(),
accuracy_score(y_test, predict)*100))
sklearn_perceptron_test()
>>>
Misclassified number 0, Accuracy 100.00%
|
- 首先实例化了一个新的Perceptron对象
- 并通过fit方法训练模型。
- 参数eta0设定学习速率
- 参数n_iter定义了迭代的次数(遍历训练数据集的次数)。
- 参数 n_jobs 可以设置是否多 CPU 处理。
- random_state参数在每次迭代后初始化重排训练数据集。
合适的学习速率需要通过实验来获取。如果学习速率太大,算法可能会跳过全局最优点,产生震荡(抖动)而无法收敛;如果学习速率太小,算法将需要更多次的迭代以达到收敛,这将导致训练速度变慢。
尽管这里的准确度达到了惊人的 100%,并不意味着模型能很好地预测未知数据,而很可能是过拟合了。通过交叉验证可以观察模型是否过拟合了。
感知器的底层实现等价于:
0 1 | from sklearn.linear_model import SGDClassifier
SGDClassifier(loss="perceptron", eta0=1, learning_rate="constant", penalty=None)
|
这里尝试使用我们实现的感知器模型和 sklearn 感知器模型进行性能对比,当然结果相当不容乐观:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | # 采用相同的数据处理
def iris_dataset_prepare(ratio=0.3, random_state=0):
import pandas as pd
import crossvalid,scaler
df = pd.read_csv('db/iris/iris.data', header=0)
# get the classifications
y = df.iloc[0:100, 4].values
y = np.where(y == 'Iris-setosa', 1, -1)
# get samples' features 2(sepal width) and 4(petal width)
X = df.iloc[0:100, [1,3]].values
X_train, X_test, y_train, y_test = crossvalid.data_split(X, y, ratio=ratio,
random_state=random_state)
ds = scaler.DataScaler(X_train)
X_train = ds.sklearn_standard(X_train)
X_test = ds.sklearn_standard(X_test)
return X_train, X_test, y_train, y_test
# 测试手动实现的感知器模型
def perceptron_test():
X_train, X_test, y_train, y_test = iris_dataset_prepare()
import time
import perceptron
irisPerceptron = perceptron.Perceptron(0.01, 50)
start = time.time()
irisPerceptron.fit_batch(X_train, y_train)
print("time {:.4f}ms".format((time.time() - start) * 1000))
predict = irisPerceptron.predict(X_test)
errnum = (predict != y_test).sum()
print("Misclassified number {}, Accuracy {:.2f}%".format(errnum, \
(X_test.shape[0] - errnum)/ X_test.shape[0] * 100))
# 测试 sklearn 感知器模型
def sklearn_perceptron_test():
from sklearn.linear_model import Perceptron
from sklearn.metrics import accuracy_score
import time
X_train, X_test, y_train, y_test = iris_dataset_prepare()
clf = Perceptron(max_iter=50, n_jobs=1, eta0=0.01, random_state=0)
start = time.time()
clf.fit(X_train, y_train)
print("time {:.4f}ms".format((time.time() - start) * 1000))
predict = clf.predict(X_test)
print("Misclassified number {}, Accuracy {:.2f}%".format((predict != y_test).sum(),
accuracy_score(y_test, predict)*100))
perceptron_test()
sklearn_perceptron_test()
|
在相同数据集,相同参数情况下,都使用单 CPU ,对比结果同样令人深刻,大约相差了 13 倍,实际上在更大数据集上进行验证,sklearn 模块性能还要突出。所以如果不是要深入研究算法的特性,而是要应用到实际环境,那么选择已经高度优化(全球最顶级开发人员的智慧)的软件库绝对是明智的选择。
在后序的更复杂的模型实现中,我们不再进行这种无谓的对比,而是尝试不同算法之间的对比,这种对比对实际应用更有意义。
0 1 2 3 | time 12.9986ms
Misclassified number 0, Accuracy 100.00%
time 0.9975ms
Misclassified number 0, Accuracy 100.00%
|
另外不得不提,sklearn 中的大多数算法均使用一对多(One-vs-Rest,OvR)方法来支持多类别分类,并且分类标签可以为任意不同的整数值,算法内部会自动调整。
sklearn Adaline模型¶
Adaline模型被实现在 SGDClassifier 中,只要指定代价函数类型为 squared_loss 即可。
0 1 2 | from sklearn.linear_model import SGDClassifier
clf = SGDClassifier(loss='squared_loss', max_iter=50, eta0=0.01,
random_state=0, learning_rate="optimal", penalty=None, shuffle=False)
|
所有的基于随机梯度下降算法的模型均可在这里找到,比如 loss 指定为 ‘log’ 即为逻辑回归分类算法。
线性回归与正则化¶
回归拟合和正则化¶
分类模型用于离散量的预测,而回归模型(regression model)可用于连续型变量。比如某种物品的价格波动,销售量,某地区不同时间的降水,气温变化等等。
线性回归¶
众所周知,连续函数在坐标系中表示出各类直线或者曲线,所谓线性回归,就是使用线性回归函数(也称为回归方程,Linear Regression Equations)来拟合所有的样本点,以使得代价最小,并能有效预测未知数据。用于拟合训练数据的回归函数被称为假设函数(Hypothesis Function)。
我们有这样一组数据,假设样本只有一个样本特征值 x1,你可以把它想象成某种物品的品质(纯度,精度等等),而 y 是这种物品的单位价格。我们有了以上训练样本,如何在给定新的品质特征值时,来预测它的价格 y 呢?
显然可以使用一条直线来预测新数据,关键是我们如何找到这条直线的截距(直线方程的常量)和 x1 的参数(也即权重系数)。显然 x1 是变量,而预测值 y 是因变量,这里只有一个变量,所以也被称为单变量线性回归(Linear Regression with One Variable)。
上式是单变量(一元)直线方程,对于训练集来说,一个样本就对应上图中的 1 个点,所以一个 x1 也就对应一个 y,在已知一组 x1(训练样本) 和一组对应的 y(目标值)时,如何反推出参数 w1 和 b 呢?线性回归问题就是在一组训练集和寻找最佳拟合参数的过程。所以假设函数是在参数 w1 和 b 条件下的关于 x1 的函数:
我们的目标是寻找一组参数 w1 和 b 使得每个样本的预测值与其对应的标签值误差和最小。
图中的蓝色线段对应预测值和真实值(回归问题中也被称为目标值 ,Target Value;分类问题中被称为标签值,显然它是离散的 )的误差,显然由于误差有正有负,直接相加会相互抵消,取绝对值相加是一个好办法,不过绝对值函数有不好的特性,不是连续可导的,无法利用梯度下降令代价函数最小。通常取差的平方和(SSE),当它最小时,那么预测值就和目标值最接近。
代价函数(Cost Function)也被称作平方误差函数。之所以要求出误差的平方和,是因为误差平方代价函数,对于大多数问题,特别是回归问题,都是一个合理的选择。实际上这里对平方误差和(SSE)取了平均(除以了n),所以实际上描述的是均方差(MSE),对于一个训练集来说 n 是一个不变的常数,所以 SSE 和 MSE 最优结果是一致的。另外注意到式中的 1/2,这只是为了方便求导数,它在求导数时被约掉了。
依据数学理论,可以看到代价函数是一个凹陷的曲面,实际上它是一个严格的凸函数,可以取到极小值,我们可以通过求导数,然后令导数表达式为 0 直接使用代数法求解参数,也可以利用梯度下降法,在大规模问题上,直接求解需要求系数的逆矩阵(有时候逆矩阵不存在),耗时费力,并且很难观察中间结果,通常梯度下降是个好办法。
实现线性回归¶
这里从最简的单变量线性回归入手,它在讨论梯度下降上既简单(利于图形化),又不失一般性。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | # linearegress.py
class LRegress():
def __init__(self, b=None, w1=None, eta=0.001, tol=0.001):
self.eta = eta
self.tol = tol
np.random.seed(None)
self.b = b
if self.b is None:
self.b = np.random.randn(1)[0]
self.w1 = w1
if self.b is None:
self.w1 = np.random.randn(1)[0]
# both w and b is verctor, and X is 2D array
def hypothesis(self, X):
return self.b + self.w1 * X[:,0]
def predict(self, X):
return self.hypothesis(X)
# MSE/LSE Least square method
def cost(self, X, y):
return np.sum((self.hypothesis(X) - y)**2) / X.shape[0] / 2
def delta_b(self, X, y):
return np.sum(self.b + self.w1*X[:,0] - y) / X.shape[0]
def delta_w(self, X, y):
derective = (self.b + self.w1*X[:,0] - y) * X[:,0]
return np.sum(derective) / X.shape[0]
|
定义 LRegress 类,初始化函数中参数 eta 和 tol 分别对应学习率和最小下降值,如果已经接近最优值并小于 tol 则退出迭代。
- 参数 b 和 w1 通常选择 0 附近的随机值,特别是在数据标准化之后,这有助于加快梯度下降速度。
- hypothesis 是假设函数,通过它计算预测值
- cost 在当前参数 (w1 和 b) 上计算代价,也即 MSE。
- delta_b 和 delta_w 用于计算梯度下降时,b 和 w1 的下降系数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def bgd(self, X, y, max_iter=1000):
# for drawing Gradient Decent Path
self.costs_ = []
self.bs_ = []
self.w1s_ = []
self.steps_ = 1
self.complex = 0
for loop in range(max_iter):
cost = self.cost(X, y)
if(cost < self.tol):
print("cost reduce very tiny less than tol, just quit!")
return
# cache to store delta
delta_b = self.eta * self.delta_b(X, y)
delta_w1 = self.eta * self.delta_w(X, y)
if self.complex % self.steps_ == 0:
self.bs_.append(self.b)
self.w1s_.append(self.w1)
cost = self.cost(X,y)
self.costs_.append(cost)
# update w1 and b together
self.b -= delta_b
self.w1 -= delta_w1
self.complex += 1
|
类的核心部分就是上面的梯度下降函数 bgd,由于我们基于所有数据进行梯度下降,所以是批量梯度下降(BGD),下降曲线非常平滑。注意更新参数时需要同时更新 b 和 w1,否则如果先更新了 self.b,那么在计算 delta_w1 时就会使用新的 self.b,这和数学理论是不一致的。
- costs_,bs_,w1s_ 用于统计梯度下降过程中的代价值,b 和 w1 的权重变化,用于绘制下降曲线。
- steps_ 则指定了统计周期的步长,越小统计采样越密集。
0 1 2 3 4 5 6 7 | def load_linear_dataset(random_state=None, features=1, points=50):
rng = np.random.RandomState(random_state)
# Generate sample data
x = 20 * rng.rand(points, features) + 2
y = 0.5 * (x[:,0] - rng.rand(1, points)).ravel() - 1
return x, y
|
load_linear_dataset 使用 numpy 的正态分布函数生成模拟数据,我们可以调整生成点数,也可以生成多特征的样本,这里 features 指定为 1。
线性回归和梯度下降¶
测试函数非常简单,首先生成随机数据,为了能够重复实验结果,random_state 设置为 0。
0 1 2 3 4 5 | def LRTest():
samples = 50
X, y = load_linear_dataset(random_state=0, features=1, points=samples)
lr = LRegress(b=5, w1=5, eta=0.005, tol=0.001)
lr.bgd(X, y, max_iter=100)
|
为了观察梯度下降的过程,我们把 b 和 w1 初始化为较大的值 5,以学习率 0.005 进行 100 次梯度下降。
在进行 100 次梯度下降后,得到的拟合直线并不完美,准确说相当糟糕。是迭代次数不够吗?
继续观察下降曲线,几乎所有的下降都在第一次完成,接下来的下降速度非常缓慢,显然简单增加迭代次数绝不是个好办法。
接着观察 3D 代价函数的曲面图,可以发现第一次下降几乎就到了谷底,接着从此点到最优点的下降速度非常慢?为何会出现这种现象?
代价函数曲面在某个方向上的波动程度和在该方向上的偏导数相关,也即梯度越大,上升越快,反之则平缓。
对比 b 和 w1 参数的偏导数只是相差变量 xi,显然如果 xi 整体上大于 1,那么相当于放大了 w1 方向的梯度,否则相当于压缩了 w1 方向的梯度。
我们观察 x1 和 y 样本点分布图,x1 的坐标分布在 0 - 20 之间,显然整体上可以认为近似放大了 10 倍,这样使得整个下降曲面在 w1 方向非常陡峭,而在 b 方向非常平缓。所以第一次下降之所以取得非常大的下降效果,基本上就在于 w1 方向上下降的贡献。
此时的代价函数输出为 1.26891864292,还处在远大于 0 的地方,这里尝试将迭代次数增大到 5000 次,可以获得比较满意的效果。
我们已经指出通过简单增加迭代次数不是个好办法,因为 b 方向的梯度非常平缓,那么是否可以增加学习率呢?
学习率导致的震荡¶
这里尝试将学习增加一倍:从 0.005 调整为 0.01,迭代次 100 的结果为还是很不理想,代价函数最终输出为 1.99786346573,结果似乎更差了。
在学习率增加后,下降曲线反而平缓了,上例中第一次迭代就从 2000 下降到了 2 以下,这次反而在迭代 100 次之后才刚刚下降到 2 。到底发生了什么?
从代价函数的曲面图中可以看出原因,由于 w1 方向非常陡峭,过大的学习率使得每次调整参数总是跳过最优点,而在 w1 方向产生了震荡,庆幸的是每次震荡到对侧还是下降的,最终还是能够收敛最优值,但是显然我们浪费了很多次迭代。
那么如何解决这种问题呢?暂时先放在一边,如果我们继续增大学习率会发生什么呢?直觉上会向上方震荡发现,而根本不能收敛。实际上为了画出比较理想的图形,需要异常小心得选择 eta,否则发散速度非常快,以至于无法作图,这里将 eta 设置为 0.011,并且迭代次数调整为 8。
0 1 | lr = LRegress(b=5, w1=5, eta=0.011, tol=0.001)
lr.bgd(X, y, max_iter=8)
|
显然较大的学习率容易导致代价函数无法收敛,并且以极快的速度发散(由于取均方差,所以是以平方的形式发散)。
在代价函数曲面图上,下降根本没有发生,而是从谷底发散开来。
当然这里的初始参数均为 5,如果我们很幸运地一开始就初始化在了谷底,显然较大的学习率将能够加快 b 方向上的收敛速度,事实确实如此。然而再更多的变量情况下,我们根本不可能指望能够选择这样一组参数,幸运的可能性非常之低。
难道只能依靠以较小的学习率和庞大的迭代次数来解决这种问题?当然不是。为何会出现在 w1 方向震荡的情况,我们已经知道代价曲面在 w1 方向上异常陡峭,而在 b 方向上却非常平缓,是否可以对数据做一些处理,令两个方向的梯度大体一致呢?答案就是数据标准化。
数据标准化和梯度下降¶
我们不能指望现实中的量纲都处在一个数量级,例如长度,密度,体积,重量,价值等等,它们具有不同的单位。房屋价格可能和面积,房间数均有关,但是它们的单位相差悬殊。数据标准化就是把不同量纲的特征值进行正态分布处理,处理后的数据在各个特征值上均值为 0 ,均方差(标准差)为 1。
0 1 | (trainData - mean(trainData)) / std(trainData)
(testData - mean(trainData)) / std(trainData)
|
标准化处理公式如上所示,注意测试集和训练集一样均需使用 训练集 的均值和标准差统一处理,这一点非常重要。同样在实际预测时,特征值也需要同样的处理。
由于在预测时也需要对数据进行标准化处理,所以需要记录训练集的均值和标准差,对 bgd 函数和预测函数进行更新。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | # 新增标准化处理函数,并记录训练集的均值和标准差
def standard(self, X):
self.mean = np.mean(X, axis=0)
self.std = np.std(X, axis=0)
assert(np.std(X, axis=0).any())
return (X - self.mean) / self.std
# 更新预测函数
def predict(self, X):
try:
X = (X - self.mean) / self.std
finally:
return self.hypothesis(X)
# 添加 standard 开关,是否对数据进行标准化
def bgd(self, X, y, max_iter=1000, standard=True):
# for drawing Gradient Decent Path
self.costs_ = []
self.bs_ = []
self.w1s_ = []
self.steps_ = 1
self.complex = 0
if standard: X = self.standard(X)
for loop in range(max_iter):
cost = self.cost(X, y)
if(cost < self.tol):
print("cost reduce very tiny less than tol, just quit!")
return X
delta_b = self.eta * self.delta_b(X, y)
delta_w1 = self.eta * self.delta_w(X, y)
# update weights and b together
if self.complex % self.steps_ == 0:
self.bs_.append(self.b)
self.w1s_.append(self.w1)
cost = self.cost(X,y)
self.costs_.append(cost)
self.b -= delta_b
self.w1 -= delta_w1
self.complex += 1
# 返回标准化数据
return X
|
在数据标准化后,x1 被压缩到了 [-2, 2] 范围内。此时 x1 特征值基本分布在原点周围,且方差为 1,那么在 w1 方向上它对梯度的影响就很小了,代价曲面看起来就是一个在 w1 和 b 两方向梯度基本均等的曲面。
我们可以使用非常高的学习率进行梯度下降,在迭代 100 次后就达到了非常低的代价值 0.0103701224044。所以上图中的拟合直线非常标准,几乎就是最优值。
数据标准化后代价函数下降曲线非常平滑,不会出现有时候剧烈下降,有时又不动的情况,实际上在迭代 40 次以后就可以停止了,此时的误差也只有 0.011。
观察代价函数的曲面图,它和直觉上的分析一致,在 b 和 w1 维度上比例保持一致,等高线成了标准的圆环。梯度下降的路径成了非常理想的弧线。最优值基本在 w1 靠近 0 附近,所以将参数初始化为 0 附近的随机值将会加快梯度下降速度。
使用数据标准化要注意的是:要在预测时对数据采取同样的标准化处理。使用以上模型尝试对一些值进行预测,对照标准化之前的数据样本散点图,和实际是基本一致的。
0 1 2 3 | print(lr.predict(np.array([[5],[10],[15]])))
>>>
[1.22770885 3.77016164 6.31261442]
|
目标值的标准化¶
通常不对真实值(Target Value,目标值)进行标准化。从代价函数的偏导公式可以看出,各方向的梯度大小和 y 值有关,但是它们之间的比例和 y 就没有关系了,决定代价函数曲面形状的是它们之间的比例。
有时如果遇到 y 的取值范围非常大,并且训练集样本数很大,那么就可能在计算中产生溢出的情况,此时也可以考虑对 y 进行标准化。
对 y 标准化除了可以防止溢出问题,还可以将最优的常数项 b (w0) 移动到 0,这对把 b 初始化为 0 是有帮助的。b 实际上就是拟合直线的截距,显然当 y 进行标准化之后,该直线将穿过原点,在线性回归或者分类问题中,可以发现 sklearn 函数提供了 fit_intercept 参数,当它设置为 False 时,截距总是 0,不参与计算,可以节约训练时间。
当然对 y 标准化处理后得到的模型进行预测时要进行反向处理,也即:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | def LRTest():
samples = 50
X, y = load_linear_dataset(random_state=0, features=1, points=samples)
import scaler
# 记录 y_mean 和 y_std,预测时使用
y_mean = np.mean(y)
y_std = np.std(y)
y = scaler.standard(y)
lr = LRegress(b=5, w1=5, eta=0.1, tol=1e-4)
X = lr.bgd(X, y, max_iter=50, standard=True)
y_predict = lr.predict(np.array([[5],[10],[15]]))
y_realp = y_predict * y_std + y_mean # 求真实预测值
print(y_predict)
print(y_realp)
LRTest()
>>>
[-1.42659576 -0.49070967 0.44517643] # 预测值
[ 1.26809748 3.83168409 6.3952707 ] # 真实预测值
|
上面的真实预测值和未标准化时的预测基本是一致的。
上图中可以清晰地看到最优点时的 b 基本位于 0 处。等高线成了围绕在 0 附近的同心圆环。
线性回归和随机梯度下降¶
如果训练数据集非常庞大,比如数十万甚至百万级别,那么在整体数据上求得偏导数,然后计算每次下降的 delta 参数将非常耗时,甚至内存的限制也无法一次加载所有数据。这时候就需要使用随机梯度下降。
随机梯度(SGD)下降基于大数定理,随机选取的子集的分布能够反映整体数据的分布,当在随机选取的子集上训练次数越来越多,最终就会接近批量梯度下降的效果。SGD每次选取一个样本进行权重调节,如果一次选取多个样本,则称为小批量梯度下降(MBGD)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | def sgd(self, X, y, max_iter=1000, standard=True):
# for drawing Gradient Decent Path
self.costs_ = []
self.bs_ = []
self.w1s_ = []
self.steps_ = 1
self.complex = 0
STDX = self.standard(X) if standard else X
import scaler
# 每次迭代进行乱序处理,以期找到较好拟合结果
X,y = scaler.shuffle(STDX, y)
for loop in range(max_iter):
import scaler
X,y = scaler.shuffle(STDX, y)
for Xi, yi in zip(X, y):
Xi = Xi.reshape(Xi.size, 1)
cost = self.cost(Xi, yi)
if(cost < self.tol):
print("cost reduce very tiny less than tol, just quit!")
return STDX
delta_b = self.eta * self.delta_b(Xi, yi)
delta_w1 = self.eta * self.delta_w(Xi, yi)
# update weights and b together
if self.complex % self.steps_ == 0:
self.bs_.append(self.b)
self.w1s_.append(self.w1)
cost = self.cost(X,y)
self.costs_.append(cost)
self.b -= delta_b
self.w1 -= delta_w1
self.complex += 1
return STDX
|
测试代码进行一些调整,由于每次基于单个数据下降,那么 tol 应调小一些,b 和 w1 这里初始化为 15,以便观察到代价曲面中梯度下降的扭曲现象。
0 1 2 3 4 | def LRTest():
samples = 50
X, y = load_linear_dataset(random_state=0, features=1, points=samples)
lr = LRegress(b=15, w1=15, eta=0.1, tol=1e-4)
X = lr.sgd(X, y, max_iter=100, standard=True)
|
sgd 函数实现随机梯度下降,注意每次迭代前进行数据的乱序处理,显然随机梯度下降在小范围内可能出现逆调整,也即下降曲线比较粗糙,偶然上升,但是整体趋势在不断下降,并接近 BGD 的效果:
观察随机梯度下降代价函数曲面上的下降过程,路径弯弯曲曲,局部体现为随机漫步,整体在靠近等高线的圆心。
线性回归和非线性回归¶
已经看到线性回归得到的拟合模型,似乎总是对应直线。实际上并非总是如此,线性回归的假设函数的标准形式(k表示每个样本的特征数):
式中的 \(w_i\) 表示参数(Parameters,或者权重),\(x_i\) 表示自变量(Independent variable)。每个参数只与其中的一个自变量相乘,然后叠加。这就是线性的本质:一个参数只以线性相乘的方式影响一个自变量。
当然可以使用多项式的形式对上式进行扩充,例如:
这里可以认为基于基本特征,扩展了新特征 x3,x4 和 x5。其中 x3 = x1*x1,x4 = x1*x2 等。这可以得到曲线形式的线性回归,当然可以扩充到任意次多项式,通常不会用到超过 3 次项形式。扩充特征的方式不仅仅是指数函数,还可以是倒数,或者以 e 为底的 x1 次方等等。
常见的非线性回归假设函数有参数的指数形式,以及参数的傅里叶形式,这里不做深入讨论。
多项式线性回归¶
这里使用一组身体质量指数 (BMI,体重(kg)/ 身高(m)),尝试通过 BMI 来预测肥胖率。BMI 数据实际上有四列,对应一组中学生的身高,体重,BMI指数以及肥胖程度。这里只用到了 BMI 和目标值。
0 1 2 3 4 5 6 7 | def BMITest():
import scaler
# 梯度下降中会对数据进行标准化,在加载数据时无需做标准化处理
X,y = dbload.load_bmi_dataset(standard=False)
X = X[:,2].reshape(X.shape[0],1) # last column is BMI
lr = LRegress(b=5, w1=5, eta=0.1, tol=0.001)
X = lr.bgd(X, y, max_iter=100, standard=True)
|
这里使用批量梯度下降,迭代 100 次,采用较大的学习率 0.1,得到下降曲线。显然在迭代 40 次后基本达到最优值,但是此时的代价函数返回 6.46,实际上误差还是很大的。
从获得的拟合直线图上可以看出直线基本已经位于所有训练样本点的中心,更多的迭代无法使代价函数继续下降。
从曲面图的等高线上也可以验证这一点,参数已经取到了最优值,这说明我们选择的假设函数并不能很好地拟合 BMI 数据。实际中可以使用交叉验证方式来评估模型的准确度。
观察样本点的分布情况,肥胖率和BMI指数之间似乎不是严格线性的,而是一个弧形,也即是一个曲线。
这里直接使用 sklearn 中的线性回归模型 LinearRegression,尝试使用多项式进行拟合。为了进行多项式拟合,首先定义特征扩展函数:
0 1 2 3 4 5 6 7 8 9 10 11 12 | # extend style as [x1,x2] to [1, x1, x2, x2x1, x1^2, x2^2]
def poly_extend_feature(X,degree=2):
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=degree)
return poly.fit_transform(X)
X = np.array([[1],[2]])
extendX = poly_extend_feature(X)
print(extendX)
>>>
[[ 1. 1. 1.]
[ 1. 2. 4.]]
|
这里验证特征扩展函数,由于我们只有一项特征值 BMI,所以这里使用 2 行 1 列的模拟数据来测试 poly_extend_feature。对于样本 1 它的 x1 就是 1,对应第一项为常数项 1,第二项为 x1 本身,第三项为平方项,观察第二行数据,处理方式相同。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | def BMISklearnTest():
from sklearn.linear_model import LinearRegression
# 加载数据
X,y = dbload.load_bmi_dataset(standard=False)
X = X[:,2].reshape(X.shape[0],1) # last column is BMI
# y = b + w1x + w2* x** 2,扩展数据
extend_X = poly_extend_feature(X, degree=2)
# 训练模型,fit_intercept 为 True 将同时训练常数项
# 由于数据扩展的第一列全为 1,所以就对应了常数项,可以设置为 False
lr = LinearRegression(fit_intercept=False)
lr.fit(extend_X, y)
print(lr.coef_)
# 计算代价函数的最终值
cost = np.sum((lr.predict(extend_X) - y)**2) / extend_X.shape[0] / 2
print("cost:\t%f" % cost)
# 评估模型得分
print("score:\t%f" % lr.score(extend_X, y))
plt.figure()
x1 = np.linspace(10, 40, 50, endpoint=True).reshape(50,1)
extend_x1 = poly_extend_feature(x1, degree=2)
plt.plot(x1, lr.predict(extend_x1), c='red')
plt.scatter(X, y, c='black', marker='o')
plt.xlabel("BMI")
plt.ylabel("Fat%")
plt.show()
BMISklearnTest()
>>>
[-23.18746064 3.28574267 -0.03998913]
cost: 6.040900
score: 0.760597
|
打印结果第第一行分别对应 b (w0),w1 和 w2,它存储在 coef_ 类成员中。由于 LinearRegression 没有提供计算代价值的函数,这里使用MSE方式实现,结果为 6.04,比我们的线性模型要好一些,score 则表示对模型的评估,也即预测准确率在 76%。
当然可以使用更复杂的多项式,比如3次或者4次,但是观察数据分布,实际上我们无法再通过简单的 BMI 指数一个特征值来提高预测肥胖度的准确率了。
过拟合与正则化¶
观察下图中的幂函数,指数取得越大,曲线变化率越大。所以多项式的次数取得越高,那么它的表达能力就越强,以至于可以穿过所有训练样本点,使得代价函数为 0 ,但是它的泛化能力却很差。
实际上这是一个平方曲线,进行了部分点的干扰,如果使用直线进行拟合,则会有较大的偏差,导致欠拟合,而这里使用6次多项式使得曲线几乎可以穿过所有训练集样本点,而使用它来预测效果并不好,例如取 0.3 和 3.1 进行预测,与真实值偏差很大,这就是典型的过拟合:过于强调拟合原始数据,而丢失了算法的本质:预测新数据。
显然使用下图的二次多项式来拟合,效果会更好。但是实际应用中,我们无法这么直观地通过图像来观察拟合模型是否刚刚好,实际上2次多项式是6次多项式的子集,只需要其他高次项的参数接近 0,这样高次项对整条曲线的影响就很小了,曲线看起来就很平滑。使用正则化来降低过拟合的思想就来源于此。
下图展示了不同参数的高次项对直线弯曲程度的影响,显然参数 0.1 的 5 次方项对直线 y = x 的弯曲程度影响最小:
L2 正则化和岭回归¶
如果我们知道哪些参数影响高次项,可以选择给与惩罚,使其对曲线影响很少,但是如果我们有非常多的特征,我们并不知道其中哪些特征对应的参数要进行惩罚,或者我们根本无法通过人工来区分,这样可以选择对所有的特征参数都进行惩罚,并且让代价函数最优化的程序自动选择惩罚程度。
这样的结果是得到了一个较为简单的能防止过拟合问题的代价函数:
我们在 MSE 代价函数中加入了权重的平方和,它被称为 L2 惩罚项(L 为 Least 缩写),使用 L2 方式进行正则化的回归就是岭回归(Ridge Regression)。
其中 λ 又称为正则化参数(Regularization Parameter)。根据惯例,不对常数项 b(也即 w0)项进行惩罚。
为何增加了 L2 惩罚项,就可以缓解过拟合情况呢?为了使代价函数尽可能的小,所有的参数值 w(不包括w0)都会在一定程度上减小。如果 λ 的值取很大,那么 w 都会趋近于 0,这样我们得到的就近似一条平行于 x 轴的直线,就会造成欠拟合,相反,如果取得很小就会过拟合。
对于正则化参数 λ,要取一个合理的的值,这样才能既不欠拟合也不过拟合。
sklearn 中的线性模型提供了岭回归的算法实现。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def load_curve_dataset(random_state=None, features=1, points=50):
x = np.linspace(0.5, 3, points, endpoint=True).reshape(points,1)
y = x**2 - 1
y[1] = 0.4 # 向添加数据噪声
y[4] = 2
y[7] = 5
y[-1] = 6.5
return x, y
def RidegeTest():
from sklearn import linear_model
X, y = load_curve_dataset(random_state=0, features=1, points=10)
extend_X = poly_extend_feature(X, degree=6)
lr = linear_model.Ridge(fit_intercept=False, alpha=500)
lr.fit(extend_X, y)
print(lr.coef_)
RidegeTest()
>>>
[[ 0.00526983 0.01128546 0.02135532 0.03802435 0.0609829 0.07208664
-0.02267014]]
cost: 0.199519
score: 0.936215
|
λ 正则化参数通过 alpha 设置,这里使用了非常高的正则化参数 500,最终的拟合曲线看起来平滑多了:
由于上述数据只有 1 个特征值,为了便于绘图并和过拟合曲线进行比较,这里没有对数据进行标准化,实际应用中一定要对数据做标准化处理。
正则化参数的选择¶
当正则化参数很大时,所有权重都将趋近于 0,那么如何选择合适的 λ,以使得泛化效果最好呢?首先看一下权重在不同正则化参数时,权重的变化情况。
这里使用 BMI 数据的前两行(身高和体重)作为特征值,并且没有取高次项,也即线性方程为 y = w0 + w1x1 + w2x2,来观察权重参数和正则化参数的变化情况(正则化不影响 w0)。
在最左侧,λ 接近于0(2^-10),相当于没有进行正则化,可以看到权重的原始值。随着 λ 越来越大,各个权重均一起向 0 收缩,直至接近于 0。基于以上的理论分析,直觉上可以感到在 λ 的整个大的变化空间中,模型的拟合效果一定也在跟着变化,那么我们可以使用测试集的代价函数变化或者评估得分来观察。
从图中可以看出,在大约 2 附近,也即 λ = 4 时模型在测试集上的代价函数略微下降,然后随着 λ 的增大极剧变差。所以我们可以在 4 附近来进行更细致的交叉验证以或得较理想的正则化参数。
实际应用中,特征值非常多,模型参数也会变得异常庞大,此时拟合的效果会随着 λ 的变化而有很大的起伏,也即可以找到比较理想的正则化参数使得模型刚刚好能够拟合新的预测数据。
LASSO和弹性网络¶
最小绝对收缩及算子选择(Least Absolute Shrinkage and Selection Operator,LASSO)是另一种正则化方法,只不是是把惩罚项从方差和变成了绝对值求和,该惩罚项被称为 L1。
对于基于稀疏数据训练的模型,通常选择 LASSO,基于正则化项的强度,某些权重可以变为零(不像岭回归,所有权重的变化趋势基本一致,同时趋向于0点),这使得 LASSO 成为一种监督特征选择的技术:某些特征与预测目标值基本无关,它的权重就被收缩成了 0。
岭回归中 w 每次调整中会多减去一个 \(\frac {\lambda}{n}w\) 的项,所以 w 是按照比例收缩的(注意其中的梯度也会随着坡度变缓慢慢降低,可以认为是关于 w 的比例项),不同权重会按照自身的比例一起趋向于 0 。而在 LASSO 中,即 w 是正数时为 +1,w 为负数时为 −1。所以是按照常数 \(\frac {\lambda}{n}\) 进行递减收缩(从 0 点左右向 0 点以单位长度靠近 0 点),小的权重将率先收缩到 0 。
L1 规范化的权重收缩得要比 L2 规范化快得多。最终的结果是:L1 规范化倾向于聚集权重在相对少量的重要权重上,其他权重就会被驱使向 0 接近。在 w = 0 的时候,绝对值的偏导数不存在,但是由于当权重收缩到 0 时,已经无法收缩了,所以直接规定这里的偏导数为 0 在理论上不会带来问题。
上图同样适用 BMI 数据来验证 LASSO 权重参数和正则化参数的关系,显然 w1 对应的身高特征对肥胖率的影响要低于 w2 体重的影响,所以它率先被压缩为 0,也即实现了特征的筛选。
0 1 | from sklearn import linear_model
la = linear_model.Lasso(fit_intercept=False, alpha=1)
|
Lasso 算法的调用与 Ridge 类似,均位于 sklearn 的 linear_model 模块中。LASSO 的缺点在于,即如果 k > n,也即权重数目(不含常数项 w0)大于样本数,则至多能完成 k 个特征(权重)的筛选。
弹性网络(Elastic Net)是岭回归和 LASSO 算法的折中,其中包含一个用于稀疏化的 L1 惩罚项和一个消除 LASSO 限制(如可筛选特征数量)的 L2 惩罚项。
λ1 通过 alpha 参数来调节,λ2 通过 l1_ratio 参数来调节:
0 1 | from sklearn import linear_model
en = linear_model.ElasticNet(alpha=1, l1_ratio=0.5)
|
逻辑回归¶
之所以要亦步亦趋地实现逻辑回归代码,是为了深度理解逻辑回归为何会导致欠拟合,以及在神经网络中为何有梯度消失问题。并且在 Adaline 感知器的基础上,实现逻辑回归的改动很小。
逻辑回归也被称为逻辑斯谛回归(logistic regression,缩写为 LR),或者对数几率回归。大部分教科书都会强调它是一个分类模型,而不是回归模型(用于预测连续值的模型)。
逻辑回归模型原理¶
一个事件发生的可能性被称为概率,记作 p,则它不发生的可能性就是 1-p,那么 p/(1-p) 被称为几率比(odd ratio),它指的是特定事件发生的几率,此时 p 也被称为正事件发生概率(所谓正事件表示当前 p 描述的事件,没有好坏之意)。几率看起来比概率理解起来要困难,实际上它只不过是使用不发生的概率来放大发生的概率:
- 如果发生的概率为 0.9,显然发生几率就是 0.9/0.1 = 9,当然发生概率越大,几率就越大,并趋于无穷,所以当一个事件是必然事件时,那么几率就是无穷大。
- 如果发生的概率为 0.1,显然发生几率就是 0.1/0.9 = 0.11,显然此时的放大倍数就很小,随着发生概率减小,几率越来越小,直至当发生概率为 0 时,几率就是 0。
那么既然有了概率,为何还要进入几率?几率本身的用途并不大,用途大的是它的对数函数(log-odds,对数几率)以及反函数 sigmoid函数(逻辑回归函数):
logit函数的输入值是事件发生的概率,所以范围总是介于区间[0,1],它能将[0,1]输入转换到整个实数范围内。显然它的反函数可以把实数空间压缩到 [0,1] 区间:
再观察 Adaline 模型中的线性表达式 \(z=w^{T}x\),对于不同的权重参数 w, z 的取值是整个实数空间,通过sigmoid 函数可以把它压缩到[0,1] 区间。这将 z 的取值和预测事件的准确性发生了关联:
- 如果样本标签为 1,而 z 的值很大,那么 sigmoid(z) 就会趋近于 1(几乎确信预测为1是正确的).
- 如果样本标签值为 -1,而 z 的值很小,那么 sigmoid(z) 就会趋近与0(几乎确认预测为-1是正确的)。
sigmoid 函数曲线如下所示:
所以这里的激活函数就变成了 sigmoid 函数,所以量化器不再使用 >=0 预测为正样本,< 0 时预测为负样本,而是 >=0.5 时为正样本,< 0.5 时为负样本。在正负样本上预测正确的概率 p 和输入 z 的关系图:
横坐标 z 就是对数几率,其中:
- z >= 0 时值就是预测为正样本的对数几率。
- z < 0 时它的绝对值就是预测为负样本的对数几率。
图中的左右侧的 p 分别表示当对数几率等于 2 时,预测 x 为负样本(正样本)的概率(似然概率)。
在 Adaline 中我们使用误差平方和 SSE 作为代价函数,然后使用梯度下降法寻找最优参数值。那么在逻辑回归中,是否可以继续采用 SSE 作为代价函数呢?
这个代价函数有着不太好的特性,对它求导是一件很麻烦的情,实际上它根本不是一个凸函数(无法使用梯度下降法找寻最优值)。下图是在 w0 为0时,代价函数关于 w1 和 w2 的曲面图,从中可以看出等高线弯弯曲曲,大多数不闭合(非凸函数),存在很多个褶皱,很容易陷入局部最小值;某些阶梯看起来就像平原地形,梯度下降在这种平原情况下将因无法下降而终止。这说明上式不适合作为代价函数使用。
在 Adaline 模型中,我们的目标是找寻一组权重参数,使得正样本与它的点乘接近1,负样本接近 -1,直觉上,如果正样本的点乘值能够远远大于 1, 负样本的值能够远远小于 -1,那么对于量化函数似乎能够获得更好的效果。
在统计学上有个名词叫做似然(Likelihood),实际上它与概率的意思接近,只是专门用来称呼在给定的权重参数 w 时,对样本 x 预测为标签 y 的概率,显然它是一个条件概率,记作 \(P(y|x;w)\),其中的分号表示在给定权重参数 w 的限制条件下。
显然如果对于任一样本,如果都能使得这个概率很高(接近1),那么最终预测的结果的正确性整体上就会很高。这种算法就被称为极大似然估计(Maximum Likelihood Estimation,MLE,也被称为最大似然估计)。对于二分类问题,这里的正负样本标签要表示为 1 和 0,而不是 1 和 -1,我们很快就能看到为何要如此表示。
每一样本的似然概率可以表示为:
由于这里的正负标签被表示为 1 和 0,上式可以表示为一个表达式:
以上公式我们特地显示出中间的乘法小圆点,至此可以领略到正负样本标签表示为 1 和 0 的用意了。显然上式只是一个样本的似然概率,所有样本的似然概率就是每个样本似然概率相乘,我们称之为似然函数L:
由于连乘操作容易造成下溢出,通常对似然函数 L(w) 取自然底数 e 的对数,得到对数似然函数(Log-Likelihood,也被称为二元交叉熵 Binary Cross-Entropy)l(w):
使用对数似然函数不仅解决了下溢出问题,还将复杂的乘法转换为了加法,可以更容易地对该函数求导(实际上该函数是严格凸函数)。该函数值最大化,就是最大似然估计,通常我们求代价函数的最小值,所以只要在在函数前加上负号就得到了最终的代价函数。
相较于 Adaline 模型的 SSE 代价函数曲面,它看起来更像一个斗笠,越靠近极小值,下降速度越快,而处在边缘处,下降速度就比较慢了,所以权重值初始化为 0 周围的值是非常有意义的(数据标准化的前提下)。
为了更好地理解这一代价函数本质,可以从单个样本的预测代价入手:
通过观察不难发现上式可以分解为两部分:
不要忘记其中 \(\phi (z^i)=sigmoid(z^i)\),而对数回归函数有个很好的特性:
同样以 e 为底的对数函数也具有类似的导数特性,也即导数是函数的倒数:
有了以上的理论基础,梯度下降中的 \(\Delta w\) 就很容易通过链式求导法则求出了:
其中:
可以得出:
这一计算量要远小于 Adaline 模型的 SSE 代价函数。下面是 Adaline 模型和逻辑回归模型的对比图:
逻辑回归实战¶
与 Adaline 模型的 AdalineGD 类极为相似,一些注意点在于:
- 激活函数使用 sigmoid,而不是直接采用 z
- 激活阈值不同,Adaline 模型激活阈值为 >=0,而 LogRegression 模型为 >=0.5
- LogRegression 模型可以提供每一预测类别的似然概率,所以增加一个名为 predict_proba() 的类方法
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | #logregress.py
class LogRegressGD(object):
def __init__(self, eta=0.001, n_iter=1000):
self.eta = eta
self.n_iter = n_iter
self.complex = 0 # Statistic algorithm complexity
def errors(self, X, y):
'''Statistic all errors into self.errors_'''
predicts = self.appendedX_.dot(self.w_)
diffs = np.where(predicts >= 0.0, self.positive, self.negtive) - y
errors = np.count_nonzero(diffs)
self.errors_.append(errors)
return errors, diffs
def update_labels(self, y):
# for ploting and predict
self.positive = np.max(y)
self.negtive = np.min(y)
self.positive_num = np.count_nonzero(y == self.positive)
self.negtive_num = np.count_nonzero(y == self.negtive)
def fit(self, X, y):
samples = X.shape[0]
x_features = X.shape[1]
self.w_ = 1.0 * np.zeros(1 + x_features)
self.update_labels(y)
self.appendedX_ = np.hstack((np.ones(samples).reshape(samples, 1), X))
self.errors_ = []
self.costs_ = []
# record every w during whole iterations
self.wsteps_ = []
self.steps_ = 1 # every steps_ descent steps statistic one cose and error sample
while(1):
self.complex += 1
# minmium cost function with partial derivative wi
output = self.sigmoid(self.net_input(X))
deltaw = (y - output) # J(W) 对 wi 的偏导数,并取负号
deltaw = self.eta * deltaw.dot(self.appendedX_)
if np.max(np.abs(deltaw)) < 0.00001:
print("deltaw is less than 0.00001")
return self
self.w_ += deltaw
if(self.complex > self.n_iter):
print("Loops beyond n_iter %d" % self.n_iter)
return self
if (self.complex % self.steps_ == 0):
errors, diffs = self.errors(X, y)
self.wsteps_.append(self.w_.copy())
# 计算代价函数,注意这里需防止下溢出而导致的除 0 错误
diff = 1.0 - output
diff[diff <= 0] = 1e-9
cost = -y.dot(np.log(output)) - ((1 - y).dot(np.log(diff)))
self.costs_.append(cost)
return self
# X is a vector including features of a sample
def net_input(self, X):
'''Calculate net input'''
return np.dot(X, self.w_[1:]) + self.w_[0] * 1
# 激活函数
def sigmoid(self, z):
"""Compute logistic sigmoid activation"""
return 1.0 / (1.0 + np.exp(-np.clip(z, -250, 250)))
def predict(self, X):
"""Return class label after unit step"""
return np.where(self.sigmoid(self.net_input(X)) >= 0.5, 1, 0)
# 预测每种分类的概率,也即 sigmoid 函数的输出
def predict_proba(self, x):
p = self.sigmoid(self.net_input(x))
return np.array([p, 1-p])
|
我们使用鸢尾花数据集来进行逻辑回归分类的测试:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | def irisLogRegressGD():
import dbload
# 加载鸢尾花数据集,负类标签设置为 0
X_train, X_test, y_train, y_test = dbload.load_iris_dataset(negtive=0)
irisPerceptron = LogRegressGD(0.01, 100)
irisPerceptron.fit(X_train, y_train)
predict = irisPerceptron.predict(X_test)
errnum = (predict != y_test).sum()
print("Misclassified number {}, Accuracy {:.2f}%".format(errnum, \
(X_test.shape[0] - errnum)/ X_test.shape[0] * 100))
print("LastCost: %f" % irisPerceptron.costs_[-1])
print('Weights: %s' % irisPerceptron.w_)
irisLogRegressGD()
>>>
LastCost: 1.336864
Weights: [ 0.67409685 1.32281698 -3.79640448]
|
由于在逻辑回归中使用了批量梯度下降,整个代价函数的下降曲线非常平滑。
这里的逻辑回归模型只能进行二分类,而 sklearn 中的线性分类模型均支持多分类。我们直接使用 sklearn 中的逻辑回归模型,来查看多分类的效果。
sklearn 逻辑回归模型¶
sklearn 中的逻辑回归模型位于线性分类模块中,这里使用它对鸢尾花数据进行多分类,并画图。
有一些常被用来令代价函数最小的算法,这些算法更加复杂和优越,且通常不需要人工选择学习率,通常比梯度下降算法要更加快速。这些算法有:共轭梯度(Conjugate Gradient),局部优化法(Broyden fletcher goldfarb shann,BFGS)和有限内存局部优化法(LBFGS)。 这里的 solver 指定了 ‘lbfgs’,拟牛顿法的一种。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | import drawutils
def test_plot_decision_regions():
import dbload
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
X_train, X_test, y_train, y_test = dbload.load_iris_mclass()
lr = LogisticRegression(solver='lbfgs', random_state=0, multi_class='auto')
lr.fit(X_train, y_train)
predict = lr.predict(X_test)
print("Misclassified number {}, Accuracy {:.2f}%".format((predict != y_test).sum(),
accuracy_score(y_test, predict)*100))
X_all = np.vstack((X_train, X_test))
y_all = np.hstack((y_train, y_test))
drawutils.plot_decision_regions(X_all, y_all, clf=lr,
test_idx=range(X_train.shape[0], X_all.shape[0]))
plt.xlabel('petal length [standardized]')
plt.ylabel('petal width [standardized]')
plt.legend(loc='upper left')
plt.tight_layout()
plt.show()
test_plot_decision_regions()
>>>
Misclassified number 3, Accuracy 93.33%
|
逻辑回归算法特点¶
线性回归做分类因为考虑了所有样本点到分类平面的距离,所以在两类数据分布不均匀的时候将导致误差非常大。
逻辑回归(LR)和支持向量机(SVM)克服了这个缺点,逻辑回归将所有数据采用sigmod函数进行了非线性映射,使得远离分类平面的数据作用减弱;SVM直接去掉了远离分类决策面的数据,只考虑支持向量的影响。
但是对于这两种算法来说,在线性分类情况下,如果异常点较多无法剔除的话,逻辑回归中sigmoid函数自动压制异常的贡献。显然这是由于远离直线的点(特别是异常点)对权重调整的影响是有限度的(sigmoid 将输出限制在了 0和1 之间),所以当样本数量不平衡时,效果要远远好于 Adaline 模型。
SVM+软间隔对异常比较敏感,因为其训练只需要支持向量,有效样本本来就不高,一旦被干扰,预测结果难以预料。
逻辑回归中,数据的标准化异常重要,否则求和函数的输出就可能很大,继而导致 sigmoid 函数的输出接近 1 或者 -1,尽管正确的标签应该是 0 和 1,但是从偏导数项的计算公式可以看出,这个差值异常之小(sigmoid 函数曲线在 y 轴两侧以很快的速度接近平行于 x 轴的直线,斜率非常小,也即梯度很小),以至于下降速度非常之慢,我们可以将 w 初始化成比较大的值来模拟这种情况,这里将所有 w 初始化为 1000:
尽然已经将学习率调整到了较大的 0.5,在迭代一万次之后下降速度依然缓慢。这也是逻辑回归容易出现欠拟合的原因之一,特别是逻辑回归采用随机梯度下降算法的时候。
概率与贝叶斯分类¶
从概率开始¶
基本数学概念¶
随机试验的特征:
- 可在相同条件下重复进行,之所以强调相同条件,是因为改变了试验条件,试验就被改变了,试验 A 就变成了试验 B,所以说某项试验就暗含了是在某种条件约束下进行的。
- 在试验结果出来之前,可事先明确所有可能的结果(Outcomes)。例如抛硬币只有正反两面。
- 试验结果出来之前是不能确定结果的。
概率讨论基于随机试验的结果,某种试验被简写为试验 E (Experiment,也可使用 Trial),试验 E 的所有可能结果组成的集合(Set,结果的全集),被称为试验 E 的样本空间,简写为 S(sample space,或希腊字母 \(\Omega\) 表示),显然 S 就是结果集,例如抛硬币试验的样本空间 S:{正,反}。S 集合中的每个元素称为样本空间 S 的样本点(Sample),例如“正”样本点。
试验 E 的样本空间 S 的子集被称为试验 E 的随机事件(Random Event),简称事件(Event),显然事件的本质是集合,而集合是在一定条件下的结果集,例如抛硬币中的 “正” 事件。根据事件包含样本点的数目,可以分为:
- 基本事件,也被称为原子事件,有单个样本点组成的集合称为基本事件,S 有多少个样本点,就有多少个基本事件。例如抛硬币试验 E 的基本事件为 {正} 和 {反}。基本事件两两互不相容,互为互斥事件,如何事件(除不可能事件)都可以表示为多个基本事件的并集。
- 必然事件,S 是自身的子集,本质就是由所有基本事件构成的集合。
- 不可能事件,空集不包含任何基本事件,每次试验都不可能发生。
根据事件之间的关系,两个或多个事件可以分为以下几种关系:
- 独立事件:A, B 事件无相互影响,相互独立,P(A) = P(A|B),同时 P(B) = P(B|A) 也成立。
- 互斥事件:A, B 事件不可能同时发生;1 个发生,另一个就不可能发生。
- 对立事件,也被称为互逆事件:如果 A,B 为对立事件,那么要么 A 发生,要么 B 发生,P(A) + P(B) = 1,A 的对立事件常记作 A’。
事件之间的关系常用韦恩图(venn diagram)表示,它可以方便地描述 2 个事件的关系。
对 S 集合进行完备划分,使得所有事件互斥,且并集为 S,那么就得到一个完备事件组(complete events group),简称为完备组,例如掷骰子(a die,复数为 dice)试验,样本空间为 S:{1,2,3,4,5,6},An 表示划分的事件,它的完备组可以为:
- A1 和 A2,其中 A1: {1,2,3},A2: {4, 5, 6}
- A1,A2…A6,其中 A1:{1},A2{2},A3{3} 以此类推。
显然如果事件 A1、A2、A3…An 构成一个完备组,则它们两两互不相容,其并集为样本空间。完备组被用于定义全概率公式,A 和 A’ 是一组典型的完备组。
考虑掷骰子试验 E:它的样本空间为 S:{1,2,3,4,5,6}。基于该样本空间集合,我们可以考察很多条件下的事件:
- 奇偶条件:取到偶数的事件,取到奇数的事件。
- 大小条件:取到 <= 3 的事件,>=5 的事件。
- 倍数条件:取到是 3 的倍数的事件,非 3 的倍数的事件。
- 取到数字同时为偶数且是 3 的倍数的事件。
显然考察条件不同,事件就不同。概率表征事件 A 发生可能性的大小,那么如何精确定义某个事件的概率呢?随机试验的结果具有随机现象:每次试验结果呈现不确定性,在大量重复试验中结果又具有统计规律性。
- 频数:在相同条件下,进行 n 次试验,事件 A 发生的次数 \(n_A\) 称为事件 A 发生的频数。
- 频率: \(n_A\)/\(n\) 称为事件 A 发生的频率,并记为 \(f_n(A)\) 。
- 概率:当 \(n\to \infty\) 时,\(f_n(A)\) 被定义为事件 A 发生的概率,记为 P(A),P 是 Probability 的缩写(依据为大数定律)。
有些时候,我们更关心试验结果的某些函数,而不是结果本身,例如在掷两枚骰子的试验中,关心点数之和为 7 的情况。
随机变量:定义在样本空间上的实值单值函数,简写为 X,也即 X=X(e),e 表示样本空间 S 中的样本点。随机变量与随机事件一样,具有统计规律性,使用它来描述各种随机现象。
此时使用一种随机变量的定义就可以定义一个事件,例如事件 {X=7} 表示两次掷骰子的和为 7 的事件,且概率表示为 P(X=7)。
韦恩图¶
韦恩图(venn diagram)在集合理论中被广泛使用,由于概率的本质就是事件集合相对于样本空间的占比,所以韦恩图也被用于描述事件之间的关系和运算。
继续考虑掷骰子试验,我们可以对应上图描述以下事件之间的关系:
- 图1:描述事件的包含关系,例如事件 A {X<5},事件 B {X<3},显然 A 是 B 的子集
- 图2:描述互斥事件,不可同时发生,例如事件 A{X>4},B {X<3}。
- 图3:描述相关事件的并集,例如事件 A {X = 偶数},B {X > 3},则阴影部分对应 A {X = 偶数 或 X > 3}。
- 图4:描述相关事件的交集,例如事件 A {X = 偶数},B {X > 3},则阴影部分对应 A {X = 偶数 且 X > 3}。
- 图5 和 图6 描述事件补集的关系。
- 图8 描述了对立事件(互逆事件),例如 A{X < 3},B{X >= 3},它们构成了一个完备组。
通过韦恩图可以非常直观得计算出 2 个事件的交并,互补事件的概率。但是它不适合描述多事件和多随机变量事件,否则图像将变得混乱,同时它也不适合描述条件概率。
打地鼠与概率规则¶
考虑掷骰子试验,结果为偶数的概率是多少?结果为偶数时,它是 3 的倍数的概率有多少?如果结果为 3 的倍数,它是偶数的概率有多少?这就涉及到了先验概率,条件概率和联合概率的关系和计算问题。
- 先验概率(prior probability):可以通过分析得到,不依赖于其他事件,例如P(X=偶数) = 3/6 = 1/2。
- 条件概率(conditional probability,也称为后验概率,posterior probability),记作 P(A|B),在事件 B 发生后,A 发生的概率,例如 P(Y=3的倍数|X=偶数)。
- 联合概率(joint probability),记作 P(A,B) 或 P(A \(\cap\) B),A 和 B事件同时发生的概率,例如 P(Y=3的倍数,X=偶数)。
这三种概率之间是什么关系呢?为了推导概率的一般规则,考虑下面的打地鼠游戏:
地鼠从某个洞中探出头来,游戏者非常快速地把它敲回去,这非常有趣。如果我们想要统计一些规律:从某行出来,某列出来,某个洞中出来。为了分析地鼠探头的规律性,可以抽象成以下模型:
地鼠出现的位置可以使用两个随机变量来描述:X 和 Y,X 表示横坐标,Y 表示纵坐标,假设 X 的取值为 \(x_i\), 其中 i = 1,2,3…I;Y 可以取值为 \(y_j\),其中 y = 1,2,3…J,考虑 N 次试验,同时对随机变量 X 和 Y 进行统计:
- 把 {X = \(x_i\) 且 Y = \(y_j\)} 的出现次数记作 \(n_{ij}\) ;
- 把 X 取值为 \(x_i\) (与 Y 无关,只关心列,column)的出现次数记为 \(c_i\) ;
- 把 Y 取值为 \(y_j\) (与 X 无关,只关心行,row)的出现次数记为 \(r_j\) 。
根据概率定义,当 N 趋向于无穷时,可以得出联合概率计算公式:
同理,先验概率 X = \(x_i\) 的计算公式为出现在 i 列上的次数 \(c_i\) 与总试验的次数比值:
另外注意到 \(c_i = \sum_j{n_{ij}}\),可以得出概率的加和规则(sum rule):
已知落在列 i 上的总点数 \(c_i\) ,那么落在 ij 上的点数 \(n_{ij}\) 与它的比值就是条件概率:
根据公式 (1)(2)(4),可以得出联合概率和条件概率的关系,它被称为概率的乘法规则(product rule):
同样根据乘法规则,可以得到 \(P(Y=y_j,X=x_i)\) 的概率公式:
通过公式 (5)(6) 可以看出联合概率在两个条件概率之间架起了一座桥梁,得到条件概率的算术关系:
上式被称为贝叶斯定理(Bayes’ theorem),它是机器学习中朴素贝叶斯和贝叶斯分类算法的理论基础。上式中的分母可以用全概率公式表示:
其中的所有的 \(y_j\) 事件构成了一个完备组。
条件概率与概率树¶
上面使用网格图表示两个随机变量,可以清晰地阐述三种概率的关系。但是当随机变量超过 2 个时,使用网格图就无法表达了。概率树可以清晰地表示多种随机变量关系。
概率树以分层的方式依次描述不同的随机变量。
- 第一层随机变量描述随机变量 X,它有 i 个分支,分别对应 \(X=x_i\) 事件,这里简写为事件 A 和 A’,先验概率在相应的分支上标出,对应的节点标出事件 A 和 A’,所有分支上的事件构成一个完备组,它们的概率和为 1。
- 第二层分支线上标出已知所连接的上一级结果的情况下的第二层结果的概率。所以它是条件概率。
- 根据乘法规则,从根节点沿着分支依次向右连乘,得到联合概率。所有联合概率的和一定为 1,这可以用来校验计算是否正确。
概率树使用规则:
- 努力分出需要计算的概率的不同层级。如果给定了条件概率 P(A|B),则第一层应该考虑 B 的各分支,第二层再考虑 A。
- 将已知概率填入概率树相应位置。
- 每一层各个分支构成一个完备组,概率总和为 1,我们可以根据 P(A) 计算出它的对立事件 P(A’) = 1 - P(A)。
- 根据乘法规则,由已知概率求解联合概率,或者条件概率。注意所有联合概率的和一定为 1,这可以用来校验计算是否正确。
有了概率树,我们在应用贝叶斯定理时将非常直观和清晰,这里以一个示例说明。
某种疾病在人群中的感染概率为 1%,某种试剂对感染情况进行阳性测试,如果该人已感染,则阳性概率为 95%(另外 5% 被称为假阴性),如果未感染,则阳性概率为 10%(称为假阳性)。如果某人试验为阳性,那么他感染该疾病的概率为多少?
表面上看,由于测试为阳性,感染的几率可能很高,实际可能并非如此。根据概率树使用规则,首先分析问题中的随机变量包含两种:
- 是否感染(Infect),我们使用 P(Y) 和 P(N) 表示它们的概率
- 是否检出阳性(Positive,阴性为 Negative),我们使用 P(+) 和 P(-) 表示它们的概率。
由于我们已知人群的感染率,所以感染情况的概率放在第一层,得到:
0 1 2 3 4 5 6 7 | Y
/
/ P(Y) = 1/100
/
\
\ P(N) = 1 - P(Y) = 99/100
\
N
|
接着分别在已经感染人群和非感染人群中考虑阳性检出情况:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | + P(+,Y) = P(+|Y)P(Y) = 1%*95%
/
/ P(+|Y) = 95%
Y
/ \ P(-|Y) = 1 - P(+|Y) = 5%
/ \
/ -
/
/ P(Y) = 1%
/
\
\ P(N) = 1 - P(Y) = 99%
\
\ + P(+,N) = P(+|Y)P(Y) = 10%*99%
\ /
\ / P(+|N) = 10%
N
\ P(-|N) = 1 - P(+|N) = 90%
\
-
|
如果某人试验为阳性,那么他感染该疾病的概率为多少?首先求出所有试验为阳性的人数占比:P(+,Y) + P(+,N) = P(+|Y)P(Y) + P(+|N)P(N) = 1%*95% + 10%*99%,其中真阳性的人数占比为 P(+,Y),所以求出在检出阳性后患病概率为:
结果为 95/(95+990) = 8.76%,所以即便检出为阳性,由于感染人群的概率很小,此人患病的可能性依然很低。不过要注意到在检出阳性后,此人的患病率比其他人的患病概率还是高了 8 倍多,基础概率已经改变了。如果有更先进(成本也更高)的检测方法对其进行阳性测试,那么基础概率就要使用 8.76%,而不再是 1%了。
观察上面的计算公式,可以看出就是对贝叶斯定理的应用:
以上两式代入上式中的分子和分母就是贝叶斯公式:
当考虑的随机变量很多,无法理清相关概率时,通过概率树由已知条件概率计算相反的条件概率,要比直接套用贝叶斯公式更清晰直观,且非常简便。
朴素贝叶斯分类¶
朴素贝叶斯¶
贝叶斯定理常用于解决语自然语言(NLP,Nature Language Processing))中的文档分类问题,例如垃圾邮件过滤,新闻分类,文本情感分析(sentiment analysis,也称为观点挖掘:opinion mining)等。
观察上面的公式,如何使用它与文本分类相结合?文本分类问题中有两个随机变量:分类和文档,根据得到的文本分析它的分类倾向。结合上式,可以转化为以下形式:
式中 \(c_i\) 表示 i 分类的概率,例如样本中垃圾邮件/非垃圾邮件的占比,w 则表示文本,例如一个句子,一段文字甚至一篇文章。那么如何使用上式进行分类呢?贝叶斯分类准则为在 w 条件下,\(P(c_i|w)\) 条件概率最高,则被分类为 \(c_i\)。
w 是一串单词或词组(w={w0,w1…}),我们必须把它向量化:每个词的出现或不出现作为一个特征。如果我们考虑单词顺序,把一个句子甚至一篇文章整体作为特征,那么由于单词之间有意义的组合结果太多,导致我们的样本稀少,所以朴素贝叶斯基于贝叶斯定理,而假设特征之间相互独立(Independence),也即不考虑单词组合顺序,而认为每个单词的出现相互独立,这一假设实际上并不成立,但是实际测试效果却很好。朴素(Naive)的意思也由此而来。此时的类条件概率公式如下:
朴素贝叶斯分类器有两种实现方式:
基于伯努利模型(Bernoulli model)实现,也即假设每个特征(单词)同等重要,不考虑单词在文档中出现的次数,只考虑是否出现,特征向量中只有 0 和 1。
- 先验概率 \(P(c_i) = \frac {i 类下文档总数}{整个训练样本的文档数}\)
- 类条件概率 \(P(w_k|c_i)= \frac {(i 类下包含单词 w_k 的文档数 + 1)}{( i 类的文档总数 + 2)}\)
之所以要分子加 1,分母加 2 是由于在进行类条件概率计算时需要多个概率相乘,如果其中一个概率为 0,那么乘积也就为 0,为了避免这种情况,将所有词的出现次数在每一分类中初始化为 1,显然分母要加上分类的数目,这里只有 2 类,所以为 2。
基于多项式模型(multinomial model)实现,考虑特征的出现次数,向量中记录的是单词的出现次数。设文档 d = (w0,w1,…,wk),tk是该文档中出现过的单词,允许重复,则
- 先验概率 \(P(c_i) = \frac {i 类下单词总数}{整个训练样本的单词总数}\)
- 类条件概率 \(P(w_k|c_i)= \frac {(i 类下单词 w_k 在各个文档中出现过的次数之和 + 1)}{(i 类下单词总数 + |V|)}\)
其中 V 是训练样本的词汇表(vocabulary,即抽取单词,单词出现多次,只算一个),可以把它看做 V 维的向量,所以用 |V| 表示训练样本包含多少个单词(V 的模)。与伯努利模型类似为了防止概率计算为 0,将词汇表中的每个单词在每一分类中出现的次数初始化为 1,分母则要增加词汇表的长度。
\(P(w_k|c_i)\) 可以看作是单词 \(w_k\) 在证明 d 属于 i 类上提供的证据强度,而 \(P(c_i) \) 可以认为是类别 i 在整体上的占比(有多大可能性)。
对比两个模型:
- 两者计算粒度不一样,多项式模型以单词为粒度,伯努利模型以文件为粒度,因此两者的先验概率和类条件概率的计算方法不同。
- 计算后验概率时,对于一个文档 d ,多项式模型中,只有在 d 中出现过的单词,才会参与后验概率计算,伯努利模型中,没有在 d 中出现,但是在全局单词表中出现的单词,也会参与计算。
构建特征向量¶
我们收集到的文本数据可能存储各种文件中,例如 txt 文本中,例如一封邮件的内容可能是这样的:
0 1 2 3 4 5 6 7 | Hi Peter,
With Jose out of town, do you want to
meet once in a while to keep things
going and do some interesting stuff?
Let me know
Eugene
|
我们必须对它进行向量化,然后进行各类概率的计算,文档特征向量化步骤如下:
- 从所有训练集中提取所有单词,也即分词操作,对于英文来说比较简单,但是汉语就需要专门的分词工具。
- 经过分词后,句子变成了单词向量,此时进行数据清洗:去除不需要的字符。
- 词干提取(word stemming),有些语言会随着语境单词出现变体,例如 have, has,这是一个提取单词原形的过程,也被称为词形还原。
- 停用词(stop-word)移除:停用词是指在各种文本中很常见,但是包含很少的区分文本所属类别的有用信息,英语中常见的停用词有is、and、has等。不同的领域可能需要使用不同的停用词库,nltk 软件包提供了这些常用词库。
- 生成特征向量,也即词汇表,为方便调试,可以对它进行字母排序。
scikit-learn 提供了以上处理步骤,这里为了深入理解处理步骤,基于伯努利模型进行最基本处理的代码实现。数据源于《机器学习实战》,社区留言板数据包含两种侮辱类留言和非侮辱类留言,使用朴素贝叶斯进行分类。
0 1 2 3 4 5 6 | messages =[['my', 'dog', 'has', 'flea', 'problems', 'help', 'please'],
['maybe', 'not', 'take', 'him', 'to', 'dog', 'park', 'stupid'],
['my', 'dalmation', 'is', 'so', 'cute', 'I', 'love', 'him'],
['stop', 'posting', 'stupid', 'worthless', 'garbage'],
['mr', 'licks', 'ate', 'my', 'steak', 'how', 'to', 'stop', 'him'],
['quit', 'buying', 'worthless', 'dog', 'food', 'stupid']]
class_vec = [0,1,0,1,0,1] # 类别标签,1 表示侮辱性留言
|
这里的留言条目已经进行了分词处理,我们直接使用它生成词汇表和特征向量。集合对象可以去除重复元素,借助它我们生成每个单词只出现一次的词汇表:
0 1 2 3 4 5 6 7 8 9 10 11 | def vocab_list_create(msgs):
vocab_set = set()
for i in msgs:
vocab_set |= set(i)
return sorted(list(vocab_set))
vocab_list = vocab_list_create(messages)
print(vocab_list)
>>>
['I', 'ate', 'buying', 'cute', 'dalmation', 'dog', 'flea', ...]
|
使用词汇表,将一条留言转换为特征向量,可以看到第一个 1 对应词汇表中的 ‘dog’,它出现在了第一条留言中。
0 1 2 3 4 5 6 7 8 9 10 11 12 | import numpy as np
def message2vec(vocab_list, msg):
vec = np.zeros(len(vocab_list))
for word in msg:
if word in vocab_list:
vec[vocab_list.index(word)] = 1
return vec
print(message2vec(vocab_list, messages[0]))
>>>
[ 0. 0. 0. 0. 0. 1. 1. 0. 0. 1. 1. 0. 0. 0. 0. 0. 0. 0.
1. 0. 0. 1. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]
|
由于大部分单词表中的单词不会再文档中出现,所以特征向量的大部分元素值为 0,所以特征向量是稀疏(sparse)的。
为了提高处理效率,我们直接将所有留言一次性转换为一个 2D ndarray 类型,这样可以保证所有向量处在同一块连续内存中。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # every row is a message vec
def messages2vecs(vocab_list, msgs):
msgs_len = len(msgs)
shape = (msgs_len,len(vocab_list))
matrix = np.zeros(shape)
for i in range(msgs_len):
for word in msgs[i]:
if word in vocab_list:
matrix[i,vocab_list.index(word)] = 1
return matrix
msg_vecs = messages2vecs(vocab_list, messages)
print(msg_vecs)
>>>
[[ 0. 0. 0. 0. 0. 1. 1. 0. 0. 1. 1. 0. 0. 0. 0. 0. 0. 0.
1. 0. 0. 1. 0. 1. 0. 0. 0. 0. 0. 0. 0. 0.]
...
|
生成的 2D 数组每一行对应一条留言的特征向量,行数等于留言数,列数等于词汇表的长度。可以看到第一条特征向量与 message2vec 生成结果是一样的。
注意
尽管数组中的元素大小只有 0 和 1,我们并没有定义为 uint8 或者其他整型,因为在进行概率计算时需要进行除法运算,这里使用默认的 float64 以避免下溢出。
计算类条件概率¶
有了每一条信息的特征向量,我们就可以计算类条件概率了,基于特征向量属性的独立性假设,类条件概率公式为:
我们只要计算出每个单词在分类 i 下的概率即可,根据伯努利模型的类条件概率公式计算每个单词在各个分类上的概率:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | def word_probability_vecs(msg_vecs, class_list):
index_vec = np.array(class_list)
prob_vecs = []
for cls in set(class_list):
cls_index = index_vec == cls
cls_vecs = msg_vecs[cls_index,:]
prob_vec = (np.sum(cls_vecs, axis=0) + 1) / (np.sum(cls_index) + 2)
prob_vecs.append(prob_vec)
return prob_vecs
word_vecs = word_probability_vecs(msg_vecs, class_list)
print(word_vecs[0]) # 在非侮辱性分类中,每个单词的出现概率
print(word_vecs[1]) # 在侮辱性分类中,每个单词的出现概率
>>>
[ 0.4 0.4 0.2 0.4 0.4 0.4 0.4 0.2 0.2 0.4 0.4 0.6 0.4 0.4 0.4
0.4 0.2 0.4 0.8 0.2 0.2 0.4 0.2 0.4 0.2 0.4 0.4 0.4 0.2 0.2
0.4 0.2]
[ 0.2 0.2 0.4 0.2 0.2 0.6 0.2 0.4 0.4 0.2 0.2 0.4 0.2 0.2 0.2
0.2 0.4 0.2 0.2 0.4 0.4 0.2 0.4 0.2 0.4 0.2 0.2 0.4 0.8 0.4
0.4 0.6]
|
word_probability_vecs 返回所有分类下的条件概率,是一个列表,这样在多分类情况下依然通用。这里取第一个单词 ‘I’ 来验证,它在正分类中出现了 1 次,所以结果为 (1+1)/(3+2) = 2/5 = 0.4。
注意这里的实现和《机器学习实战》中的实现是不一样的,原书实现中分母取了所有当前分类的单词数,这是不正确的,参考Issues 。
同样,遍历分类列表,计算每种分类的概率,结果按分类从小到大排列,也即 cls_prob_vecs[0] 对应正分类。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def class_probability(class_list):
cls_vec = np.array(class_list)
total_msgs = len(class_list)
cls_prob_vecs = []
for cls in set(class_list):
cls_prob = len(cls_vec[cls_vec==cls]) / total_msgs
cls_prob_vecs.append(cls_prob)
return cls_prob_vecs
cls_prob_vecs = class_probability(class_list)
print(cls_prob_vecs)
>>>
[0.5, 0.5] # messages 中分类条目分别为 3 + 3,所以为 0.5 和 0.5
|
有了每个单词的条件概率,我们就可以使用连乘求得每条留言的类条件概率,但是由于每个数都是很小的分数,连乘将导致下溢出,通常取自然对数来解决,这样乘法就被转变为了加法。
由于 ln(x) 函数在整个定义域上单调递增,所以在 (0,1] 区间上也是单调递增,概率大的值对应 ln(x) 值也更大。
0 1 2 3 4 5 6 7 8 9 10 | # msg_vector is a vector
def naive_bayes_classifier(msg_vec, prob_vecs, cls_prob_vecs):
ps = []
for prob, cls_prob in zip(prob_vecs, cls_prob_vecs):
p = np.sum(np.log(prob) * msg_vec) + np.log(cls_prob)
ps.append(p)
print(p)
# 返回概率最大的分类
return ps.index(max(ps))
|
以上函数对一个特征向量进行分类,其中对概率采用了对数处理,返回概率最大的分类。
实现朴素贝叶斯类¶
基于以上函数封装 NB 类,并支持同时预测多条信息,同时评估准确率:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class NB():
def __init__():
pass
def word_probability_vecs(self, msg_vecs, class_list):
index_vec = np.array(class_list)
prob_vecs = []
for cls in set(class_list):
cls_index = index_vec == cls
cls_vecs = msg_vecs[cls_index,:]
prob_vec = (np.sum(cls_vecs, axis=0) + 1) / (np.sum(cls_index) + 2)
prob_vecs.append(np.log(prob_vec)) # 概率取对数
return prob_vecs
def class_probability(self, class_list):
cls_vec = np.array(class_list)
total_msgs = len(class_list)
cls_prob_vecs = []
for cls in set(class_list):
cls_prob = len(cls_vec[cls_vec==cls]) / total_msgs
cls_prob_vecs.append(np.log(cls_prob)) # 概率取对数
return cls_prob_vecs
|
基于以上更新后的概率计算函数,实现 fix 和 predict 类方法:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | def fix(self, train_msgs, train_class):
# 生成分类集合
self.class_set = set(train_class)
self.class_num = len(self.class_set)
self.class_array = np.array(list(self.class_set))
# 生成单词表
self.vocab_list = vocab_list_create(train_msgs)
# 训练集留言转换为特征向量
self.msg_vecs = messages2vecs(self.vocab_list, train_msgs)
# 计算各分类上单词的条件概率 P(wk|ci)
self.prob_vecs = self.word_probability_vecs(self.msg_vecs, train_class)
# 计算各分类的先验概率 P(ci)
self.cls_prob_vecs = self.class_probability(train_class)
def predict(self, msgs):
msgs_len = len(msgs)
# 将信息列表转换为 2D array,每行对一特征向量
predict_vecs = messages2vecs(self.vocab_list, msgs)
# 生成 msgs_len * class_num 的数组,每一行对应在不同分类上的预测概率
predict_array = np.zeros((msgs_len, self.class_num))
for i in range(self.class_num):
prob_vec = self.prob_vecs[i][:,np.newaxis] # transfrom to n*1
predic_prob = predict_vecs.dot(prob_vec) + self.cls_prob_vecs[i] # msgs_len*1
predict_array[:, i] = predic_prob[:,0]
# 计算每一行上的概率最大索引
index = np.argmax(predict_array, axis=1)
# 通过索引获取分类信息
return self.class_array[index]
# 根据预测标签,打印预测准确率
def predict_accurate(self, predicted_cls, label_cls):
label_vec = np.array(label_cls)
correct_num = np.sum(label_vec == predicted_cls)
ratio = correct_num / len(predicted_cls)
print("Predict accurate percent {}%".format(ratio * 100))
return ratio
|
fix 方法根据训练数据来获取模型参数:条件概率和类概率,predict 对新留言列表进行预测,一次可以预测多条。这里使用训练集进行测试:
0 1 2 3 4 5 | nb = NB()
nb.fix(messages, class_list)
cls = nb.predict(messages)
print(cls)
[0 1 0 1 0 1]
|
当然我们可以手动指定一些句子,并打印预测准确率:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | nb = NB()
nb.fix(messages, class_list) # 训练模型
test_messages = [['you', 'are', 'stupid'],
['I', 'am', 'very', 'well']]
test_labels = [1, 0]
# 对新数据进行预测
predicted_cls = nb.predict(test_messages)
print(predicted_cls)
nb.predict_accurate(predicted_cls, test_labels)
>>>
[1 0]
Predict accurate percent 100.0%
|
显然由于我们的训练样本很少,导致词汇表很小,很多负面或者正面词汇都没有包含进来,不过作为示例已经足够了。
词袋模式和词集模式¶
如果把每一词是否在单词表(词汇表)中出现作为一个特征,就称为词集模式(SOW,Set of words),显然这里把每条信息作为一个集合看待,所有重复单词都作为集合的一个元素看待。但是实际上如果一个词在文档中不止一次出现,那么这可能意味着我们丢失了一些信息。
如果考虑到单词在文档中出现的次数,这种方法被称为词袋模式(BOW,Bag of words),显然这把一条消息看成了一个装满单词的袋子,袋子可以装入重复的单词。
无论是 SOW 还是 BOW,都未考虑词法和语序的问题,即每个词语都是独立的,语序关系包含的信息已经完全丢失。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def bag_message2vec(vocab_list, msg):
vec = np.zeros(len(vocab_list))
for word in msg:
if word in vocab_list:
vec[vocab_list.index(word)] += 1
return vec
def bag_messages2vecs(vocab_list, msgs):
msgs_len = len(msgs)
shape = (msgs_len,len(vocab_list))
matrix = np.zeros(shape)
for i in range(msgs_len):
for word in msgs[i]:
if word in vocab_list:
matrix[i,vocab_list.index(word)] += 1
return matrix
|
词袋模式每次出现均累加,得到所有词的词频。我们使用如下代码测试词频向量:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | sentences = ['I want to go to BeiJing', 'Watch the dog watch the dog']
def sentence2lists(sentences):
msg_list = []
for i in sentences:
msg_list.append(i.lower().split()) # 不区分大小写
return msg_list
msg_list = sentence2lists(sentences)
vocab_list = vocab_list_create(msg_list)
msg_vecs = bag_messages2vecs(vocab_list, msg_list)
print(vocab_list)
print(msg_vecs)
>>>
['beijing', 'dog', 'go', 'i', 'the', 'to', 'want', 'watch']
[[ 1. 0. 1. 1. 0. 2. 1. 0.]
[ 0. 2. 0. 0. 2. 0. 0. 2.]]
|
另外我们要基于多项式模型(multinomial model)来计算概率,这里重新定义 BagNB 类,并更新相关函数,其中预测函数继承自 NB 类:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | class BagNB(NB):
def __init__(self):
pass
# P(wk|ci),基于多项式模型,重新实现 word_probability_vecs
def word_probability_vecs(self, msg_vecs, class_list, V):
index_vec = np.array(class_list)
prob_vecs = []
for cls in set(class_list):
cls_index = index_vec == cls
cls_vecs = msg_vecs[cls_index,:]
cls_total_words = np.sum(msg_vecs[cls_index,:])
print(cls_total_words, V)
prob_vec = (np.sum(cls_vecs, axis=0) + 1) / (cls_total_words + V)
prob_vecs.append(np.log(prob_vec))
return prob_vecs
# P(ci),基于多项式模型,重新实现 class_probability
def class_probability(self, msg_vecs, class_list):
index_vec = np.array(class_list)
total_words = np.sum(msg_vecs)
cls_prob_vecs = []
for cls in set(class_list):
cls_index = index_vec == cls
cls_total_words = np.sum(msg_vecs[cls_index,:])
cls_prob = cls_total_words / total_words
cls_prob_vecs.append(np.log(cls_prob))
return cls_prob_vecs
def fix(self, train_msgs, train_class):
# 生成分类集合
self.class_set = set(train_class)
self.class_num = len(self.class_set)
self.class_array = np.array(list(self.class_set))
# 生成单词表
self.vocab_list = vocab_list_create(train_msgs)
# 训练集留言转换为特征向量
self.msg_vecs = bag_messages2vecs(self.vocab_list, train_msgs)
# 计算各分类上单词的条件概率 P(wk|ci)
self.prob_vecs = self.word_probability_vecs(self.msg_vecs, train_class,
len(self.vocab_list))
# 计算各分类的先验概率 P(ci)
self.cls_prob_vecs = self.class_probability(self.msg_vecs, train_class)
......
|
邮件分类实战¶
我们使用朴素贝叶斯进行邮件分类,首先要对邮件数据进行处理,例如删除异常字符,然后转换为向量,最后进行分类。数据收集和清洗往往会占用大部分时间。
数据清洗和向量化¶
我们可以使用网络爬虫爬取网络数据,针对 html 文件可以使用 pyquery 和 BeautifulSoup 提取。如果我们已经将网页数据保存到数据库,或者其他格式的文件,例如这里将邮件数据保存为 txt 文件,分别放在 email 文件夹下,并分为两类正常邮件和垃圾邮件,对应子文件夹 ham 和 spam。
针对一个邮件文档,我们可以使用 split() 函数对句子进行分割,或者使用 re 模块替换一些特殊字符。Python 自带的 split 函数无法使用多个字符分割字符串,re 模块可以完成这一功能:
0 1 2 3 4 | p = re.compile(r'[, \-\*]')
print(p.split('1,2 3-4*5'))
>>>
['1', '2', '3', '4', '5']
|
不过这里我们不打算使用 re 来处理:scikit-learn 封装了 CountVectorizer 模块,通过它可以非常方便地将一个字符串转化为单词列表:
0 1 2 3 4 5 6 7 8 9 10 11 | from sklearn.feature_extraction.text import CountVectorizer
def msg2list(msg):
vectorizer = CountVectorizer()
analyze = vectorizer.build_analyzer()
return analyze(msg)
word_list = msg2list("hello ^$%^$ world!!")
print(word_list)
>>>
['hello', 'world']
|
原句子中有很多干扰字符,analyze 分析器自动进行了清理。下面通过遍历子文件夹生成邮件信息数组和分类数组:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | # 加载文档列表的数组和分类数组
def load_emails():
import os
ham_mail_dir = r'db/email/ham/' # 正常邮件
spam_mail_dir = r'db/email/spam/'# 垃圾邮件
email_list = []
file_list = os.listdir(ham_mail_dir)
class_list = [0] * len(file_list)
for i in file_list:
with open(ham_mail_dir + i, "r", encoding='ISO-8859-1') as f:
msg = f.read(-1)
email_list.append(msg)
file_list = os.listdir(spam_mail_dir)
class_list += [1] * len(file_list)
for i in file_list:
with open(spam_mail_dir + i, "r", encoding='ISO-8859-1') as f:
msg = f.read(-1)
email_list.append(msg)
email_array = np.array(email_list)
class_array = np.array(class_list)
return email_array,class_array
# 转换为单词列表
def load_email_msgs():
words_list = []
email_array,class_array = load_emails()
for i in email_array:
words_list.append(msg2list(i))
words_array = np.array(words_list)
return words_array,class_array
|
这里返回的是数组类型,这是为了方便我们进行数据集分割,把它们随机地按一定数目划分到训练集和测试集中,以进行交叉验证。
分类和交叉验证¶
shuffle() 函数对样本进行乱序处理,然后我们按比例分割为训练集和测试集:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # 对数据集进行
def shuffle(X, y, seed=None):
idx = np.arange(X.shape[0])
np.random.seed(seed)
np.random.shuffle(idx)
return X[idx], y[idx]
def test_email_nb_classifier(msg_array, class_array):
# 乱序处理
msg_array, class_array = shuffle(msg_array, class_array)
# 划分为训练集和测试集,总邮件数为 50,3:2 比例划分
train_num = 30
train_array = msg_array[0:train_num]
train_class_list = list(class_array[0:train_num])
test_array = msg_array[train_num:]
test_class_list = list(class_array[train_num:])
# 使用训练集训练
nb = BagNB()
nb.fix(train_array, train_class_list)
# 使用测试集测试并返回准确率
predicted_cls = nb.predict(test_array)
return nb.predict_accurate(predicted_cls, test_class_list)
|
为了准确获取模型的分类正确率,这里测试 100 次,然后取平均值:
0 1 2 3 4 5 6 7 8 | def average_test(test_times=100):
score = 0.0
msg_array, class_array = load_email_msgs()
for i in range(test_times):
score += test_email_nb_classifier(msg_array, class_array)
print("Predict average accurate percent {:.2f}%"
.format(score / test_times * 100))
|
在如此小的数据及上的测试效果还不错,正确率达到了 96%,实验发现无论是 SOW 还是 BOW 模型,测试结果差别不大。
0 1 2 3 4 5 6 7 8 9 | ......
Predict accurate percent 95.0%
Predict accurate percent 100.0%
Predict accurate percent 100.0%
Predict accurate percent 100.0%
Predict accurate percent 95.0%
Predict accurate percent 95.0%
Predict accurate percent 100.0%
Predict accurate percent 90.0%
Predict average accurate percent 96.00%
|
移除停用词¶
nltk 软件包提供了各种语言的常用停用词。
0 1 2 3 4 5 6 | import nltk
nltk.download('stopwords') # 下载停用词
>>>
[nltk_data] Downloading package stopwords to
[nltk_data] C:\Users\Red\AppData\Roaming\nltk_data...
[nltk_data] Unzipping corpora\stopwords.zip.
|
0 1 2 3 | from nltk.corpus import stopwords
# 获取停用词,指定语言为 'english'
stop_words = set(stopwords.words('english'))
|
无需对每一条加载的邮件信息过滤停用词,只需要在生成词汇表时移除即可。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def stop_words_remove(vocab_list):
from nltk.corpus import stopwords
stop_words = set(stopwords.words('english'))
for word in vocab_list:
if word in stop_words:
vocab_list.remove(word)
return vocab_list
def vocab_list_create(msgs):
vocab_set = set()
for i in msgs:
vocab_set |= set(i)
vocab_list = sorted(list(vocab_set))
# 移除停用词
return stop_words_remove(vocab_list)
|
移除停用词可以提高运算效率,并显著提升分类正确率。但是实践证明,移除停用词并不一定会提高分类正确率,不恰当的选择停用词可能效果恰恰相反。例如 ‘is’ 常常被认为是一个停用词,对分类没有帮助,然而当它与 ‘not’ 连用时就包含了非常强烈的否定信息,这在进行n元(n-gram)分词标记时尤为明显。
在邮件分类中,使用 nltk 提供的停用词,分类效果反而下降了:
0 | Predict average accurate percent 94.10%
|
此外对于不同的应用领域,停用词是不一致的,例如情感倾向分析中,像 ‘computer’ 这类词是中性的,但是在新闻分类中它显然包含了有用的分类信息,再比如数字在大部分分类中无关紧要,但是在垃圾邮件中往往充当重要角色,它可能是一个证券代码。
另外 CountVectorizer 模块同样支持设置停止词,当前内置只支持 ‘english’,也可以提供停止词列表:
0 1 2 3 4 5 6 7 | def msg2list(msg, stop_words='english'):
from sklearn.feature_extraction.text import CountVectorizer
# 支持停止词
vectorizer = CountVectorizer(stop_words=stop_words)
analyze = vectorizer.build_analyzer()
return analyze(msg)
|
可以通过以下方式查看 scikit-learn 自带的停止词,或者更新它:
0 1 2 3 4 | from sklearn.feature_extraction import text
print(text.ENGLISH_STOP_WORDS)
# 添加额外的停止词
print(text.ENGLISH_STOP_WORDS.union(['xxx', 'www']))
|
当然停止词同样支持一个列表类型的单词表,这时直接使用该单词表作为停止词。此时无需改动 vocab_list_create 函数,只需要在 msg2list 函数指定即可。
移除高频词¶
高频词是指在正负文本中都出现频率很高的单词,通常被认为无助于分类,可以把它们移除掉。简单的方式是直接在两分类上统计高频词,从实际验证看对结果依然有提升:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | # 统计单词表词频
def vocab_freq_get(vocab_list, msg_array):
words_list = []
for i in msg_array:
words_list += i
freq_list = []
for i in vocab_list:
freq_list.append(words_list.count(i))
return freq_list
# 移除前 num 个高频词
def vocab_list_create_remove_freq(msg_array, num=10):
vocab_list = vocab_list_create(msg_array)
freq_list = vocab_freq_get(vocab_list, msg_array)
for i in range(num):
index = freq_list.index(max(freq_list))
#print(vocab_list[index])
freq_list.pop(index)
vocab_list.pop(index)
return vocab_list
|
这样做的话,如果高频词可以被用于分类,效果就不一定好,在每一类上进行单独统计,然后求交集,并移除高频词:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | # 获取频率排序后的单词索引,频率越高排序越靠前
def max_freq_index_get(freq_list, num=10):
d = {key:val for key,val in zip(range(len(freq_list)), freq_list)}
d = sorted(d.items(), key=lambda x:x[1], reverse=True)
max_freq_index = []
for i in range(num):
max_freq_index.append(d[i][0])
return max_freq_index
# 返回高频停止词:不同分类中前 num 高频词中的交集
def high_freq_stop_words_get(vocab_list, msg_array, class_array, num=50):
freq_list_c0 = vocab_freq_get(vocab_list, msg_array[class_array==0])
freq_list_c1 = vocab_freq_get(vocab_list, msg_array[class_array==1])
high_freq_c0_index = max_freq_index_get(freq_list_c0, num=num)
high_freq_c1_index = max_freq_index_get(freq_list_c1, num=num)
# 求交集
high_freq_words = []
both_freq_index_set = set(high_freq_c0_index).intersection(set(high_freq_c1_index))
for i in both_freq_index_set:
high_freq_words.append(vocab_list[i])
return high_freq_words
# 移除不同分类中前 num 高频词中的交集词汇
def vocab_list_create_remove_freq_class(msg_array, class_array, num=50):
vocab_list = vocab_list_create(msg_array)
high_freq_words = high_freq_stop_words_get(vocab_list, msg_array,
class_array, num=num)
for word in high_freq_words[:num]:
vocab_list.remove(word)
return vocab_list
|
实践发现移除的高频词通常是一些停止词,例如:
0 | ['you', 'all', 'and', 'to', 'of', 'in', 'for', 'have', 'at', 'your']
|
但是这比使用通用的停止词要准确,这些停止词均是基于当前应用的准确的停止词,在移除高频词后测试效果提升了 1.4 个百分点:
0 | Predict average accurate percent 97.40%
|
文档转特征向量¶
实际上 CountVectorizer 模块功能非常强大,使用它可以直接生成词汇表和特征向量,并提供更多参数进行数据的细节处理。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | # 导入 CountVectorizer 模块,对数据进行清晰并分割
from sklearn.feature_extraction.text import CountVectorizer
cv = CountVectorizer()
corpus = [
'This is the first &&*document.',
'This is the second second document.',
'And the third one. !!',
'Is this the first document? <#$>',
]
bag = cv.fit_transform(corpus)
# cv.vocabulary_ 是单词表,格式为 {'word':index},index 是单词编号
# 这里把它转换为列表,然后按照索引排序
vocab_sorted = sorted(cv.vocabulary_.items(), key=lambda x:x[1], reverse=False)
print(vocab_sorted)
print(bag.toarray())
>>>
[('and', 0), ('document', 1), ('first', 2), ('is', 3), ('one', 4),
('second', 5), ('the', 6), ('third', 7), ('this', 8)]
[[0 1 1 1 0 0 1 0 1]
[0 1 0 1 0 2 1 0 1]
[1 0 0 0 1 0 1 1 0]
[0 1 1 1 0 0 1 0 1]]
|
特征向量中的每个索引位置与通过 CountVectorizer 得到的词汇表字典中存储的索引值对应。
观察 bag 中的数组,每一行对应一个句子,显然第一个单词为 ‘and’,只有第三个句子的第一单词是 ‘and’,所以第三行第一个元素为 1,其余行皆为 0。另外注意到原句子中有很多干扰字符,fit_transform 方法实现了数据清洗和分割。
N-Gram 模型¶
在词袋模型中,特征向量的属性由单个单词构成,也称为1元(1-gram)或者单元(unigram)模型。1-gram 模型完全忽略了单词之间的关系,显然这可能丢失了大量的可被用于分类的信息,例如 ‘is’, ‘not’ 和 ‘is not’,显然后者可以表达强烈的否定意义。
N-Gram 是基于一个假设:第 n 个词出现与前 n-1 个词相关,而与其他任何词不相关(也即隐马尔可夫链假设),整个句子出现的概率就等于各个词出现的概率乘积。各个词的概率可以通过语料统计计算得到。
N-Gram 也被称为 n 元模型。假设由 n 个词组成的句子 S=(w1,w2,w3…wn) 组成,如何从概率上对它评估呢?此时基于隐马尔科夫链假设,每一个单词 wi 都只依赖从第一个单词 w1 到它前一个单词 wi-1 的影响,用公式表示 N-Gram 语言模型如下:
N 元模型中数字 N 的选择依赖于特定的应用:Kanaris 等人通过研究发现,在反垃圾邮件过滤中,N 的值为 3 或者 4 的模型即可得到很好的效果,通常:
- N = 2 时,称为 Bi-Gram,只与前 1 词出现概率有关。
- N = 3 时,称为 Tri-Gram,只与前 2 词出现概率有关。
分别使用 1 元模型和 2 元模型来表示文档 “I pretty love you” 的结果如下:
- 1 元模型:”I”,”pretty”,”love”,”you”·
- 2 元模型:”I pretty”,”pretty love”,”love you”
scikit-learn 中的 CountVecorizer 类支持 ngram_range 参数来使用不同的 N 元模型。它的默认为值为 (1,1)。ngram_range [tuple (min_n, max_n)] 指定使用 N 元模型的范围:
0 1 2 3 4 5 6 | def msg2list(msg, ngram_range=(1,1)):
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(ngram_range=ngram_range)
analyze = vectorizer.build_analyzer()
return analyze(msg)
|
更新 msg2list 函数,添加 ngram_range 参数,查看效果:
0 1 2 3 4 5 6 7 8 9 | msg_list0 = msg2list("I pretty love you", ngram_range=(1,1))
msg_list1 = msg2list("I pretty love you", ngram_range=(2,2))
msg_list2 = msg2list("I pretty love you", ngram_range=(1,2))
print(msg_list0, msg_list1, msg_list2, sep='\n')
>>>
['pretty', 'love', 'you'] # CountVectorizer 默认忽略只有 1 个长度的单词
['pretty love', 'love you']
['pretty', 'love', 'you', 'pretty love', 'love you']
|
显然当使用 N-Gram 模型时,要求样本要足够多,这样单词间的组合特征才能充分出来,否则由于特征太过稀疏,导致预测结果变差,例如在当前邮件分类示例上的效果变得很差了:
0 1 | # ngram_range=(2,2) 预测结果
Predict average accurate percent 88.10%
|
当然我们可以取一折中,指定 ngram_range=(1,2),此时将同时采用 1-Gram 和 Bi-Gram 模型,当然计算量也同时增大了:
0 1 | # ngram_range=(1,2) 预测结果
Predict average accurate percent 97.10%
|
scikit 朴素贝叶斯实现¶
基于 CountVecorizer 类进行文档向量化,加载邮件时无需单个对文档向量化,而是通过 fit_transform 方法直接对所有文档一次性向量化,
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | def test_sklearn(email_array, class_array):
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import BernoulliNB # 伯努利模型
from sklearn.naive_bayes import MultinomialNB # 多项式模型
email_array, class_array = shuffle(email_array, class_array)
# split into train set and test set
train_num = 40
train_array = email_array[0:train_num]
train_class = class_array[0:train_num]
test_array = email_array[train_num:]
test_class = class_array[train_num:]
# 此时可以通过 stop_words='english' 添加停止词
vectorizer = CountVectorizer()
bag = vectorizer.fit_transform(train_array)
# 生成训练集和测试集特征向量
train_vecs = bag.toarray()
test_vecs = vectorizer.transform(test_array).toarray()
#clf = MultinomialNB()
clf = BernoulliNB(alpha=1.0, binarize=0.0,
class_prior=None, fit_prior=True)
clf.fit(train_vecs, train_class)
predicted_cls = clf.predict(test_vecs)
#print(predicted_cls, test_class)
correct_num = np.sum(test_class == predicted_cls)
# 返回正确率
return correct_num / len(predicted_cls)
|
BernoulliNB 中的参数意义如下:
- alpha 对应所有词默认的出现的次数,通常为 1,防止概率计算为 0。
- binarize 表示是否对数据二值化,也即所有非 0 值均置为 1,对于 BernoulliNB 模型来说需要二值化,如果传入参数已经是二值化的,那么可以设置为 None。
- class_prior 默认为 None,指定分类先验概率,一个与分类数相同的列表,如果不提供,则直接通过训练样本计算
- fit_prior 当未指定 class_prior 时,是否在通过样本计算分类的先验概率,默认为 True。如果为 False 则认为各个分类概率是均等的,直接使用 1/分类数计算。
test_sklearn 函数第一个参数 email_array 每一个元素都是原始文档,没有进行分词,vectorizer.fit_transform 直接进行向量化处理。
0 1 2 3 4 5 6 7 8 | def scikit_average_test(test_times=100):
score = 0.0
email_array, class_array = load_emails()
for i in range(test_times):
score += test_sklearn(email_array, class_array)
print("Predict average accurate percent {:.2f}%"
.format(score / test_times * 100))
|
由于这里没有进行高频词处理,所以分类得分要差于我们自编码实现的测试结果。
0 | Predict average accurate percent 93.00%
|
tf-idf 技术¶
tf-idf(Term Frequency-Inverse Document Frequency),词频-逆文档频率算法,它是一种统计方法,用于评估一词汇对一文件集或一语料库的中的某一类文档的重要性,词汇的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在所有文档中分布的均匀性成反比。计算公式如下:
- 其中 tf(t,d) 表示词汇 t 在文档 d 中出现的次数,也即词频(Term Frequency)。
- idf(t,d),表示逆文档频率(Inverse Document Frequency),\(n_d\) 为文档总数,df(d,t) 为包含词汇 t 的文档 d 的数量。加 1 是为了保证分母不为 0。取对数是为了保证文档中出现频率较低的词汇不会被赋予过大的权重。
tf-idf 技术是一种加权操作:一个词在一篇文档中出现的次数越多,同时在其他所有文档中出现的次数越少,这个词越能代表这篇文档的特征。tf-idf 技术单纯地认为文本频数小的单词就越重要,文本频数大的单词就越无用,显然这并不是完全正确的。
例如两分类中,一共 100 个样本,各 50 个,正样本中每个文档均含有 ‘good’ 单词,负样本中均含有 ‘bad’ 单词,显然这两个词的逆文档频率 idf(‘good’,d) = idf(‘bad’,d) = ln(100/50),大约等于 0.7,而其他词因为出现比较少,这个值就会增大,但是 ‘good’ 和 ‘bad’ 是两个非常典型的用于区分的重要单词,反而因为逆文档频率被抑制了权重。
tf-idf 技术与我们移除高频词汇的原理基本一致,但是没有按分类分开考虑,实际测试发现,效果提升并不明显。
scikit-learn 提供了 TfidfTransformer 模块用于 tf-idf 变换:
0 1 2 3 4 5 6 7 8 9 10 11 12 | def test_sklearn(email_array, class_array, with_tfidf=False):
......
with_tfidf = 1
if with_tfidf: # tf-idf 变换
from sklearn.feature_extraction.text import TfidfTransformer
tfidf_transformer = TfidfTransformer()
train_vecs = tfidf_transformer.fit_transform(bag).toarray()
test_vecs = tfidf_transformer.transform(vectorizer.transform(test_array)).toarray()
else:
train_vecs = bag.toarray()
test_vecs = vectorizer.transform(test_array).toarray()
......
|
神经网络¶
神经网络原理¶
神经网络(Neural Networks)由多层感知器组成,每个节点的激活函数均使用 sigmoid 逻辑回归函数。
上图是一个简单的三层神经网络,输入层,1个隐藏层和一个输出层,每个神经节点和节点输出的权重均编号。其中:
- 层号从 1 编号,神经节点从 0 编号
- 层号使用上标加括号表示,节点号使用下标表示。例如 \(a^{(2)}_1\) 表示第 2 层的 1 号节点。同时它也用于表示节点的激活值。
- 0 号节点总是用来表示偏置单元(bias unit),对应感知器中的阈值,它没有输入,输出权重总为 1。
- 输出权重 \(w^{(l)}_{ji}\) 表示第 l 层的第 i 个节点输出到第 l+1 层的第 j 节点权重。显然这里的 ji 不是按照从左到右的层排序的,而是 l+1 节点在前,l 节点在后,这是为了矩阵表达时的方便才如此定义的,看到 ji 就明白是反向定义即可。
\(w^{(1)}_{23}\) 表示第 1 层上的节点 3 (即图中 \(a^{(1)}_3\) )输出到第 2 层上的节点 2 (即图中 \(a^{(2)}_2\) )的权重。
通常只有单个隐藏层的神经网络被称为单层或者浅层(Shallow)神经网络,当隐藏层大于一层时,被称为深度神经网络(Deep Neural Networks)。
神经网络的学习过程如下:
- 从输入层开始,通过网络前向传播(从左向右,也称为正向传播,Forward propagation)训练数据中的模式,以生成输出。
- 基于输出层的输出,通过一个代价函数计算所需最小化的误差。
- 反向传播(Back propagation)误差,通过链式法则计算代价函数对于网络中每个权重的导数,并更新模型。
每个节点(神经元)可以看作是返回值位于[0,1]连续区间上的逻辑回归单元。
以上图为例,可以写出第 2 层各节点的输入值
可以看到每一层的 a 都是由上一层所有的 x 和每一个 x 所对应的权重系数决定的。这种从左到右的算法就是前向传播算法(Forward propagation)。
其中:
观察上式,权重 w 和输入 x 可以写成矩阵点乘形式:
其中权重矩阵 w 行数为第 2 层节点数(接受输出的节点数,不含偏置单元),列数为第 1 层节点数(所有输出节点数,含偏置单元)。观察每个权重的下编号,就可理解为何 l+1 节点在前,l 节点在后的编号定义了。
于是得出第 2 层输出的矩阵表示形式:
这只是针对一个训练样本所进行的计算。如果要对整个训练集进行计算,需要将训练集特征矩阵进行转置,使得同一个实例的特征都在同一列里。
显然,第 3 层的输入矩阵形式也容易写出。这非常易于编码实现。
神经网络实战¶
实现基本的神经网络类并不复杂,它是多个逻辑回归节点的分层叠加。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | # nn.py
class NN(object):
def __init__(self, sizes, eta=0.001, epochs=1000, tol=None):
'''
Parameters
------------
eta : float
Learning rate (between 0.0 and 1.0)
epochs: uint
Training epochs
sizes : array like [3,2,3]
Passes the layers.
'''
self.eta = eta
self.epochs = epochs
self.num_layers = len(sizes)
self.sizes = sizes
self.tol = tol
self.biases = [np.random.randn(l, 1) for l in sizes[1:]]
self.weights = [np.random.randn(l, x) for x, l in zip(sizes[:-1], sizes[1:])]
|
这里定义一个 NN 神经网络类,并定义初始化函数,完成以下工作:
- sizes 参数是一个层数列表,例如 [2,3] 表示神经网络有 2 层,每层节点数分别为 2 和 3。
- biases 成员记录了每一层的偏置单元的权重值,由于前一层偏置的权重个数等于后一层接受输出的节点数,所以这里取 sizes[1:]。
- weights 成员是权重列表,每一个元素都是一个 2 维的 ndarray,由于列表索引从 1 开始,所以 weights[0] 表示的是层 1->2 的权重矩阵。如果 1 层节点数为 2,2 层节点数为 3,则 weights[0] 就是一个 3*2 的二维矩阵。
- tol 停止条件的容忍度,当代价函数小于该值时,退出循环。
注意:理解 weights 的构造形式非常重要。为了逻辑更清晰,这里将偏置从权重矩阵中分离出来,同时初始化为 0-1 之间的随机正态分布数值。
0 1 2 3 4 5 | def sigmoid(self, z):
return 1.0/(1.0 + np.exp(-z))
def sigmoid_derivative(self, z):
sg = self.sigmoid(z)
return sg * (1.0 - sg)
|
sigmoid 为激活函数,sigmoid_derivative 对应它的导数,它被用于梯度下降算法中,其中的临时变量 sg 可以减少一次 sigmoid 运算。
0 1 2 3 4 | def feedforward(self, X):
X = X.T
for b, W in zip(self.biases, self.weights):
X = self.sigmoid(np.dot(W, X) + b)
return X
|
实现前向传播算法(Forward propagation)函数,关键点是从第一层开始针对每一层进行权重矩阵求和然后做 sigmoid。另外注意到 X 是一个训练集的输入矩阵,每一行代表一个样本,所以传入参数后要先对其转置。
显然返回值就是最后的节点输出,它是一个二维矩阵,如果输出节点数为 n,输入样本数为 m,那么它的 shape 为 (n,m),第 i 行对应第 i 个节点上的输出,第 j 列对应第 j 个样本通过神经网络处理的输出,所以 ij 个元素对应第 j 个样本在节点 i 上的输出(这里 i 和 j 下标均从 0 开始)。
这里使用异或训练集来查看 feedforward 函数的输出:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def boolXorTrain():
# Bool xor Train x11 x12 y1
BoolXorTrain = np.array([[1, 0, 1],
[0, 0, 0],
[0, 1, 1],
[1, 1, 0]])
X = BoolXorTrain[:, 0:-1]
y = BoolXorTrain[:, -1]
nn = NN([2,2,1])
a = nn.feedforward(X)
print(a.shape)
boolXorTrain()
>>>
(1, 4)
|
这里使用了 4 个训练样本,网络层数为 [2,2,1],输出节点数为 1,所以 feedforward 函数会输出 1 行 4 列的结果,这和理论分析是一致的。
从算法上看,feedforward 就是在对输入通过网络权重计算输出的过程,也就是算法进行预测的过程。预测函数也就可以如下实现了:
0 1 2 3 4 5 | def predict(self, X):
ff = self.feedforward(X)
if self.sizes[-1] == 1: # 1 output node
return np.where(ff >= 0.5, 1, 0)
return np.argmax(ff, axis=0)
|
这里的预测函数同时考虑到了输出节点只有 1 个和多个的情况:
- 如果输出节点只有 1 个,就是一个二分类神经网络,预测方式和逻辑回归是一致的,结果为 0 或 1。
- 如果输出节点有 n (n>1) 个,那么就是 n 分类算法,此时取输出层中输出似然概率最高的那个节点,每个分类编号从 0 到 n - 1。
在多分类情况下,n 个分类通常会安排 n 个输出节点,尽管 n 个节点从输出二进制(sigmoid输出经阈值处理后总是输出0,1)上可以表示 2^n 种分类,这是基于经验主义规定的,或许是 n 个输出节点增加了整个网络参数的节点数和权重数目,使得分类效果更好。
到这里我们还缺少最核心的东西,基于梯度下降的训练函数 fit。在实现它之前必须再次回顾逻辑回归的代价函数和梯度下降算法,因为神经网络本质上就是多层逻辑回归模型的叠加。
反向传播理论推演¶
逻辑回归是一个二分类算法,回顾它的代价函数:
逻辑回归算法,一个输入 \(x^i\) 只对应一个输出 \(y^i\),可以看做是一个标量,对应的标签值为 0 或者 1。但是在神经网络中,可以有很多输出变量,也即 K 个分类对应一个 K 维的向量,对应不同的标签向量。例如 3 分类的标签向量可能为:
\[\begin{split}\begin{eqnarray} \mathbf{y^i} = \left( \begin{array}{ccc} 1 \\ 0 \\ 0 \end{array} \right) \left( \begin{array}{ccc} 0 \\ 1 \\ 0 \end{array} \right) \left( \begin{array}{ccc} 0 \\ 0 \\ 1 \end{array} \right) \end{eqnarray}\end{split}\]
那么,对于 K 分类,标签向量被记为 \(y^i_k\),k 取 1 到 K。此时的代价函数可表示为多个输出节点的代价函数和:
尽管表达式看起来很复杂,实际算法上只不过在外循环内加上一个用于计算每个输出节点在训练样本 \(x^i\) 上代价的累加。
相较于上面的形式,笔者更喜欢把 K 累加放在外边,这样内侧累加就和逻辑回归代价函数保持一致,表示一个输出节点上的代价函数:
在逻辑回归中只要对代价函数求权重偏导,就可以得到调整权重 \(\Delta w\),但是神经网络具有多层,需要采用一种反向传播算法,也即先计算最后一层的误差(预测值与标签值的差),然后再一层一层反向求出各层的误差,直到倒数第二层。
理解反向传播的第一个关键点在于实际上它是逐个对每个训练样本计算代价函数的关于各个权重的偏导,并调整权重(实际上就是随机梯度下降),这将问题大大简化,我们无需一次考虑代价函数中的两层累加,而把问题的注意力放在单个训练样本对代价函数的影响上。
上图中是一个三层的神经网络,每层均有 2 个节点,暂不考虑偏置节点。除了输入节点外,每个节点被分割为左右两个半圆,左边表示节点的加权求和的权重输入,右边表示节点的激活输出。观察上图中的红色圈中的输出层节点 \(a^{(3)}_{1}\) ,它的权重输入通常标记为 \(z^{(3)}_{1}\); 它的输出与节点编号相同:\(a^{(3)}_{1}=\hat{y^i_1}\),这是当样本 i 输入到神经网络后,节点 \(a^{(3)}_{1}\) 的输出。
我们从图中被涂成红色的边上的权重 \(w^{(2)}_{11}\) 开始考虑如何对它进行调整,以使得输出的代价函数最小。 这条边的输入部分就是它连接的左侧节点 \(a^{(2)}_{1}\) 的输出,所以把 \(a^{(2)}_{1}\) 节点的右半边涂成红色, 这条边的权重输出对应到它连接的右侧节点 \(a^{(3)}_{1}\) 的输入 \(z^{(3)}_{1}\) ,所以把它的左半边涂成红色。
我们很快就会明白为何把这两个半圆涂成红色,这是某种强烈的隐喻,边上权重的调整与它们息息相关。
为了计算 \(w^{(2)}_{11}\) 的偏导数,就要理清 \(w^{(2)}_{11}\) 是如何影响代价函数的,这样我们就可以使用链式法则求偏导了。
观察关于训练样本 i 的代价函数,注意到式子中的 \(\phi (z^{(3)}_1)\),它就是输出层节点的输出:
与权重变量 \(w^{(2)}_{11}\) 发生关联的就是 \(z^{(3)}_1\):
由以上各式根据链式法则求对 \(w^{(2)}_{11}\) 的偏导:
观察以上公式,它分为左右两个部分,左侧部分对应代价函数对权重右侧节点的输入的偏导,右侧部分对应权重左侧节点的输出,当然左右可以交换下位置,这样就和图中的标记顺序一致了。到这里就可以理解为何图中如此涂色了。
通常代价函数对节点输入的偏导数称为该节点的误差信号(Error Signal),记为 \(\delta^{(l)}_i\),它表示当这个节点的输入变化 \(\Delta z\) 时,代价函数将产生 \(\delta^{(l)}_i\Delta z\) 的误差(实际上这就是对导数的数学意义的解释)。这里称为误差信号,更准确的意思是对代价函数的误差的影响大小,信号越大(越强),节点上的输入变化导致的误差就越大。
这里参考逻辑回归中的求导过程,上式结果为:
同理,根据上述规则,考虑所有输出节点的代价函数对第 2 层各权重的偏导关系,可以得出:
观察以上四个偏导公式,我们对偏导的两部分顺序进行了交换,这就和图中的左右顺序一致了:左侧部分对应权重节点的输入,右侧部分对应 \(\delta^{(3)}_i\)。如果考虑所有第三层的节点,就可以把误差写成向量的形式,记作 \(\delta^{(3)}\) :
同时针对第二层的权重的偏导公式可以写成如下形式,注意其中是向量逐元素相乘。
观察上式中的下标关系,可以写成矩阵的乘法形式:
尽管已经发现了代价函数对权重的偏导规律:输入和信号误差相乘。但是假如每一层的信号误差都要使用链式法则重新计算(特别是层数很多时),那么计算量无疑是巨大的,观察链式法则,我们会发现对第 1 层求偏导的计算链要基于第 2 层的信号误差,同理第 2 层的信号误差要基于第 3 层的信号误差,直至输出层。
这里继续对第 1 层中权重 \(w^{(1)}_{11}\) 求偏导,来说明以上的规律。
图中输入层无需考虑加权和激活部分,直接输入到权重边,所以整个圆被涂红。首先梳理清楚 \(w^{(1)}_{11}\) 与代价函数的关系(函数链,以便运用链式求导法则):
显然它通过 \(z^{(3)}_1\) 和 \(z^{(3)}_2\) 与代价函数关联了起来(这里的代价函数就要考虑所有输出点的情况了),另外我们在对第二层权重求偏导时,已经计算过代价函数对 \(z^{(3)}_1\) 和 \(z^{(3)}_2\) 的偏导,即 \(\delta^{(3)}_1\) 和 \(\delta^{(3)}_2\),所以对 \(w^{(1)}_{11}\) 的偏导公式可以写成:
我们把 \(\delta^{(3)}_1\) 和 \(\delta^{(3)}_2\) 代入以上公式,上式被化简成:
注意这里把输入 \(x^1\) 放在了最左边,其中:
我们不急于写出其他几个权重的偏导数,而是观察上式与第 2 层权重偏导公式(1.1)的关系,左边为输入项,右侧为代价函数对 \(z^{(2)}_1\) 的偏导,根据误差信号的定义,右侧部分可以标记为 \(\delta^{(2)}_1\),对应下图中的橙色部分的偏导。
对照上图,仔细观察 \(\delta^{(2)}_1\) 组成的各个部分,可以体会到它和正向传播的某种对称性。
观察图中蓝色部分,正向传播使用权重矩阵点乘输入进行:
为何观察上述对称性,我们把 \(\delta^{(2)}\) 的两个表达式列出来:
观察上述矩阵运算过程,正向传播使用权重矩阵的各行与输入相乘叠加得出下一层的权重输入,逆向传播使用权重矩阵的各列分别信号误差相乘,得出上一层的信号误差,所以 \(\delta^{(2)}\) 和 \(\delta^{(3)}\) 的关系可以表示为:
其中 \(\odot\) 表示元素逐项对应相乘。图中的蓝色线对应权重矩阵的第一行,红色线对应权重矩阵的第一列,它们可以同时充当正向传播和反向传播的桥梁,也即转置的作用。到这里就可以总结出反向传播的所有公式了:
注意当计算到第 2 层的信号误差时,第 1 层的权重调整系数就已经得到了,所以式子中的 l >= 2,并且当 l 为输出层时,无需激活函数的偏导部分,l 的取值范围为 [L,2]。有了每层的误差,就可以基于它计算每个权重的调整系数了,它是对式 (1.1) 的扩展:
显然这里使用 l-1 是为了公式 (1) 中 l 的取值范围和公式 (2) 保持一致。对于偏置项,由于输入总是 1, 所以调整系数就是输出节点的信号误差。
尽管这里只计算了 2 层权重的偏导来推导反向传播公式,显然它基于链式法则,可以推广到任意层,整个反向传播的计算流程描述如下:
- 随机初始化所有权重
- 使用一个样本进行正向传播,得到输出层的信号误差
- 复制一份权重和偏置,并初始化为 0,所有系数调整在该拷贝上进行
- 基于输出层的信号误差反向计算上一层的信号误差,使用信号误差计算出调整系数
- 对拷贝权重和偏置进行调整,直至计算到第 2 的信号误差并调整完第一层 1 的权重和偏置
- 更新拷贝项替到原权重和偏置
以上算法显示是随机梯度下降,也可以使用批量梯度下降,也即使用一批样本,对拷贝的权重和偏置进行累积调整,这一批训练样本处理完后再更新到原权重和偏置,这样效率要高很多。
反向传播理论思考¶
反向传播是如何被发现的?是偶然的,还是必然的。我记起来孟德尔发现遗传定律的豌豆杂交实验。豌豆具有多种特征:皮皱和光滑,高矮,种子颜色,种子的圆扁等等,它们之间有无数种组合,这里很像多个训练样本对层层线性和非线性神经网络的影响。
孟德尔成功的原因有几个要点:
- 将问题简化,先研究一对相对性状的遗传,再研究两对或多对性状的遗传
- 应用统计学方法对实验结果进行分析
- 基于对大量数据的分析而提出假设,再设计实验来验证。
孟德尔在以上方法指导下(更可能是后人根据孟德尔实验成功的方法论总结),耐心地进行 7 年的豌豆种植试验,最终破解了生物遗传密码,被誉为遗传学之父(在被埋没 35 年之后)。
逆向传播的发现也具有类似特征:
- 首先简化问题:只考虑一个样本对一个输出节点的影响
- 运用链式法则求倒数第 1 层的权重偏导数,观察发现它只与左侧节点的输入和代价函数对右侧节点输入的偏导数(信号误差)有关,并且基于链式法则,对任意层有效。
- 继续对前一层求偏导,可以发现第 2 层的信号误差与第 3 层的信号误差的关系,进而总结出规律公式 (0) 和 (1)。
并且可以发现最后一层的信号误差与代价函数和激活函数有关,其他层信号误差只与后一层的信号误差以及激活函数(激活函数关于输入的偏导数)有关:
- 如果我们要替换代价函数,那么只要调整最后一层信号误差计算方式。
- 如果要替换激活函数,那么在计算每一层信号误差时都要考虑,当然我们把它和它的导数均定义为函数,在代码实现上非常简单。
在大部分教科书上,神经网络的代价函数可能使用的是 MSE(均方差),而不是 MLE(最大对数似然函数)。笔者猜测,如果对线性回归比较熟悉,那么就倾向于使用 MSE,至少从形式上看很简单,并且求导过程不会那么可怕;另一条研究神经网络的路线是:感知器到Adaline模型,到逻辑回归,再到神经网络,那么选择 MLE 就水到渠成。笔者更倾向于第二条路线。不过这无关紧要,重要的是不同的代价函数对神经网络实现和性能到底有什么影响?
或许有人记得 MLE 形式的代价函数在逻辑回归中是个凸函数,这让我们可以找到极小值,而在神经网络中,每个权重都经过了层层的线性和非线性叠加变换,指望这里的代价函数具有凸函数特性是不可能的。想象一个层数为 [2,2,1]的神经网络,它的权重数目为 2*2 + 2*1 = 6,我们不可能绘制一个 6 维的代价函数曲面,但是我们可以把其中的 4 个权重固定,只改变其中的两个,这样取得的曲面图就是一个子集,它能从侧面反映出代价函数的凹凸性,所以我称为这里的代价函数曲面为拟曲面图。
下图是在 XOR 问题上训练完成的权重上,调整其中两个权重得到的曲面图,显然它不是凸面函数,有着丰富的波动从而导致众多的局部极小值。此图是在直观层面上说明选择神经网络的代价函数已经不再考虑凹凸性了,也即凹凸性不是选择 MLE 方法的原因。这里可以提及的是权重使用随机初值化,而不是全 0,是因为 0 附近很可能是一个局部极小值,导致每次迭代都无法收敛。
MSE 代价函数¶
MSE 代价函数很容易写出来,与 Adaline 模型不同的是,这里要考虑输出层有 K 个输出节点的情况,也即预测值是一个向量,而不再是一个数字:
上面已经指出如果要替换代价函数,那么只要调整最后一层信号误差计算方式。这里使用 MSE 代价函数对上图中的 \(z^{(3)}_1\) 求偏导数,在考虑一个样本情况时可忽略常数 1/n:
得到最后一层的信号误差 \(\delta^{(3)}\):
对比最大对数似然估计,可以看到这里只是多了激活函数对节点输入的偏导数项。显然 MSE 在反向计算每一层信号误差时都需要加入该项,则 MLE 在计算最后一层信号误差时无需考虑(因为代价函数求导时约去了该项)。
基于以上理论,实现反向传播不再困难,我们尝试在一个样本上实现反向传播算法,并在测试验证正确后再尝试优化。
实现反向传播¶
定义名为 backprop 的类函数,x 是一个样本,为 1*n 的特征矩阵。y 是标签值,它是一个 K 维向量,K 为输出层的节点数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | def backprop(self, x, y):
# 初始化新的偏置和权重系数矩阵
delta_b = [np.zeros(b.shape) for b in self.biases]
delta_w = [np.zeros(w.shape) for w in self.weights]
# 转置样本特征矩阵,以便于权重矩阵左乘
x = x.reshape(x.shape[0], 1)
y = y.reshape(y.shape[0], 1)
activation = x
# 记录每一层每一节点的激活函数输出,用于在反向传播时计算激活函数的偏导 a(1-a)
# 第一层就是 x 自身
acts = [x]
# zs 记录每一层每一节点的权重输入
zs = []
# 进行前向传播,以得到每层每个节点的权重输入,激活函数值和最终输出层的信号误差
for b, W in zip(self.biases, self.weights):
z = W.dot(activation) + b
zs.append(z)
activation = self.sigmoid(z)
acts.append(activation)
# 反向传播,这里使用 MSE 代价函数,所以 L-1 层信号误差计算需乘以激活函数的导数
delta = (acts[-1] - y) * self.sigmoid_derivative(zs[-1])
delta_b[-1] = delta # 偏置的输入总是 1, 所以就是信号误差
delta_w[-1] = np.dot(delta, acts[-2].transpose())
for l in range(2, self.num_layers):
sp = self.sigmoid_derivative(zs[-l])
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
delta_b[-l] = delta
delta_w[-l] = np.dot(delta, acts[-l-1].transpose())
# 更新偏置和权重
self.biases = [b-(self.eta) * nb / X.shape[0]
for b, nb in zip(self.biases, delta_b)]
self.weights = [w-(self.eta) * nw / X.shape[0]
for w, nw in zip(self.weights, delta_w)]
|
结合理论分析,再理解上面的代码实现并不困难。注意以上函数一次只使用一个样本进行权重调整,这在实际中运行中效率很慢,我们可以使用矩阵来实现批量样本的权重更新。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | # X is an array with n * m, n samples and m features every sample
def mbatch_backprop(self, X, y):
delta_b = [np.zeros(b.shape) for b in self.biases]
delta_w = [np.zeros(w.shape) for w in self.weights]
# feedforward
if X.ndim == 1:
X = X.reshape(1, X.ndim)
if y.ndim == 1:
y = y.reshape(1, y.ndim)
activation = X.T
acts = [activation]
zs = []
for b, W in zip(self.biases, self.weights):
z = W.dot(activation) + b
zs.append(z)
activation = self.sigmoid(z)
acts.append(activation)
# 这里的注意点在于偏置的更新,需要进行列求和
delta = (acts[-1] - y.T) * self.sigmoid_derivative(zs[-1])
delta_b[-1] = np.sum(delta, axis=1, keepdims=True)
delta_w[-1] = np.dot(delta, acts[-2].transpose())
for l in range(2, self.num_layers):
sp = self.sigmoid_derivative(zs[-l])
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
delta_b[-l] = np.sum(delta, axis=1, keepdims=True)
delta_w[-l] = np.dot(delta, acts[-l-1].transpose())
self.biases = [b-(self.eta) * nb / X.shape[0]
for b, nb in zip(self.biases, delta_b)]
self.weights = [w-(self.eta) * nw / X.shape[0]
for w, nw in zip(self.weights, delta_w)]
|
训练函数支持批量训练,这里默认批处理样本数为 8。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | # MSE 代价函数,用于统计观察下降情况
def quadratic_cost(self, X, y):
return np.sum((self.feedforward(X) - y.T)**2) / self.sizes[-1] / 2
def fit_mbgd(self, X, y, batchn=8, verbose=False):
'''mini-batch stochastic gradient descent.'''
self.errors_ = []
self.costs_ = []
self.steps_ = 100 # every steps_ descent steps statistic cost and error sample
if batchn > X.shape[0]:
batchn = 1
for loop in range(self.epochs):
X, y = scaler.shuffle(X, y) # 每周期对样本随机处理
if verbose: print("Epoch: {}/{}".format(self.epochs, loop+1), flush=True)
x_subs = np.array_split(X, batchn, axis=0)
y_subs = np.array_split(y, batchn, axis=0)
for batchX, batchy in zip(x_subs, y_subs):
self.mbatch_backprop(batchX, batchy)
# 使用正向传播获取误差值,计算代价函数值,观察收敛情况
if self.complex % self.steps_ == 0:
cost = self.quadratic_cost(X,y)
self.costs_.append(cost)
# 平均最后5次的下降值,如果下降很慢,停止循环,很可能落入了局部极小值
if len(self.costs_) > 5:
if sum(self.costs_[-5:]) / 5 - self.costs_[-1] < self.tol:
print("cost reduce very tiny less than tol, just quit!")
return
print("costs {}".format(cost))
self.complex += 1
|
注意,如果下降很慢,则停止循环,很可能落入了局部极小值,此时应该重新训练网络以尝试其他权重值。
分割异或问题¶
我们尝试用最简单的 [2,2,1] 神经网络来解决 XOR 异或分割问题。众所周知,异或问题不是线性可分的,那么神经网络是怎么通过非线性函数实现异或分割的呢?
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def boolXorTrain():
# Bool xor Train x11 x12 y1
BoolXorTrain = np.array([[1, 0, 1],
[0, 0, 0],
[0, 1, 1],
[1, 1, 0]])
X = BoolXorTrain[:, 0:2]
y = BoolXorTrain[:, 2]
if y.ndim == 1: # vector to 2D array
y = y.reshape(y.shape[0], 1)
nn = NN([2,2,1], eta=0.5, epochs=10000, tol=1e-4)
nn.fit_mbgd(X,y)
pred = nn.predict(X)
print(nn.weights)
print(pred)
|
如果我们尝试将权重和偏置的初始值均置为 0,将会诧异地发现根本无法收敛。这预示着 0 点处是一个局部极小点,实际使用中如果发现跌入局部极小点而无法收敛到 0 附近,那么就要重新随机初始化权重,再进行模型训练。
0 1 2 3 4 5 6 7 8 | >>> python nn.py
......
costs 0.00024994697604848544
cost reduce very tiny, just quit!
weights: [array([[-5.55203673, 5.34505725],
[-6.10271765, 6.20215693]]), array([[ 9.2106689 , -8.70927677]])]
biases: [array([[-2.89311115],
[ 3.10518764]]), array([[ 4.06794821]])]
[[1 0 1 0]]
|
实际观察,在学习率为 0.5 时,大约需要 7000 个迭代周期(实际上由于样本很少,等同于批量梯度下降)才会收敛到比较满意的值。当然我们可以增加 tol 来成倍降低迭代周期,当然我们最关心的不仅仅是计算量的大小,还有分类的实际效果。
首先看下代价下降曲线图,首先很庆幸我们随机的一组权重参数在一开始就给出了较小的代价值,也即这次随机选择的点邻近最优点(也可能是局部最优点),在迭代大约 2000次之后,就已经下降到接近于 0。
现在回归最根本的问题,尽管预测值和实际的标签已经完全一致,也即正确率 100%,那么神经网络是如何做到的呢?根据以往的线性分割模型无法分割 XOR 的点,直觉上可以想到,它可能进行了某种非线性分割,比如用一个椭圆(这里指高维曲面)把其中两个标签为 1 的点圈起来,外部则为 0 的点。但是如何验证推测呢?一个需要点发散思维的方式是绘图,这样就需要把图像限制在 3D 空间,也即特征变量最多有 2 个,XOR 训练集正好满足了这一特征,剩下的就是使用已经训练好的这组权重和偏置来对更多的假想特征值进行预测,来获取代价函数曲面。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | def draw_perdict_surface(self, X, y):
from mpl_toolkits import mplot3d
# XOR 特征值范围为 0-1,所以在 -2-2 区间作图足够反应整个预测平面
x1 = np.linspace(-2, 2, 80, endpoint=True)
x2 = np.linspace(-2, 2, 80, endpoint=True)
title = 'Perdict Surface and Contour'
# 生成网格化坐标
x1, x2 = np.meshgrid(x1, x2)
acts = np.zeros(x1.shape)
for i in range(x1.shape[0]):
for j in range(x1.shape[1]):# 计算输出层激活值
acts[i,j] = self.feedforward(np.array([x1[i,j], x2[i,j]]).reshape(1,2))
plt.figure(figsize=(6, 6))
ax = plt.axes(projection='3d') # 绘制输出层激活值曲面图,透明度设置为 0.8 以便于观察样本点
ax.plot_surface(x1, x2, acts, rstride=1, cstride=1, cmap='hot',
edgecolor='none', alpha=0.8)
# 绘制样本点的输出层的激活值(蓝色)和标签值(红色)
z = self.feedforward(X)
ax.scatter3D(X[:,0], X[:,1], z, c='blue')
ax.scatter3D(X[:,0], X[:,1], y[:,0], c='red')
ax.set_title(title)
ax.set_xlabel("x1")
ax.set_ylabel("x2")
# 绘制 3D 曲面的等高线
ax0 = plt.axes([0.1, 0.5, 0.3, 0.3])
ax0.contour(x1, x2, acts, 30, cmap='hot')
plt.show()
|
实际上这里有些前提,例如输出层只有一个节点,我们可以直接使用输出值作为曲面的第三维,想象输出层有多个节点,那么其中一个节点的输出所能绘制的曲面就是高高低低,而低洼的地方对应样本点在该节点输出为 0 的特征,高凸的地方对应输出 1 的特征。
通过预测曲面图清楚地看到神经网络的强大之处,它非常聪明地生成了一对“翅膀”,靠近脊柱的地方凹陷,两侧翅膀高凸,观察样本点,预测值为1的样本点均落在了两侧翅膀上,预测值为 0 的样本点均落在了脊柱上。那么自然可以想到,另一种分割方法是预测值为1的样本点能落在脊柱上,而0的样本点落在翅膀上,此时脊柱隆起,而翅膀向下扇动:
梯度下降和交叉熵函数¶
在逻辑回归中,我们指出数据的标准化和权重的初始值异常重要,否则将导致求和函数的输出很大,继而使得 sigmoid 函数的输出接近 1 或者 -1,尽管正确的标签应该是 0 和 1(此时错误非常严重,预测和实际完全相反),此时的斜率非常小(曲线接近平行于 x 轴,导数很小),也就导致梯度下降在开始时非常缓慢。
神经网络也有如此问题,可以尝试将所有权重初始化为 3,偏置为 0,可以得到如下的下降曲线图,此时“学习效率”在初期非常低:
我们观察神经网络在使用 MSE 代价函数时的权重调整计算公式,以理解为何会出现这类现象:
理论分析阶段已经指出,基于 MSE (二次代价函数)的代价函数时,公式(0) 的最后一层就需要乘以激活函数的导数部分(当权重较大时,斜率很小),这其实就是学习缓慢的原因所在。而交叉熵函数(最大对数似然函数)就不需要该项。可以想到如果去除该项,下降速度将加快,更改 mbatch_backprop 并不复杂:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | # 增加类型 type 参数,可以在 MSE 和交叉熵函数之间选择代价函数
def mbatch_backprop(self, X, y, type='llh'):
delta_b = [np.zeros(b.shape) for b in self.biases]
delta_w = [np.zeros(w.shape) for w in self.weights]
# feedforward
if X.ndim == 1:
X = X.reshape(1, X.ndim)
if y.ndim == 1:
y = y.reshape(1, y.ndim)
activation = X.T
acts = [activation] # list for all activations layer by layer
zs = [] # z vectors layer by layer
for b, W in zip(self.biases, self.weights):
z = W.dot(activation) + b
zs.append(z)
activation = self.sigmoid(z)
acts.append(activation)
# 交叉熵函数时,第一项无需求乘以激活函数的导数,且无需取平均:样本数置为 1
samples = X.shape[0]
if type == 'llh':
samples = 1
delta = (acts[-1] - y.T) * self.sigmoid_derivative(zs[-1])
else:
delta = (acts[-1] - y.T) * self.sigmoid_derivative(zs[-1])
delta_b[-1] = np.sum(delta, axis=1, keepdims=True)
delta_w[-1] = np.dot(delta, acts[-2].transpose())
for l in range(2, self.num_layers):
sp = self.sigmoid_derivative(zs[-l])
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
delta_b[-l] = np.sum(delta, axis=1, keepdims=True)
delta_w[-l] = np.dot(delta, acts[-l-1].transpose())
self.biases = [b-(self.eta) * nb / samples
for b, nb in zip(self.biases, delta_b)]
self.weights = [w-(self.eta) * nw / samples
for w, nw in zip(self.weights, delta_w)]
|
在同样的权重初始化下,可以看到使用交叉熵函数作为代价函数时,下降速度提高了 40 倍,以极快的速度下降到了指定的 tol 之下:
总结:使用 MSE 代价函数时,在神经元犯错严重的时候反而学习速率更慢。使用交叉熵代价函数时则神经元犯错严重时速度更快(最后一层的信号误差不再乘以激活函数的导数,只保留预测值和标签值差值,因为犯错严重,几乎相反,所以差值在最大值 1 或者 -1 附近)。特别指出,当使用次代价函数时,当神经元在接近正确的输出前只在少量样本犯了严重错误时,学习变得异常缓慢,使用交叉熵代价函数就可以缓解这种情况。
当然,如果我们把权重初始化得异常大,那么就会犯逻辑回归中的错误,误差值被钳制在了 1 和 -1 上,更大的错误并不能继续提高下降速度,所以对数据标准化以及权重随机初始在 0-1 之间是至关重要的。
另外要注意到我们这里只考虑了最后一层信号误差大小对梯度下降的影响,实际分析整个链式求导法则,可以发现每一层的误差都在不停减小,也即越靠近输入层,权重调整值越小,这是神经网络的另一大问题:梯度消失。
下面的数据源于随机初始化偏置,而权重调整为 0 时,进行一些周期的梯度下降后的权重和偏置值,明显发现后层的权重更大,前层权重更小:
0 1 2 3 | weights: [array([[ 0.00099128, 0.00099128],
[ 0.00130966, 0.00130966]]), array([[-0.14912941, -0.13739083]])]
biases: [array([[ 0.00759751],
[-0.15077827]]), array([[ 0.13845597]])]
|
柔性最大值 softmax¶
在二分类问题中输出层只有一个节点,使用 sigmoid 对数回归函数就可以很好地从概率上给与预测准确率的解释,但是在多分类问题上,各个节点激活值的和就完全可以大于 1,这样就无法满足概率解释的需求了。这里就引入了柔性最大值(softmax) 激活函数。后面的分析可以看到 softmax 激活函数:
- 不仅可以很好地从概率上解释预测的准确率;
- 同样它和 sigmoid 一样,可以解决使用交叉熵代价函数学习缓慢的问题。(显然我们不能为了解决一个问题而带来另一个问题,否则就不会引入 softmax了!)
柔性最大值只用于输出层,也即它为神经网络重新定义了输出层。在 softmax 激活值函数中,分母是经归一化处理的所有 K(输出层节点数)个线性函数之和,而分子为净输入 z,二者的比值即为特定样本属于第 j 个类别的概率:
式中 L 表示输出层,j 表示输出层第 j 个节点,分母中的求和在所有的输出节点上进行,显然所有 K 个节点的激活输出的和总为 1,某个节点输出的增加将导致其他节点的输出降低,反之亦然。
另外注意到由于指数函数是正的,所以每个节点的激活输出总是正的,又由于它们的和总为1,柔性最大值在第 j 个节点上的输出可以被看做预测为分类 j 的似然概率。softmax 的实现非常简单:
0 1 | def softmax(self, z):
return np.exp(z) / np.sum(np.exp(z), axis=0)
|
那么在使用柔性最大值激活函数时,代价函数如何表示呢?显然对于一个样本输入 x,对应一个 K 维的向量,其中有一个标签为 1,其余均为 0,我们的目标是使得对应标签 1 的输出(似然概率)最大即可:
其中 \({a^L_j}^i\) 表示输入样本 i 时,标签为 1 的节点 j 的输出。显然它对应的对数似然函数如下:
显然似然函数最大,则代价函数最小,加负号,并取平均,即可得到代价函数:
只考虑一个样本时的代价函数:
根据反向传播理论,一个权重的调整系数只与输入和输出信号误差有关,例如图中的 \(w^{(2)}_{11}\) 的调整系数只与 \(a^{(2)}_{1}\) 和 \(\delta^{(3)}_{1}\) 有关,其中:
更普遍地,根据链式求导法则有:
上式第 1 部分容易求得:
第 2 部分分为两种情况,当 i = j 时,表示当前节点的激活输出对当前节点的输入求偏导,i != j 时,表示对其他节点的输入求偏导,也即:
将以上两部分合并后得到:
观察上式,对于样本 i 的输入,由于 j 对应标签值为 1 的节点,其余节点标签值为 0,上式可以合并为:
这和使用 sigmoid 激活函数的偏导是一致的,也即无需对反向传播的核心部分进行更改。再仔细观察这里的代价函数,与逻辑回归代价比较,显然当逻辑回归代价函数中所有标签为 0 的项都消去,它们就成了一样的形式。所以代价函数代码也无需更改。
这里只需要增加一个 softmax 开关,并更新前向传播的相关代码即可:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def __init__(self, sizes, eta=0.001, epochs=1000, tol=None, alpha=0, softmax=True):
......
# 根基 softmax 开关来设置不同的输出层激活函数
self.outactive = self.sigmoid
if softmax: self.outactive = self.softmax
......
def feedforward(self, X):
out = X.T
for b, W in zip(self.biases[0:-1], self.weights[0:-1]):
out = self.sigmoid(W.dot(out) + b)
# 更新最后一层的激活值计算方式
return self.outactive(self.weights[-1].dot(out) + self.biases[-1])
def mbatch_backprop(self, X, y, type='llh', total=1):
......
layers = 0
for b, W in zip(self.biases, self.weights):
z = W.dot(activation) + b
zs.append(z)
# 更新最后一层的激活值计算方式
if layers == self.num_layers - 2:
activation = self.outactive(z)
else:
activation = self.sigmoid(z)
acts.append(activation)
layers += 1
|
在 sklearn 中的 MLPClassifier 神经网络模块中,当遇到多分类情况时,输出层的激活函数默认设置为 softmax。本质上 softmax 不能带来任何性能的提升,只是方便从似然概率的角度对模型进行更好的解释。
神经网络的强大表现力¶
我们已经看到在 XOR 问题上神经网络预测曲面,它以非常具有弹性的方式扭曲,以适应不同样本所在的空间,并把它们包围或者分割开来。如果尝试在 XOR 数据的基础上,在 y = x 方向增加一些样本点,并且设置标签值互相交替,感性地看一下神经网络在“加强版” XOR 数据上的表现能力:
通过实践可以发现数据的分类交织越复杂,就要使用更多的隐藏节点,否则很难训练出有效的模型。这里使用 [2,10,1] 网络结构来训练样本,并观察上图中的等高线,负样本被一一限制在像蜂房一样的格子里,格子外则是正样本的领域。再观察 3D 图形,曲面在负样本聚集处快速下陷,形成一个蜂巢(或者抽屉,其实这里更像雪糕模型!)从而能把正负样本分离出来。
不要寄希望于每次训练都能得到这一组权重,让预测平面看起来如此完美无瑕,实际上 [2,10,1] 的网络参数已经达到了 (2*10 + 10) + (10*1 + 1) = 41 个(其中权重 2*10+10*1 =30 个,偏置 11 个),它能张成的空间早已超出人脑所能想象之外,上图只不过是数亿亿分之一的一个解决方案,大部分在训练集上的预测曲面可能是这样的: 它们长得奇形怪状,但是确实能够完美的分割训练集,但是对于未知数据的泛化能力就要大打问号了。
实际上上图已经出现了过拟合现象,神经网络如此强劲的表达能力能够将每一个样本点单独圈在一个蜂巢里,而让我们误以为它在训练集上正确率达到了百分之百,而实际上它的泛化能力可能差到了极致。
上图是对 12*12 个随机点组成的二分类 3D 曲面的投影,可以一睹神经网络强大的变形能力,不禁惊叹,神经网络似乎就是一个具有“思考”能力的超级可变函数,实际上在多样本多特征值的超级复杂网络上,它在高维空间变幻出各种分割子空间,我们根本不可能如此直观地观察边界投影,所以防止过拟合在神经网络模型上是非常重要的。
MNIST数据分类¶
这里使用 MNIST 数据集进行手写数字分类的测试,作为参照,使用 sklearn 的多层感知器 MLP 分类模型作为基准。
0 1 2 3 4 5 6 7 8 9 | def sklearn_nn_test():
from sklearn.neural_network import MLPClassifier
images, labels, y_test, y_labels = dbload.load_mnist_vector(count=40000, test=10000)
mlp = MLPClassifier(hidden_layer_sizes=(100,), max_iter=10000, activation='logistic',
solver='sgd', early_stopping=True, verbose=1, tol=1e-4, shuffle=True,
learning_rate_init=0.01)
mlp.fit(images, labels)
print("Training set score: %f" % mlp.score(images, labels))
print("Test set score: %f" % mlp.score(y_test, y_labels))
|
该算法在迭代大约 30 次之后可以达到 96.5% 的测试集识别率,效果还是很好的:
0 1 | Training set score: 0.999700
Test set score: 0.965000
|
0 1 2 3 4 5 6 7 8 9 | def MNISTTrain():
images, labels, y_test, y_labels = dbload.load_mnist_vector(count=40000, test=10000)
y = np.zeros((labels.shape[0], 10))
for i, j in enumerate(labels):
y[i,j] = 1
nn = NN([images.shape[1], 100, 10], eta=5, epochs=100000, tol=1e-4)
nn.fit_mbgd(images, y, costtype='llh', batchn=256, x_labels=labels,
Y_test=y_test, y_labels=y_labels)
|
这里对 fit_mbgd 函数增加一些用于在训练过程中用到的评估参数,并新增了评估函数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def evaluate(self, x_train, x_labels, y_test, y_labels):
pred = self.predict(x_train)
error = pred - x_labels
error_entries = np.count_nonzero(error != 0)
test_entries = x_labels.shape[0]
print("Accuracy rate {:.02f}% on trainset {}".format(
(test_entries - error_entries) / test_entries * 100,
test_entries), flush=True)
pred = self.predict(y_test)
error = pred - y_labels
error_entries = np.count_nonzero(error != 0)
test_entries = y_labels.shape[0]
print("Accuracy rate {:.02f}% on testset {}".format(
(test_entries - error_entries) / test_entries * 100,
test_entries), flush=True)
|
在 fit_mbgd 中每次统计代价值时,均对训练集和测试集进行评估,以观察神经网络的学习进度。注意这里的学习率 5 是经过多次实验验证的,它是训练时间和良好的结果的权衡的结果,较大的学习率有助于跳出局部最小值。在迭代大约 10 多次后,测试集的正确率达到了 94%,之后尽管训练集的正确率还在上升,但是测试集的准确率基本不动了。
0 1 | Accuracy rate 99.72% on trainset 40000
Accuracy rate 94.60% on testset 10000
|
实际上神经网络在 8 个迭代期后的学习已经基本无效了,它无法泛化到测试数据上。所以这不是有效的学习。神经网络在这个迭代期后就过度拟合(overfitting)或者过度训练(overtraining)了。
检测过度拟合的明显方法就是跟踪测试数据集合上的准确率随训练变化情况。如果测试数据上的准确率不再提升,那么就应该停止训练。要么换一组随机权重参数,要么调整学习率或者其他超参数。所以通常把训练数据集分成两部分:训练数据集和校验数据集,校验数据集用于预防过度拟合。
交叉验证¶
现实中,在实际解决问题时,人们总是要进行各种权衡,也即没有一击必中的解决方案。所以是选用几种不同的算法来训练模型,并比较它们的性能,从中选择最优的一个是惯常的做法。但是评估不同模型的性能优劣,需要确定一些衡量标准。常用的标准之一就是分类的准确率,也即被正确分类的样例所占的比例。这种方法被称为交叉验证:将训练数据集划分为训练集和校验集,从而对模型的泛化能力进行评估。
交叉验证(CV,Cross Validation)法又分为两种:Holdout 交叉验证(Holdout cross-validation)和 K 折交叉验证(K-fold cross-validation)。
Holdout 留出法,MNIST 分类示例中将初始数据集(initial dataset)分为训练集(training dataset)和测试集(test dataset)两部分,就是一种 Holdout 方法。训练集用于模型的训练,测试集进行性能的评价。然而从上述训练过程可以看到,在实际操作中,常常需要反复调试和比较不同的参数以提高模型在新数据集上的预测性能。这一调参优化的过程就被称为模型的选择(model selection),这是在给定分类问题上调整参数以寻求最优值(也称为超参,hyperparameter,通常指权重系数之外的参数,例如学习率)的过程。
在这一过程中如果重复使用同样的测试集,测试集等于成了训练集的一部分,此时模型容易发生过拟合,也即模型最终将能很好的泛化到训练集和测试集,但是在新数据上表现糟糕。
所以改进的 Holdout 方法将数据集分成 3 部分:
- 训练集(training set),训练集用于不同算法模型的训练。
- 验证集(validation set),模型在验证集上的性能表现作为模型选择的标准。
- 测试集(test set)用于评估模型应用于新数据上的泛化能力。
使用模型训练及模型选择阶段不曾使用的新数据作为测试集的优势在于:评估模型应用于新数据上能够获得较小偏差(防止过拟合)。
Holdout 方法的缺点在于性能的评估对训练集和验证集分割方法(例如分割比例)是敏感的。
我们必须确定训练退出的标准,而这是非常困难的,最简单的方式就是评估验证集的分类准确度的变化,如果最近几个迭代周期的准确度变化很微弱,那么就可以停止训练了。
实际上我们很快就会发现,分类准确率和训练集和验证集分割比例呈正相关,也即训练集越大,验证集准确率越高,与此同时训练集的准确率也越高,也即扩大训练数据集可以防止模型的过拟合,但是实际中收集更多数据是非常昂贵的,甚至是不现实的。
Holdout 方法提供了一种观察算法拟合情况的视窗,它揭示出数据集的独立同分布特征,如果算法能够很好地学习到真实数据的特征,那么它在这三个数据集上的得分就应该是基本一致的,而不是在训练集上很高,而在其他测试集上效果很一般。
Holdout 常用于寻找理想的超参数,以取得三个数据集上的平衡(既不欠拟合也不过拟合)。K 折交叉验证是对Holdout 方法的扩展,它具有更实际的应用意义。
K 折交叉验证¶
K-折交叉验证(K-fold Cross Validation,K-CV) 随机将训练数据集划分(通常为均为划分)为 K 个子集,其中 K-1 个用于模型的训练,剩余的 1 个用于测试。依次使用第 1 到 K 个子集用于测试,重复此过程 K 次,就得到了 K 个模型及对模型性能的评价。
K-CV方法的优势在于(每次迭代过程中)每个样本点只有一次被划入训练数据集或测试数据集的机会,与 Holdout方法相比,这将使得模型性能的评估具有较小的方差(防止了过拟合)。
K 的标准值为 10,这对大多数应用来说都是合理的。但是,如果训练数据集相对较小,那就有必要加大 K 的值。如果增大 K 的值,在每次迭代中将会有更多的数据用于模型的训练,这样通过计算各性能评估结果的平均值对模型的泛化性能进行评价时,可以得到较小的偏差(防止了欠拟合)。当然 K 值取得较大,处理时间也随之增加。
通常情况下,我们将K-CV 方法用于模型的调优,也就是找到使得模型泛化性能最优的超参值。一旦找到了满意的超参值,就在全部的训练集上重新训练模型,并使用独立的测试数据集对模型性能做出最终评价。
sklearn 实现了 KFold 算法,示例代码如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | def kfold_estimate(k=10):
from sklearn.model_selection import KFold
images, labels, y_test, y_labels = dbload.load_mnist_vector(count=500, test=100)
scores_train = []
scores_validate = []
scores_test = []
cv = KFold(n_splits=k, random_state=1)
for train, test in cv.split(images, labels):
X_images, X_labels = images[train], labels[train]
y = np.zeros((X_labels.shape[0], 10))
for i, j in enumerate(X_labels):
y[i,j] = 1
nn = NN([X_images.shape[1], 100, 10], eta=1, epochs=10000, tol=1e-2)
nn.fit_mbgd(X_images, y, costtype='llh', batchn=64)
# 分别在训练集,交叉验证集和测试集上验证第 k 次的得分
score = heldout_score(nn, X_images, X_labels)
test_entries = X_labels.shape[0]
print("Accuracy rate {:.02f}% on trainset {}".format(
score, test_entries), flush=True)
scores_train.append(score)
score = heldout_score(nn, images[test], labels[test])
test_entries = labels[test].shape[0]
print("Accuracy rate {:.02f}% on vcset {}".format(
score, test_entries), flush=True)
scores_validate.append(score)
score = heldout_score(nn, y_test, y_labels)
test_entries = y_test.shape[0]
print("Accuracy rate {:.02f}% on testset {}".format(
score, test_entries), flush=True)
scores_test.append(score)
print(scores_train)
print(scores_validate)
print(scores_test)
|
如果测试样本的分类是不均衡的,就应该使用分层 K 折交叉验证,它对标准 K 折交叉验证做了稍许改进,可以获得偏差和方差都较低的评估结果,特别是类别比例相差较大时。在分层交叉验证中,类别比例在每个分块中得以保持,这使得每个分块中的类别比例与训练数据集的整体比例一致。对应的实现为 sklearn 中的 StratifiedKFold 类:
0 1 2 | # 初始化分层 K 折交叉验证类对象
from sklearn.model_selection import StratifiedKFold
cv = StratifiedKFold(n_splits=k, random_state=1)
|
实际上 MNIST 训练数据集的分类就不是均分的:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | images, labels, y_test, y_labels = dbload.load_mnist_vector(count=40000, test=10000)
unique = np.unique(labels)
for i in unique:
print(i, ':\t', np.sum(labels==i))
>>>
0 : 3924
1 : 4563
2 : 3943
3 : 4081
4 : 3909
5 : 3604
6 : 3975
7 : 4125
8 : 3860
9 : 4016
|
K 折交叉验证的一个特例是留一(Leave-one-out,LOO-CV)交叉验证法。如果设原始数据有 N 个样本,那么LOO-CV 就是 N-CV,即每个样本单独作为验证集,其余的 N-1 个样本作为训练集,所以 LOO-CV 会得到 N 个学习模型,用这 N 个模型最终的验证集的分类准确率的平均数作为此 LOO-CV 分类器的性能指标。相比于前面的 K-CV,LOO-CV有两个明显的优点:
- 每一回合中几乎所有的样本皆用于训练模型,因此最接近原始样本的分布,这样评估所得的结果比较可靠。
- 实验过程中没有随机因素会影响实验数据,确保实验过程是可以被复制的。
但 LOO-CV 的缺点是计算成本高,需要建立的模型数量与原始数据样本数量相同,当原始数据样本数量相当多时,LOO-CV 在实作上便有困难,除非每次训练分类器得到模型的速度很快,或是可以用并行化计算减少计算所需的时间。
使用不同的分类模型进行 K 折交叉验证,如果平均得分比较高,方差比较低,那么这个模型的泛化能力就较强,且性能稳定。
梯度检验¶
神经网络是一个较为复杂的模型,当使用梯度下降算法时,可能存在一些不易察觉的错误,这意味着,虽然代价看上去在不断减小,但最终的结果可能并不是最优解。为了避免这样的问题,我们采取一种叫做数值梯度检验(Numerical Gradient Checking)的方法来发现问题。
这种方法的思想是通过估计梯度值来检验计算的导数值是正确。对梯度的估计采用在代价函数上沿着切线的方向选择离两个非常近的点然后计算两个点的平均值用以估计梯度。只针对一个权重参数的检验公式为:
然后可以设定所有权重均为值 1,然后使用上式分别计算出所有的权重值然后和反向传播计算出的偏导值比较,如果偏差很大则说明代价函数或者反向传播算法实现有问题。当然也可以多设定几组初始值以进行多重验证。
这里需要对反向传播函数做一些调整,例如返回调整的 delta 值:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | def mbatch_backprop(self, X, y, type='llh'):
delta_b = [np.zeros(b.shape) for b in self.biases]
delta_w = [np.zeros(w.shape) for w in self.weights]
......
# backpropagation
samples = X.shape[0]
if type == 'llh':
delta = (acts[-1] - y.T)
else:
delta = (acts[-1] - y.T) * self.sigmoid_derivative(zs[-1])
delta_b[-1] = np.sum(delta, axis=1, keepdims=True)
delta_w[-1] = np.dot(delta, acts[-2].transpose())
for l in range(2, self.num_layers):
sp = self.sigmoid_derivative(zs[-l])
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
delta_b[-l] = np.sum(delta, axis=1, keepdims=True)
delta_w[-l] = np.dot(delta, acts[-l-1].transpose())
......
# 返回 delta 值
for i in range(len(delta_b)):
delta_b[i] /= samples
for i in range(len(delta_w)):
delta_w[i] /= samples
return delta_b, delta_w
# MSE 代价函数
def quadratic_cost(self, X, y):
return np.sum((self.feedforward(X) - y.T)**2) / y.shape[0] / 2
# 对数最大似然代价函数
def loglikelihood_cost(self, X, y):
output = self.feedforward(X)
diff = 1.0 - output
diff[diff <= 0] = 1e-15
return np.sum(-y.T * np.log(output) - ((1 - y.T) * np.log(diff))) / y.shape[0]
|
gd_checking 函数用于梯度校验,整个流程都是很清晰的,首先通过以上公式来评估偏导数,然后通过反向传播计算偏导数,计算差值来观察算法是否正确。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | # 校验函数,costtype 指定代价函数,llh 表示最大似然函数
def gd_checking(self, X, y, costtype='llh'):
# init all weights as 1
self.biases = [np.ones((l, 1)) for l in self.sizes[1:]]
self.weights = [np.ones((l, x)) for x, l in zip(self.sizes[:-1], self.sizes[1:])]
epsilon = 1e-4
costfunc = self.quadratic_cost
if costtype == 'llh':
costfunc = self.loglikelihood_cost
# 使用公式计算偏导数
partial_biases = [np.zeros((l, 1)) for l in self.sizes[1:]]
for i in range(len(self.biases)):
for j in range(self.biases[i].size):
self.biases[i][j] += epsilon
plus = costfunc(X,y)
self.biases[i][j] -= 2*epsilon
minus = costfunc(X,y)
self.biases[i][j] += epsilon
partial_biases[i][j] = ((plus - minus)/ 2 / epsilon)
partial_weights = [np.zeros((l, x)) for x, l in zip(self.sizes[:-1], self.sizes[1:])]
for i in range(len(self.weights)):
for j in range(self.weights[i].shape[0]):
for k in range(self.weights[i].shape[1]):
self.weights[i][j,k] += epsilon
plus = costfunc(X,y)
self.weights[i][j,k] -= 2*epsilon
minus = costfunc(X,y)
self.weights[i][j,k] += epsilon
partial_weights[i][j,k] = ((plus - minus)/ 2 / epsilon)
# 使用后向传播计算偏导数
delta_b, delta_w = self.mbatch_backprop(X,y, type=costtype, total=X.shape[0])
# 计算差值,应该很小
diff_bs = [b - nb for b, nb in zip(partial_biases, delta_b)]
diff_ws = [w - nw for w, nw in zip(partial_weights, delta_w)]
print(diff_bs)
print(diff_ws)
|
L2正则化¶
正则化可以有效防止模型的过拟合。神经网络同样可以使用正则化技术来缓解过度拟合。正则化的交叉熵代价函数公式:
其中的 λ > 0,被称为正则化参数,值越大,正则化对权重的收缩越强,规范化项不包含偏置项。
同样对于 MSE 形式的二次代价函数,正则化形式如下:
在线性回归中已经知道 λ 参数大小决定了权重系数的收缩程度。规范化让神经网络倾向于寻找较小的权重来最小化代价函数。λ 越小,就倾向于原始代价函数,反之,倾向于小的权重。
显然以上两公式增加的正则化项是相同的,所以对于偏导数来说增加项也是相同的:
所以可以得到:
我们增加 alpha 表示正则化参数,并更新初始化函数,梯度下降函数和代价函数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | def __init__(self, sizes, eta=0.001, epochs=1000, tol=None, alpha=0):
......
self.alpha = alpha # for regulization
......
def mbatch_backprop(self, X, y, type='llh', total=1):
......
# 只需调整 weights 项,正则化不影响偏置项,total 表示所有样本数
self.weights = [(1-self.eta*self.alpha/total)*w - self.eta * nw
for w, nw in zip(self.weights, delta_w)]
......
# 增加正则化项的代价计算函数
def regulization_cost(self, X, y):
return 0.5 * (self.alpha / y.shape[0]) * \
sum(np.linalg.norm(w)**2 for w in self.weights)
def quadratic_cost(self, X, y):
cost = np.sum((self.feedforward(X) - y.T)**2) / y.shape[0] / 2
if self.alpha:
cost += self.regulization_cost(X,y)
return cost
def loglikelihood_cost(self, X, y):
output = self.feedforward(X)
diff = 1.0 - output
diff[diff <= 0] = 1e-15
cost = np.sum(-y.T * np.log(output) - ((1 - y.T) * np.log(diff))) / y.shape[0]
if self.alpha:
cost += self.regulization_cost(X,y)
return cost
|
更新测试函数,使用以下参数进行模型训练,经过多次尝试,发现 alpha 取 15 效果较好。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def MNISTTrain():
import crossvalid
images, labels, y_test, y_labels = dbload.load_mnist_vector(count=40000, test=10000)
X_images, validate, X_labels, validate_labels = \
crossvalid.data_split(images, labels, ratio=0.1, random_state=None)
y = np.zeros((X_labels.shape[0], 10))
for i, j in enumerate(X_labels):
y[i,j] = 1
nn = NN([X_images.shape[1], 100, 10], eta=1, epochs=100000, tol=1e-6, alpha=15)
nn.fit_mbgd(X_images, y, costtype='llh', batchn=256, x_labels=X_labels,
y_test=validate, y_labels=validate_labels)
pred = nn.predict(y_test)
error = pred - y_labels
error_entries = np.count_nonzero(error != 0)
test_entries = y_test.shape[0]
print("Accuracy rate {:.02f}% on trainset {}".format(
(test_entries - error_entries) / test_entries * 100,
test_entries), flush=True)
MNISTTrain()
>>>
Accuracy rate 98.26% on trainset 36000
Accuracy rate 96.12% on testset 4000
Accuracy rate 96.50% on trainset 10000
|
显然对比正则化前的 94.60%,在测试集上正则化后提升了 1.9 个百分点,效果是非常明显的。此时的训练集并没有达到 99% 以上的准确率,却得到了更好的泛化效果。
为了寻找比较合适的 alpha 参数,可以尝试绘制 alpha 和测试集准确率的关系曲线图。
实践证明,由于正则化项的存在,形成对权重大小的钳制,所以在使用不同随机权重值时,效果更加稳定,很容易复现出同样的结果。如果代价函数未规范化,那么某些权重度可能不停增加,有些会不停减小,随着时间的推移,这会导致大权重的调整比例越来越小,从而下降越来越慢。
梯度消失问题¶
目前为止我们只使用了单个隐藏层的神经网络。如果我们尝试增加层数,出乎意料的是并没有因为层数的增加而带来明显的分类正确率的提高。
我们已经指出交叉熵函数可以缓解最后一层权重调整缓慢的问题,但是分析整个链式求导法则,可以发现每前一层的调整值都在不停减小,也即越靠近输入层,权重调整值越小。
上图是在使用[2,2,1]神经网路拟合 XOR 数据时,记录了迭代 100 次时第 2 层和第 3 层偏置项的调整大小,显然第 3 层的调整量整体是第 2 层 3-4 倍。这不是偶然的,因为链式法则使得前一层总要在后一层的基础上乘以 sigmoid 关于 z 的偏导数,显然该值最大只有 0.25,这和我们的观察倍数相符。各层权重项的调整值和偏置项具有相同规律,并且由于输入部分最大只能接近 1 所以调整更小。
这个现象被称作是梯度消失问题(vanishing gradient problem)。可以想象如果链式中的 w 项大于 4,那么 w 可以中和导数带来的缩小,但是这又会导致梯度激增问题(exploding gradient problem)。实际上遇到梯度消失问题要远远大于梯度激增,因为过大的 w 会让加权和 z 比较大,从而导致偏导数很小,以至于逼近0,所以乘积就会小于 1。
一种尝试解决梯度消失的办法是给与不同层以不同的学习率,前层总比后一层学习率大一些,比如大 2-4 倍,但是当层数比较多时,相当于增加了非常多的超参数,所以通常由程序自动适配学习率。实际上多层神经网络的学习率不能设置得太大,且下降函数具有明显阶梯特征:
另外一种方法是中间层的激活函数使用 ReLU (修正线性单元),也即 f(z)=max(0,z),显然它的导数只有 0 和 1:
- 提高 ReLU 的带权输入不会导致其饱和,所以就不存在梯度下降问题。
- ReLU 的计算比 sigmoid 激活函数计算简单,没有指数运算,程序执行更快。
- 但是,当带权输入是负数时,梯度就消失了,神经元就完全停止了学习。此时可以使用改良版的 ELU 或者 LReLU 来代替 ReLU。
- 由于图片像素值均是大于 0 的,所以在加权之后的和也是倾向于大于 0 的,此时使用 ReLU 负数梯度消失问题对图片分类影响就不大了,在使用 ReLU 时可以进行归一化,但是不要进行标准化。
ReLU 在 0 点不可导,意味着它在0点有无数条切线,而梯度下降是沿着切线反方向进行的,所以只要从 [0, 1] 取值均是可以的,通常取 0 值。
尽管 ReLU 在图片分类上有长足之处,但是理论上还没有一个关于什么时候什么原因导致 ReLU 表现更好的深度的理解。以上两幅图均是对加强版的异或问题进行分类,采用同样的单隐藏层 10 节点神经网络模型。从直观上看它们的边界一个趋向于圆弧形,一个趋向于折线形,从直觉上也可以感受到为何会有如此形状:尽管我们使用层叠方式通过线性和非线性叠加了无数的 sigmoid 和 ReLU 变换,它们在最终的输出上总是会反映出神经网络变换函数的本质,否则变换代价函数将不能带来任何预测性能的改变。
显然我们不能武断地说明哪一种模型更好,这显然和数据的分布有关。现实世界中抽屉,蜂巢似乎更倾向于使用折线形状,也许这就是 ReLU 在虚拟的高维空间对二进制数据隔离更好的原因,毕竟二进制数据也是对现实世界的反映, 当然这种基于假想的推测确实牵强。实际中还是要进行效果对比才能针对目标任务做出更好的代价函数选择。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def sklearn_nn_test():
from sklearn.neural_network import MLPClassifier
images, labels, y_test, y_labels = dbload.load_mnist_vector(count=40000, test=10000)
mlp = MLPClassifier(hidden_layer_sizes=(100,), max_iter=10000, activation='relu',
solver='sgd', early_stopping=False, verbose=1, tol=1e-6, shuffle=True,
learning_rate_init=0.01, alpha=0.5)
mlp.fit(images, labels)
print("Training set score: %f" % mlp.score(images, labels))
print("Test set score: %f" % mlp.score(y_test, y_labels))
sklearn_nn_test()
>>>
Training set score: 0.990325
Test set score: 0.974300
|
使用 sklearn 中的 MLPClassifier 模型,激活函数更改为 relu 方式,在 MNIST 的测试数据集上取得了 97.43% 的成绩。
基于Pipline的工作流¶
通常机器学习有着一样的流程:数据处理(数据转换,标准化等),模型拟合,模型评估,参数调优等步骤。scikit-learn 中的 Pipline 类可以创建包含任意多个处理步骤的模型,并将模型用于新数据的预测。
Pipeline对象采用元组的序列作为输入,其中每个元组中的第一个值为一个字符串,它可以是任意的标识符,我们通过它来访问管线中的元素,而元组的第二个值则为scikit-learn中的一个转换器(transforms,需要实现 fit 和 transform 方法)或者评估器(estimator,只需实现 fit 方法),管线中可以安排多种处理工序,最后一道工序通常是评估器。
0 1 2 3 4 5 6 7 8 9 10 11 12 | from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import Pipeline
pipe = Pipeline([('scl', StandardScaler()),
('lr', LinearRegression(fit_intercept=True))])
X = np.linspace(1,11,10).reshape(10,1)
y = X * 2 + 1
pipe.fit(X, y)
print(pipe.predict([[1],[2]]))
>>>
[[ 3.]
[ 5.]]
|
以上示例,首先创建一条管线,管线元组序列包含数据标准化部分和回归拟合部分,通过管线对象 pipe 可以直接进行拟合和预测。
学习曲线和验证曲线¶
通常使用代价函数的下降曲线来评估学习率的设定,太大不容易收敛,太小下降速度太慢。当选定较好的学习率后,通常测试不同数量级的正则化参数,选定理想的正则化参数后,再反过来调整学习率。
可以通过观察校验集的得分来决定迭代是否停止,然而实际上对于深层网络来说,可能会出现梯度暂停下降现象,此时的得分也不再上升,所以武断地选择停止可能不是一个好主意。
是否有统一的一套流程来针对各类超参数进行筛选,并对模型性能进行评估呢。一个强大的工具就是学习曲线和验证曲线:
- 学习曲线用来判定学习是否过拟合(高方差,High variance)或欠拟合(高偏差,High bias)。
- 验证曲线,可以帮助寻找学习算法中的各类问题点。
通常一个模型的构造越复杂(比如神经元节点数,层数;多项式的次数),参数越多那么这个模型的容量就越大,学习能力就越强,就像一个水库,水库越深,面积越大那么它的容积也就越大。显然对于复杂模型,在有限的训练数据集下,很容易出现过拟合,也即可以百分百地对训练集数据正确分类,但是对于未知数据的泛化能力却很差。通常情况下,收集更多的训练样本有助于降低模型的过拟合程度。但是收集更多数据成本高昂,或者根本不可行(理由有些罕见疾病的数据很难收集)。
通过将模型的训练及准确性验证看作是训练数据集大小的函数,并绘制图像,可以很容易得出模型是面临高方差还是高偏差的问题,以及收集更多的数据是否有助于解决问题。
0 1 2 3 4 5 6 7 8 9 10 11 | def sklearn_mnist(ratio=1.0, hidden_neurons=10, alpha=0):
from sklearn.neural_network import MLPClassifier
images, labels, y_test, y_labels = dbload.load_mnist_vector(
count=int(40000 * ratio), test=int(10000 * ratio))
mlp = MLPClassifier(hidden_layer_sizes=(hidden_neurons,), max_iter=10000,
activation='relu',solver='adam', early_stopping=False,
verbose=1, tol=1e-6, shuffle=True,
learning_rate_init=0.01, alpha=alpha)
mlp.fit(images, labels)
return mlp.score(images, labels), mlp.score(y_test, y_labels)
|
sklearn_mnist 针对 MNIST 数据集进行训练,可以设定训练集和测试集的数据比例,hidden_neurons 则用于设置隐藏层的节点数,越大神经网络容量就越大,越容易过拟合。relu 和 adam (或 lbfgs,一种梯度下降加速算法,适用于小型数据集,adam 适用于更大型数据集)可以加速训练。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | def plot_learning_curve():
train_scores,test_scores,percents = [],[],[]
for i in np.linspace(1, 10, 50, endpoint=True):
train_score, test_score = sklearn_mnist(i * 0.1)
print("Training set score: %f" % train_score)
print("Test set score: %f" % test_score)
train_scores.append(train_score)
test_scores.append(test_score)
percents.append(i*0.1*40000)
plt.figure()
plt.title("Train and Test scores status")
plt.xlabel("Trainset samples")
plt.ylabel("Scores (%)")
plt.plot(percents, train_scores, label='Train Scores', c='black')
plt.plot(percents, test_scores, label='Test Scores', c='gray')
plt.scatter(percents, train_scores, c='black')
plt.scatter(percents, test_scores, c='gray')
# target horizontal line
plt.hlines(0.97, 0, 40000, alpha=0.5, linestyle='--')
plt.legend(loc='upper right')
plt.show()
|
上图是针对 MNIST 数据集的准确率曲线,分别在 40000 个训练集上按比例训练了 50 次,也即每次取训练集数分别为 1/50,2/50,测试集按同样比例选取。图中的水平虚线是假想的目标准确率,实际上目标准确率是未知的,只是建立在许多模型的多次实验中得出的,这里用来作为参考。可以发现:
- 训练集越小,在训练集上的得分越高,越容易过拟合,随着训练集的增大,由于单层 10 隐藏节点神经网络容量有限,能够学到的特征越来越少,导致训练集上的得分越来越低。
- 当训练集很小时,模型没有学习到足够的特征,导致测试集得分一开始很低,随着训练数据集的增大,网络学到了更多特征,测试集得分慢慢上升,直至训练数据集扩充到所有训练数据,此时模型的训练准确率和测试集的准确率都很低,这表明此模型未能很好地拟合数据。
我们尝试将隐藏神经节点从 10 个扩充到 100 个,此时模型不再欠拟合,而是更容易过拟合。
观察上图,当训练集非常大时,依然可以达到 98% 左右的准确率,而测试集只能徘徊在 95% 左右而不再上升,训练集准确率和测试集准确率有着一段差距,进入了过拟合状态。
此时将 alpha 调整为 0.1,也即增加正则化项,以防止过拟合,得到下图,此时两个数据集的得分基本相当:
通过上图学习曲线可见,模型在测试数据集上表现良好,在训练准确率曲线与交叉验证准确率之间,存在着相对较小差距,模型对训练数据有些微过拟合,可以继续微调 alpha 值来解决。实际上对于 MNIST 数据集在最终阶段可以看到测试集准确率还在不停上升,所以扩充数据集是一种明显提升准确率的可行方式。
我们可以将防止欠拟合和过拟合的方法总结如下:
- 欠拟合:判断标准:训练集得分较低;增加模型复杂度,降低正则化参数,减少训练集数据(过少的数据集不能正确反映整体数据分布特征),根据测试集得分进行早停。
- 过拟合:判断标准:训练集得分很高,测试集得分低;降低模型复杂度,增加正则化参数,特征选择,增加数据,根据测试集得分进行早停。
验证曲线是一种通过定位过拟合或欠拟合来帮助寻找提高模型性能的参数的方法。验证曲线与学习曲线相似,不过绘制的不是样本大小与训练准确率、测试准确率之间的函数关系,而是准确率与模型参数之间的关系,比如正则化参数 alpha ,神经网络隐藏节点数等与准确率的关系。
这里尝试绘制准确率与隐藏神经元个数的验证曲线,显然我们不应该想当然地来设置隐藏神经元就是 100,而是要根据目标数据集进行验证:
显然大约 40 个神经元就已经使得训练集达到了过拟合状态,更复杂的神经网络除了增加运算量之外并不能提高准确率。
通过 Pipeline 类结合 scikit-learn 中的学习曲线评估模块 learning_curve 可以很方便地绘制各类学习曲线,这样就无需对不同任务编写大量重复代码:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn import datasets
def sklearn_learning_curve():
from sklearn.model_selection import learning_curve
# 创建神经网络模型
mlp = MLPClassifier(hidden_layer_sizes=(3,), max_iter=10000, activation='relu',
solver='lbfgs', early_stopping=False, verbose=1, tol=1e-6,
shuffle=True, learning_rate_init=0.001, alpha=0)
# 加载IRIR数据集
pipelr = Pipeline([('scl', StandardScaler()),
('clf', mlp)])
iris = datasets.load_iris()
X_train = iris.data
y_train = iris.target
# 获取交叉验证数据
train_sizes, train_scores, valid_scores = \
learning_curve(estimator=pipelr, X=X_train,y=y_train,
train_sizes=np.linspace(0.1, 1, 10, endpoint=True),
cv=5, n_jobs=8)
# 计算交叉验证的平均得分
train_mean = np.mean(train_scores * 100, axis=1)
train_std = np.std(train_scores * 100, axis=1)
valid_mean = np.mean(valid_scores * 100, axis=1)
valid_std = np.std(valid_scores * 100, axis=1)
# 绘制曲线图,使用标准差以颜色标记结果的稳定性
plt.title('IRIS Data Learning Curve')
plt.plot(train_sizes, train_mean, color='black', marker='o', label='Train Scores')
plt.fill_between(train_sizes, train_mean + train_std, train_mean - train_std,
alpha=0.2, color='black')
plt.plot(train_sizes, valid_mean, color='purple', marker='s', label='Validation Scores')
plt.fill_between(train_sizes, valid_mean + valid_std, valid_mean - train_std,
alpha=0.2, color='purple')
plt.xlabel('Trainset samples')
plt.ylabel('Scores')
plt.legend(loc='lower right')
plt.grid()
plt.show()
|
这里使用 IRIS 数据训练隐藏层节点为 3 个的神经网络,由于分类只有 3 中,且数据量不大,采用 3 个隐层节点的神经网络已经足够,这里使用 lbfgs 加速小批量数据的梯度下降,cv=5 表示 5 折(Stratified)交叉验证,最终得到如下学习曲线:
实际上通过交叉验证,验证集上准确率已经达到了 98%,即便使用 2 个隐层节点的神经网络依然可以达到如此准确度,可见神经网络的学习能力非常强。
sklearn 同样提供了交叉验证曲线的绘制模块,位于 model_selection 中的 validation_curve,注意点在于参数名称的指定,管线分类器名称加两个下划线再加参数名:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | def sklearn_validation_curve():
from sklearn.model_selection import validation_curve
mlp = MLPClassifier(hidden_layer_sizes=(3,), max_iter=10000, activation='relu',
solver='lbfgs', early_stopping=False, verbose=1,
tol=1e-6, shuffle=True,
learning_rate_init=0.001)
pipelr = Pipeline([('scl', StandardScaler()),
('clf', mlp)])
iris = datasets.load_iris()
X_train = iris.data
y_train = iris.target
# 交叉验证的正则化参数
param_range = (0, 0.001, 0.01, 0.1, 1.0, 10)
train_scores, valid_scores = \
validation_curve(estimator=pipelr, X=X_train,y=y_train,
param_name='clf__alpha', param_range = param_range,
cv=5, n_jobs=8)
train_mean = np.mean(train_scores * 100, axis=1)
train_std = np.std(train_scores * 100, axis=1)
valid_mean = np.mean(valid_scores * 100, axis=1)
valid_std = np.std(valid_scores * 100, axis=1)
plt.title('IRIS Data Validation Curve')
plt.plot(param_range, train_mean, color='black', marker='o', label='Train Scores')
plt.fill_between(param_range, train_mean + train_std, train_mean - train_std,
alpha=0.2, color='black')
plt.plot(param_range, valid_mean, color='purple', marker='s', label='Validation Scores')
plt.fill_between(param_range, valid_mean + valid_std, valid_mean - train_std,
alpha=0.15, color='purple')
plt.xlabel('Regulization Parameter \'alpha\'')
plt.ylabel('Scores')
plt.legend(loc='lower right')
plt.grid()
plt.ylim([80,100])
plt.xscale('log')
plt.show()
|
图中可以看出增加正则化参数并不能显著提高校验集分数,并且随着正则化参数的增大,模型趋向于欠拟合,所以这里无需使用正则化参数。
使用 learning_curve 和 validation_curve 除了带来编码方便之外,它自动实现了多进程的支持,可以使用 n_jobs 来进行多核环境的运算提速,n_jobs = -1表示使用所有核加速。
网格搜索和随机搜索¶
learning_curve 把不同的比例的训练集作为变量,validation_curve 把模型中的一个参数作为变量。显然一个模型有非常多的超参数需要调整,比如 MPL 模型中的激活函数,正则化参数 alpha,tol 等等。如果要对每个参数均使用以便 validation_curve 绘图将非常繁琐。
网格搜索(grid search)是功能强大的超参数搜索方式,它通过寻找最优的超参值的组合以获得性能模型。网格搜索思想非常简单,对每种参数均与其他参数组合进行暴力穷举搜索(Brute force parameter search),显然这种计算量是巨大的,适应于小型数据集,或者对大型数据集进行抽样处理,以获得一个大概的参数范围和调优方向。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | def sklearn_grid_search():
from sklearn.grid_search import GridSearchCV
mlp = MLPClassifier(hidden_layer_sizes=(3,), max_iter=10000, activation='relu',
solver='lbfgs', early_stopping=False, verbose=1,
tol=1e-6, shuffle=True,
learning_rate_init=0.001)
pipelr = Pipeline([('scl', StandardScaler()),
('clf', mlp)])
iris = datasets.load_iris()
X_train = iris.data
X_labels = iris.target
# 构造参数网格
alpha_range = (0, 0.001, 0.01, 0.1, 1.0, 10)
tol_range = (1e-3, 1e-4, 1e-5, 1e-6, 1e-7)
act_range = ('relu', 'logistic')
solver_range = ('lbfgs', 'sgd', 'adam')
param_grid = {'clf__alpha': alpha_range,
'clf__tol': tol_range,
'clf__activation': act_range,
'clf__solver': solver_range}
gsclf = GridSearchCV(estimator=pipelr,
param_grid=param_grid,
scoring='accuracy',
cv=5,
n_jobs=-1)
gsclf.fit(X_train, X_labels)
# 打印最优得分和最优参数
print(gsclf.best_score_)
print(gsclf.best_params_)
# 使用最优模型对测试集进行评估
bestclf = gsclf.best_estimator_
bestclf.fit(X_train, X_labels)
return bestclf
sklearn_grid_search()
>>>
0.98
{'clf__activation': 'relu', 'clf__alpha': 0.001, 'clf__solver': 'lbfgs', 'clf__tol': 0.001}
|
由于网格搜索进行穷举暴力搜索,所以计算成本相当昂贵,另一种轻量级的方法就是随机搜索(randomized search),sklearn 提供了 RandomizedSearchCV 类,使用它可以以特定的代价从抽样分布中抽取出随机的参数组合。与网格搜索类似,通过字典指定参数搜索空间,这一空间可以是连续的:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | def sklearn_random_search():
from time import time
from scipy.stats import randint as sp_randint
from sklearn.model_selection import RandomizedSearchCV
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier
# get some data
digits = load_digits()
X, y = digits.data, digits.target
# build a classifier
clf = RandomForestClassifier(n_estimators=20)
# Utility function to report best scores
def report(results, n_top=3):
for i in range(1, n_top + 1):
candidates = np.flatnonzero(results['rank_test_score'] == i)
for candidate in candidates:
print("Model with rank: {0}".format(i))
print("Mean validation score: {0:.3f} (std: {1:.3f})".format(
results['mean_test_score'][candidate],
results['std_test_score'][candidate]))
print("Parameters: {0}".format(results['params'][candidate]))
print("")
# specify parameters and distributions to sample from
param_dist = {"max_depth": [3, None],
"max_features": sp_randint(1, 11),
"min_samples_split": sp_randint(2, 11),
"bootstrap": [True, False],
"criterion": ["gini", "entropy"]}
# run randomized search
n_iter_search = 20
random_search = RandomizedSearchCV(clf, param_distributions=param_dist,
n_iter=n_iter_search, cv=5)
start = time()
random_search.fit(X, y)
print("RandomizedSearchCV took %.2f seconds for %d candidates"
" parameter settings." % ((time() - start), n_iter_search))
report(random_search.cv_results_)
|
以上示例来自 sklearn 的官网,sp_randint 用于生成随机均匀离散分布数据,n_iter 是一个比较重要的参数,选择随机测试参数组合的数量,越大耗时越久,但是尝试的参数越多,效果越好。连续空间需要 scipy 概率统计模块的支持,详细参考 scipy.stats 模块的使用,例如连续指数分布:scipy.stats.expon(scale=100) 可以用于正则化参数的搜索。
卷积神经网络¶
从最朴素的 KNN 到非常复杂的多层神经网络,对于图像输入均被拆解为了单个的像素,组成一维的向量,相似的图形在高维空间聚集在一起(向量距离较小),KNN 通过 K 邻近投票的方式来判别输入的数字,而神经网络则使用激活函数在高维度变换成一个一个格子将这些高维空间的点(向量终点)区分开来,但是图像是对 3D 世界的 2D 映射,显然再把 2D 数据拆解成一维向量数据,必定会丢失像素行间信息,这和人脑对图像的识别是有本质区别的。
最直观的感受就是当一张图片在进行角度旋转时,它在高维空间的向量终点将会有非常大的移动,很容易被误判,而然人脑不会因为旋转而无法识别数字,甚至对识别率没有任何影响。为什么?人脑考虑了图片的空间结构,而不单单是一维的像素点。本质上讲 2D 图片的空间结构构成了它要表达的图像内容,是图像内容的基本反映。
实际上,一幅图片的大部分像素对人来讲会被人脑直接过滤掉,而只留下最本质的东西,而KNN和全连接的神经网络它以同等方式处理距离很远和临近的像素值,这样的整个空间结构必须从训练数据中通过强力计算进行推断。
卷积神经网络(Convolutional Neural Network,CNN 或 ConvNets)在图像识别中的表现优异,它通过局部感受野(local receptive fields),共享权重(shared weights),和池化(pooling,也被称为混合或汇聚)技术来提取图像的空间特性,在大大降低计算量的基础上表现出了令人印象深刻的识别能力。CNN 与 NN 的区别在于它不是直接把像素向量作为输入,而是提前对图像做一些特征提取处理,然后再把特征值作为 NN 的输入,所以其名字在 NN 前加上 C(Convolutional)是名副其实的。
卷积神经网络的基础在于卷积(convolution)处理,我们从卷积操作入手认识 CNN 的本质特性(从最浅的地方下水,总不会是太坏的选择!)。
认识卷积¶
图像卷积(Convolution)可以提取图像更多特征。卷积的本质就是大矩阵和小矩阵的元素对应乘法运算(不是点积)结果相加,使用结果值替换卷积核坐标元素值,并且滑动小矩阵遍历大矩阵所有可被卷积的元素。
如上图所示,卷积的运算步骤如下:
- 定义一个卷积核(小矩阵),上图中的红色窗口就是一个 3*3 的卷积核,之所以使用方阵是便于快速计算(底层库进行方阵运算速度更快)
- 从左上角开始(x=0,y=0)将卷积核对齐图片(大矩阵,这里认为是一张灰度图或者一张图片彩色图的单个颜色通道),卷积核矩阵元素与重合的对应元素相乘,乘积相加,结果替换卷积核中间的位置(这就是为何卷积核大小选取奇数的原因)的元素。注意替换是在图片矩阵的副本中进行。
- 每计算一次,向右滑动一次卷积核(红色窗口),直到卷积核对齐到最右侧,然后回到最左侧向下滑动一个像素
- 直至计算结束,可以看到蓝色窗口中的所有元素都被替换(卷积)了
对于蓝色窗口外部的元素,显然没有办法进行卷积运算,只能够根据需要人为设定,比如保持不变,或者清0(这基于边缘像素对物体识别影响很小的假设),或者采用艺术化处理。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | # convolute.py
def convolution_ignore_border(img, kernel):
from skimage.exposure import rescale_intensity
yksize, xksize = kernel.shape
# kernel must with odd size
if yksize % 2 == 0 or xksize % 2 == 0:
print("kernel must with odd size")
return None
y_slide_count = img.shape[0] - kernel.shape[0]
x_slide_count = img.shape[1] - kernel.shape[1]
if x_slide_count < 0 or y_slide_count < 0:
print("img size too small to do convolution")
return None
newimg = img.copy().astype(np.float64)
# sliding kernel along y(right) and x(down) from left-top corner
centery, centerx = yksize >> 1, xksize >> 1
for y in range(0,y_slide_count+1):
for x in range(0,x_slide_count+1):
sum = (img[y:y+yksize,x:x+xksize] * kernel).sum()
# round reducing truncation error float64 -> uint8
newimg[y+centery, x+centerx] = round(sum)
# rescale the output image in range [0, 255]
newimg = rescale_intensity(newimg, in_range=(0, 255))
return (newimg * 255).astype(np.uint8)
np.random.seed(0)
matrix = np.random.randint(0, 256, size=(8, 8), dtype=np.uint8)
kernel = np.ones((3, 3)) * 1.0 / 9
newimg = convolution_ignore_border(matrix, kernel)
|
下图中左侧马赛克图片就是上面的数字矩阵对应的灰度图像,这里使用 3*3 的平均模糊卷积核,经过卷积处理之后,图像明显变模糊了。但是由于我们没有对边缘像素进行任何处理,所以边缘显得非常突兀,一个可行的办法是对原图像边缘进行扩展处理,然后再进行卷积。
OpenCV 中的 copyMakeBorder 函数用来对边界进行插值(borderInterpolate)处理。使用copyMakeBorder将原图稍微放大,就可以处理边界的情况了。扩充边缘的插值处理有多种方式:
- BORDER_REPLICATE,复制边界值填充,形如:aaaaaa|abcdefgh|hhhhhhh,OpenCV 中的中值滤波medianBlur采用的边界处理方式。
- BORDER_REFLECT,对称填充,形如:fedcba|abcdefgh|hgfedcb
- BORDER_REFLECT_101,对称填充,以最边缘像素为轴,形如:gfedcb|abcdefgh|gfedcba,这种方式也是OpenCV边界处理的默认方式(BORDER_DEFAULT=BORDER_REFLECT_101)也是filter2D, blur, GaussianBlur, bilateralFilter 的默认处理方式,这种方式在边界处理中应用是最广泛的。
- BORDER_WRAP,对边镜像填充,形如:cdefgh|abcdefgh|abcdefg
- BORDER_CONSTANT,以一个常量像素值(由参数 value给定)填充扩充的边界值,这种方式多用在仿射变换,透视变换中。
在卷积神经网络(Convolutional Neural Networks)中通常采用清 0 处理,也即使用 BORDER_CONSTANT 方式,value 设置为 0。
支持边框扩展的卷积操作实现:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def convolution(img, kernel):
from skimage.exposure import rescale_intensity
yksize, xksize = kernel.shape
# kernel must with odd size
if yksize % 2 == 0 or xksize % 2 == 0:
print("kernel must with odd size")
return None
newimg = img.copy().astype(np.float64)
y_slide_count,x_slide_count = img.shape
left_right = (xksize - 1) // 2
top_bottom = (yksize - 1) // 2
img = cv2.copyMakeBorder(img, top_bottom, top_bottom,
left_right, left_right, cv2.BORDER_REFLECT_101)
# sliding kernel along y(right) and x(down) from left-top corner
for y in range(0,y_slide_count):
for x in range(0,x_slide_count):
sum = (img[y:y+yksize,x:x+xksize] * kernel).sum()
# round reducing truncation error float64 -> uint8
newimg[y, x] = round(sum)
# rescale the output image in range [0, 255]
newimg = rescale_intensity(newimg, in_range=(0, 255))
return (newimg * 255).astype(np.uint8)
|
为了验证程序的正确性,这里与 OpenCV 实现的 filter2D 进行卷积的结果进行对比:
0 1 2 3 4 5 6 7 8 9 10 | def verify_convolution(size=8):
np.random.seed(0)
matrix = np.random.randint(0, 256, size=(size, size), dtype=np.uint8)
kernel = np.ones((3, 3)) * 1.0 / 9
newimg = convolution(matrix, kernel)
print(np.all(newimg == cv2.filter2D(matrix, -1, kernel)))
verify_convolution()
>>>
True
|
实验证明我们的实现和 OpenCV 的实现结果完全相同,接下来我们使用不同的卷积核来检验图片的处理效果,并对比两种实现的性能差别。
图片卷积处理¶
首先定义一些常见的卷积核。例如均值模糊卷积核:
0 1 | smallblur = np.ones((7, 7), dtype=np.float64) * (1.0 / (7 * 7))
largeblur = np.ones((21, 21), dtype=np.float64) * (1.0 / (15 * 15))
|
锐化卷积核:
0 1 2 | sharpen = np.array(([0, -1, 0],
[-1, 5, -1],
[0, -1, 0]), dtype=np.int32)
|
用于边缘处理的拉普拉斯卷积核,Sobel 卷积核:
0 1 2 3 4 5 6 7 8 9 | laplacian = np.array(([0, 1, 0],
[1, -4, 1],
[0, 1, 0]), dtype=np.int32)
sobelX = np.array(([-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]), dtype=np.int32)
sobelY = np.array(([-1, -2, -1],
[0, 0, 0],
[1, 2, 1]), dtype=np.int32)
|
浮雕图案卷积核:
0 1 2 | emboss = np.array(([-2, -1, 0],
[-1, 1, 1],
[0, 1, 2]), dtype=np.int32)
|
尽管已经验证我们手动实现的卷积函数和 OpenCV 的 filter2D 函数结果相同,然而对比一下运算效率更有意义:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def convolute_speed_cmp(count=100, type=0):
image = cv2.imread(arg_get("image"))
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
kernel = np.ones((3, 3)) * 1.0 / 9
start = time.time()
if type == 0:
for i in range(0,count):
convolution(gray, kernel)
print("convolution cost walltime {:.02f}s with loop {}"\
.format(time.time()-start, count))
else:
for i in range(0,count):
cv2.filter2D(gray, -1, kernel)
print("filter2D cost walltime {:.02f}s with loop {}" \
.format(time.time()-start, count))
convolute_speed_cmp(10, 0)
convolute_speed_cmp(10000, 1)
|
验证结果对比惊人,为了节省计算时间,不得不分成两个分支,以进行不同的循环。测试图片 640 * 480 的分辨率,竟然达到了 4000 倍的性能差距,笔者当然不相信 OpenCV 能进行如此强劲的优化,很显然我们只是对同一幅图片进行循环处理,OpenCV 内部进行了类似缓存的处理,但是即便每次循环都采用不同的图像,效果依然有 2000 倍之差。
0 1 2 | $ python convolute.py --image imgs\Monroe.jpg
convolution cost walltime 21.74s with loop 10
filter2D cost walltime 5.42s with loop 10000
|
一个可行的优化方式是对卷积核进行扩展以代替 W*H(图片宽和高的像素数)次的窗口滑动,此时只需要向右向下滑动 kW 和 kH 次,这可以节约大量的循环处理时间。如果要深入理解 OpenCV 的效果为何如此强劲,就要从它的源码入手。
Fast 版本的卷积函数有些复杂,实现也要有些技巧,不过这种优化是值得的:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | def convolution_fast(img, kernel):
from skimage.exposure import rescale_intensity
yksize, xksize = kernel.shape
# kernel must with odd size
if yksize % 2 == 0 or xksize % 2 == 0:
print("kernel must with odd size")
return None
newimg = img.copy().astype(np.float64) * 0
# extend four borders to convolute border pixels
left_right = (xksize - 1) // 2
top_bottom = (yksize - 1) // 2
img = cv2.copyMakeBorder(img, top_bottom, top_bottom,
left_right, left_right,
cv2.BORDER_REFLECT_101)
# extend kernel as big as the img size, but no bigger than img
ytile = img.shape[0] // yksize
xtile = img.shape[1] // xksize
nkernel = np.tile(kernel, (ytile, xtile))
# sliding kernel along y(right) and x(down) from left-top corner
ynksize, xnksize = nkernel.shape
for y in range(0, yksize):
for x in range(0, xksize):
# use nkernel convolute img, so have a cross window
w_window = min([img.shape[0] - y, ynksize])
h_window = min([img.shape[1] - x, xnksize])
# resize the window round base kernel size
(ny, ry) = divmod(w_window, yksize)
(nx, rx) = divmod(h_window, xksize)
w_window -= ry
h_window -= rx
tmp = img[y:w_window+y, x:h_window+x] * nkernel[:w_window, :h_window]
tmp = tmp.reshape(ny, yksize, nx, xksize).sum(axis=(1, 3))
for i in range(tmp.shape[0]):
for j in range(tmp.shape[1]):
newimg[y + i * yksize, x + j * xksize] = round(tmp[i,j])
# rescale the output image in range [0, 255]
newimg = rescale_intensity(newimg, in_range=(0, 255))
return (newimg * 255).astype(np.uint8)
|
与此同时更新性能测试函数,type 为 0 和 2 时分别对应快速版本和普通版本。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def convolute_speed_cmp(image=None, count=100, type=0):
if image is None:
image = cv2.imread(arg_get("image"))
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image
kernel = np.ones((3, 3)) * 1.0 / 9
start = time.time()
if type == 0:
for i in range(0,count):
convolution_fast(gray + count, kernel)
print("convolution_fast cost walltime {:.02f}s with loop {}"\
.format(time.time()-start, count))
elif type == 1:
for i in range(0,count):
cv2.filter2D(gray + count, -1, kernel)
print("filter2D cost walltime {:.02f}s with loop {}"\
.format(time.time()-start, count))
else:
for i in range(0,count):
convolution(gray + count, kernel)
print("convolution cost walltime {:.02f}s with loop {}"\
.format(time.time()-start, count))
convolute_speed_cmp(None, 10, 0)
convolute_speed_cmp(None, 10, 2)
|
结果显示,快速版本比原函数提高了 4 倍左右的性能,但是距离 OpenCV 的优化版还是差了上千倍。这一结果令人异常深刻。如果直接采用底层语言,性能将会继续提升,采用分治法协同处理是加速的另一个选择。 显然 OpenCV 的代码实现一定经过大量的优化,读一读源码将受益匪浅。
0 1 2 | $ python convolute.py --image imgs\Monroe.jpg
convolution_fast cost walltime 5.95s with loop 10
convolution cost walltime 20.47s with loop 10
|
我们无需直接编写底层代码,而能一窥底层代码能带来的近似的优化性能,numba 就可以实现,只需要在优化函数前添加装饰器:
0 1 2 3 4 | from numba import jit
@jit
def convolution_fast(img, kernel):
......
|
效果很明显,性能大约提升了 13 倍:
0 1 2 3 4 5 | $ python convolute.py --image imgs\Monroe.jpg
# numba 优化版本
convolution cost walltime 3.10s with loop 10
# 维优化版本
convolution cost walltime 40.57s with loop 10
|
使用 numba 时提升性能时是有前提条件的,通常当代码中有很多大的 for 循环时,优化效果很好,如果是小循环,或者逻辑处理代码,则可能效果差强人意,这里之所以选择优化 convolute 而不是 convolute_fast 就是基于这种原因,实际上 convolute_fast 优化后的效果反而没有 convolute 效果好。
实践中的优化要基于目标环境通过各个方面(瓶颈分析,软件优化,硬件增强)进行优化,没有一刀切的黄金策略。
CNN 三要素¶
局部感受野¶
如果对卷积处理有了深刻认识,那么局部感受野实际上就是一个小矩阵(卷积核)的加权操作,通常把它作为名词解释,也即小矩阵遮住的图像的局部区域。笔者更喜欢这样的解释:小矩阵就是图片的局部(一个小窗口),而“感受”就是加权计算,“野”就是计算结果。无数的局部感受野结果构成新的矩阵,被称为特征图(feature map)。
使用不同的卷积核可以提取到不同的图像特征图,例如锐化,模糊,浮雕效果等。这些特征图从 2D 空间上来反应图片的根本特征。
考虑一幅 MNIST 数字图像,那么它是一个 28*28 的矩阵,使用 3*3 的卷积核,那么从上到下卷积核进行单跨距移动,可以移动 28 - 3 + 1 = 26 次,同样从左到右也可以移动 26 次,就得到一个 26*26 的特征图(特征矩阵)。(这里没有考虑边缘插值处理)
如果使用 5*5 的卷积核,则得到一个 24*24 的特征图。
如果卷积层也用神经网络的一层实现,那么就对应了第 1 层到第 2 层的转换,显然第 1 层输入节点有 28*28 个,第 2 层接受输入到节点有 24*24 个,它们显然不是全连接的:
- 左上角第 1 个感受野中的 25 个像素对应第 1 层中的 25 个输入节点,它们与其他感受野共享一组权重(卷积核)和一个偏置量。
- 第 2 层有 24*24 个节点,对应前层每一感受野的输入,并使用激活函数产生输出。
- 这种卷积加偏置的处理就是一个函数操作,所以输入层到隐藏层的处理也被称为特征映射。
在CNN中,一组共享权重和偏置一起被称为一个卷积核或者滤波器。用于 MNIST 识别的 LeNet 网络使用 6 个 5*5 的卷积核。CNN 中的第 2 层被称为卷积层,当然卷积层可以有多层,也即对特征图继续卷积。
我们已经看到不同卷积核对于一幅图片的作用,那么从直观上感受卷积层到底在做什么:
- 数字 0 在进行模糊处理后倾向于向整幅图片扩散强度
- 锐化之后,中间形成明显的孔洞
- 拉普拉斯变换可以发现 0 可以成为两个嵌套的圆环
- Sobel 算子在 x,y 方向的导数将 0 切割成两个弧形
- 浮雕处理后 0 在四个方向被平切
有些变换得到的特征并不适用于人脑对数字的识别,例如浮雕处理后到底对数字识别有什么裨益,当然卷积核有成千上万种供选择,也不可能一一分析。但是人脑是够识别 0 在于它就是一个圈,也许这个圈不太圆,所以锐化操作和拉普拉斯变换就能清晰地反应这一事实,不难假想它们在卷积层对应的节点输入权重可能被调整得比较大,这有益于对手写数字的识别。当然实际上所有的卷积核都是动态学习到的,我们不能指望凑巧生成了一个拉普拉斯变换的卷积核,但是这种直觉理解是有益的。
实际上,人脑还会结合上下文来进行识别,如果在一串数字中,那么圆形就被判定为数字 0,如果是在单词中,显然是字母 o。从这一观点出发机器学习显然具有非常大探索空间。
共享权重和偏置¶
共享权重和偏置并不是 CNN 网络的发明,而是采用由提取图像特征的算法:卷积来决定的。一幅图片的拍摄环境不会有剧烈变化,只是 3D 世界的一瞬间的采样,所以在这一幅图的不同局部均反映了共同的环境特征:强度,色度等等,所以对于不同的感受野采用不同的权重倒是更令人费解。
共享权重和偏置附带来的好处就是大大降低了网络复杂度和计算量,性能提高了不止一个数量级,如果图片尺寸更大则更明显:
- 设想全连接网络的权重数目在隐藏层 30 个节点情况下,权重数目为:28*28*30 = 23520。
- 而共享权重只需要 (5*5 + 1)*3 = 78 个权重参数,其中 5*5 为卷积核大小,1 为偏置,3 为特征图数目。
池化¶
卷积层之后紧跟的是池化层(pooling layer),对各种特征图进行汇聚处理,以减少传入下一层的特征数量。池化层可以看作是一种特征抽取方式。
池化操作非常容易理解,例如 28*28 的图像矩阵,以 2*2 的池化窗口进行切割,一共可以分为 12*12 个区域,每个区域有 2*2 = 4 个激活值,那么就对这 4 个值进行抽取:
- 选择最大的值,最大值池化(max-pooling)。
- Mean 抽取,也即求平均值。
- MSE抽取,也即标准差抽取,又被称为 L2 池化(L2 pooling)。
当然池化窗口有可以指定跨距(stride)来进行滑动,而不是以窗口单位进行移动。
池化层和卷积层共同完成了对数据特征提取和空间压缩的作用,实际上如果把卷积层的跨距设置得比较大,可以避免使用池化层(池化层本质是丢弃了一些小区域的特征,令该区域最典型一个小区域的特征代表整个区域)。
池化层的输出要对接一个全连接的神经网络,当然也可以只有一层全连接层也即输出层,节点数等于分类数。
实现 CNN¶
通过以上分析,一个最简的 CNN 模型结构如下所示,其中 FC 表示全连接层(fully-connected),激活函数使用 ReLU:
0 | INPUT -> CONV(ReLU) -> POOLING -> FC(SOFTMAX) -> OUTPUT
|
我们使用 Keras 来一步步构建 CNN 神经网络,Keras 对一些深度神经网络的学习库进行了统一接口封装,例如 TensorFlow, CNTK, 或者 Theano,它们被作为后端运行。Keras 支持快速建模和实验,能够以最小的时延把你的想法转换为实验结果。Keras 就是深度学习的“炼丹炉”,将深度学习的六大要素(网络层,损失函数,激活函数,正则化方法,梯度下降优化和参数初始化策略)以模块的方式组合起来,非常易于使用。 正如 Python 的 slogan “人生苦短,我用 Python”,同样适用于 Keras !
这里使用 TensorFlow 作为后端,并借助 GPU 加速。这里依然使用 MNIST 数据集作为“炼丹”原料,感受一下卷积神经网络到底比传统的全连接神经网络优势在哪里。
MNIST 数据集有 40000 个训练集,10000 个测试集,每幅图片是分辨率为 28*28 的灰度图,所以没有颜色通道,也即颜色通道是 1。当使用 TensorFlow 作为后端时,颜色通道在最后,所以我们的输入数据的 shape 为 (40000,28,28,1),我们要根据通道设置来进行数据转换:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | def cnn_load_mnist(ratio=1):
num_classes = 10
img_rows, img_cols = 28,28
x_train, y_train = dbload.load_mnist(r"./db/mnist", kind='train',
count=int(ratio*40000))
x_test, y_test = dbload.load_mnist(r"./db/mnist", kind='test',
count=int(ratio*10000))
# 对应 ∼/.keras/keras.json 中的 image_data_format 配置
if K.image_data_format() == 'channels_first':
x_train = x_train.reshape(x_train.shape[0], 1, img_rows, img_cols)
x_test = x_test.reshape(x_test.shape[0], 1, img_rows, img_cols)
else:
x_train = x_train.reshape(x_train.shape[0], img_rows, img_cols, 1)
x_test = x_test.reshape(x_test.shape[0], img_rows, img_cols, 1)
# 归一化处理
x_train = x_train.astype('float32')
x_test = x_test.astype('float32')
x_train /= 255
x_test /= 255
# 转化为对分类标签
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)
return x_train, y_train, x_test, y_test
|
‘channels_first’ 用于 Caffe 和 Theano,’channels_first’ 用于 TensorFlow。另外要注意将类别标签转化为多分类标签,例如两个样本的标签为 [5, 0] 转换为二分类矩阵:
0 1 2 3 4 5 | # 向量类别标签 shape 为 (2,)
[5 0]
# 转化为二分类矩阵,shape 为 (2, 10)
[[ 0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
[ 1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]]
|
创建 CNN 序列模型,注意我们的模型没有使用池化层:
- 卷积层:32 个 3*3 的卷积核,输入为图片的高和宽以及通道数,灰度图通道数为 1
- padding 为 same 表示进行边缘插值处理,这样卷积后的特征图大小和输入的图像保持不变
- 卷积层的激活函数设置为 relu
- Flatten 进行一维化处理,以便与全连接层 Dense 对接
- Dense 为全连接层,节点数就是分类数
- Dense 节点的激活函数使用柔性最大值函数(softmax)。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | from keras.models import Sequential
from keras.layers.convolutional import Conv2D
from keras.layers.core import Activation
from keras.layers.core import Flatten
from keras.layers.core import Dense
from keras.layers import Dropout
from keras.optimizers import SGD
from keras.layers import Conv2D
def cnn_model_create(width, height, depth, classes):
model = Sequential()
input_shape = (height, width, depth)
if K.image_data_format() == "channels_first":
input_shape = (depth, height, width)
# define the first (and only) CONV => RELU layer
model.add(Conv2D(32, (3, 3), padding="same", input_shape=input_shape))
model.add(Activation("relu"))
# softmax classifier
model.add(Flatten())
model.add(Dense(classes))
model.add(Activation("softmax"))
return model
|
model 在使用之前需要编译,其中参数:
- loss 指定代价函数,这里为多分类交叉熵代价函数 “categorical_crossentropy”。
- optimizer 指定梯度下降的优化器,这里使用最原始的 sgd,学习率 lr 设置为 0.001。
- metrics 指定模型评估标准,这里使用准确率 “accuracy”。
编译后使用 fit 训练模型:
- validation_split 指定交叉验证集占训练集数据比例,注意这里不会对训练集进行乱序处理,也即选择最后的十分之一样本,如果样本不是随机的,要首先进行 shuffle 处理。
- batch_size 指定训练批数据量大小,这里为 256
- epochs 指定在数据上训练的轮次
- verbose 0 不输出日志信息,1 为输出进度条记录,2 为每个 epoch 输出一行记录。
- shuffle 为 True,则每一轮次进行乱序处理。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | def cnn_mnist_test():
epochs = 10
x_train, y_train, x_test, y_test = cnn_load_mnist(1)
# # SGD(lr=0.005)
model = cnn_model_create(width=28, height=28, depth=1, classes=10)
model.compile(loss="categorical_crossentropy", optimizer=SGD(lr=0.001),
metrics=["accuracy"])
model.fit(x_train, y_train, validation_split=0.1,
batch_size=256, epochs=epochs, verbose=1,
shuffle=True)
score = model.evaluate(x_test, y_test, verbose=1)
print('Test loss:', score[0])
print('Test accuracy:', score[1])
|
在经过 10 轮的迭代训练后,得到如下结果,看来我们对 CNN 过度自信了。
0 1 | Test loss: 0.571357248878
Test accuracy: 0.8684
|
尝试把优化参数从 SGD 更改为 keras.optimizers.Adadelta(),我们将会对 CNN 重获信心。但是记住没有进行更深入的比较是不公正的:
- 与一个全连接浅层神经网络 NN [28*28,50,10] 作比较,它有 40,534 个权重系数
- 而实际上这里的 CNN 的权重系数达到了 251,210 个,这样看 CNN 不仅没有降低计算量,实际上超过了全连接网络的 5 倍以上,CNN 的加速主要得益于 GPU 对卷积的高效处理。特征图转换为一维数据后与全连接层之间参数剧增。在不考虑池化技术的情况下,每个特征图都是一个28*28 的输入向量,多少个特征图,就增加了多少倍的参数。CNN 的优势在于对大分辨率图片(200以上)的处理上,此时的卷积核可以很大,池化窗口也可以很大,此时的性能将超越全连接神经网络。
- CNN 网络的好处在于它考虑了区域 2D 特征,也即当图片轻微变形后(平移,旋转,扭曲),不会影响识别率,而 NN 则不具有这个特性,这里要强调这一点。
0 1 2 | model.compile(loss="categorical_crossentropy",
optimizer=keras.optimizers.Adadelta(),
metrics=["accuracy"])
|
更新后的训练结果为:
0 1 | Test loss: 0.0667042454224
Test accuracy: 0.9797
|
至此我们使用一个简单的 CNN 网络将准确度推向了新高度。
CNN 的优势¶
我们分析过 CNN 的优势在于大分辨率图片的快速学习,另外就是它考虑了 2D 图片特征,所以对于图片轻微的变形具有更强鲁棒性。为了进行这种对比,我们尝试对测试的数据进行平移扩展处理,也即上下左右各移动 offset_pixels 个像素,这样我们就在原来的 10000 个测试集上扩展为 40000 个测试集。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | expand_file = r"./db/mnist/mnist_testexp.pkl.gz"
def expand_mnist(count=0, offset_pixels=1):
import pickle,gzip
import scaler
ef = expand_file + str(offset_pixels)
if os.path.exists(ef):
print("The expanded training set already exists.")
return
x_train, y_train = __load_mnist("./db/mnist", kind='t10k',
count=count, dtype=np.uint8)
# move down 1 pixel
x_down = np.roll(x_train, offset_pixels, axis=1)
x_down[:, 0, :] = 0
# move up 1 pixel
x_up = np.roll(x_train, -offset_pixels, axis=1)
x_up[:, -1, :] = 0
# move right 1 pixel
x_right = np.roll(x_train, offset_pixels, axis=2)
x_right[:, :, 0] = 0
# move left 1 pixel
x_left = np.roll(x_train, -offset_pixels, axis=2)
x_left[:, :, -1] = 0
expand_x = np.vstack([x_down, x_up, x_right, x_left])
expand_x, expand_y = scaler.shuffle(expand_x, np.tile(y_train, 4))
print("Saving expanded data. This may take a few minutes.")
with gzip.open(ef, "w") as f:
pickle.dump((expand_x, expand_y), f)
|
代码具有自解释性,count 指定加载训练集个数,0 表示加载所有,这样扩展后的数据就是 4*count 个。offset_pixels 指定上下左右移动的像素个数。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def load_expand_mnist(count=0, offset_pixels=1):
import pickle,gzip
ef = expand_file + str(offset_pixels)
if not os.path.exists(ef):
print("The expanded test set not exists.")
return
f = gzip.open(ef, 'rb')
expand_x, expand_y = pickle.load(f)
f.close()
count = int(count)
if count <= 0:
return expand_x, expand_y
return expand_x[0:count], expand_y[0:count]
|
load_expand_mnist 用于加载扩展数据集。我们这里尝试移动 1/3 个像素,比较 NN 和 CNN 的效果。
模型 原数据 平移1像素 平移3像素 NN 0.9624 0.9135 0.3726 CNN 0.9797 0.9582 0.5918
显然 NN 抗平移性能远远差于 CNN 网络,随着平移像素的增加,NN 的识别率急剧下降,另外注意到我们的 CNN 没有使用池化处理,否则优势将更明显。但是我们的验证也说明 CNN 网络并不能抵抗较大的变形,但是这对于人脑识别基本没有影响,显然 CNN 学习到的特征与背景严重相关,只要前景在背景上进行了移动,学习到的特征就开始失效。所以使用 CNN 前依然要对数据进行中心化等预处理。
显然如果我们把扩展数据添加到训练集,测试集上的结果将会继续变好,实际上如果我们继续对图像进行倾斜,旋转,扭曲等处理,这一结果将更好,这给神经网络以 3D 的视角来理解输入的图像。即便只添加上下左右移动 1 像素的扩展数据集,我们在测试集上获得了如下正确率:
0 1 | Test loss: 0.0305156658327
Test accuracy: 0.9913
|
现在我们可以尝试修改模型:增加池化层,以及随机失活(dropout,也称弃权)层,这里直接借用 Keras 示例模块 examples/mnist_cnn.py:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def cnn_model_create2(width, height, depth, classes):
model = Sequential()
input_shape = (height, width, depth)
if K.image_data_format() == "channels_first":
input_shape = (depth, height, width)
model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),
activation='relu',
input_shape=input_shape))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(classes, activation=tf.nn.softmax))
return model
|
该模型的上传者声称达到了 99.25% 的准确率,我们在扩展训练集上进行训练,得到惊人的结果,也即在 10000 张测试图片中,只有 11 张被分类错误,实际上这 11 张图片即便让人来辨识,也无法分清。
0 1 | Test loss: 0.004121249028
Test accuracy: 0.9989
|
观察扩展数据上的学习曲线是一件很有意思的事情:与以往的学习曲线不同,校验集的准曲率竟然遥遥领先于训练集,也即在第一个训练周期,训练集上的准确率还在 85% 左右,而它在校验集上的泛化能力达到了惊人的 97%,另外在迭代 15 次之后,模型并没有饱和,而是在 99% 徘徊。或许我们继续增加迭代次数来获取 100% 的识别率,但是这已经无关紧要。
显然 CNN 从我们提供的训练集上很快就学习到了手写数字的基本特征,而不是一些对分类无效的细枝末节,这似乎和人类观察事物有“点儿”相像了:只需要看一张黑色印刷的数字 0,然后再看一张红色印刷的数字 0,接着看一张缩写或者放大版的 0,并且告诉他这些全都是数字 0,那么人脑立即就能获取 0 和哪些特征是无关的(颜色,大小等),而能立即留下 0 的本质特征印象(一个抽象的数字符号),人脑表现出在小数据上的强劲的泛化能力(学习能力)。
通过人为扩展训练集提高准确率是非常可行的,前提就是扩展后的数据必须反映数据的本质特征,而不能丢失这些特征,比如 0-9 手写数字,平移和旋转都不会丢失数字特征,而如果进行剪切和过分扭曲那么就会导致识别率降低了。
模型保存和加载¶
显然当我们扩展训练数据时,并不希望每次都从 0 开始训练,而是期望从现有的基础上继续使用扩展数据训练,或者直接加载模型并进行预测。
model.save 将 Keras 模型和权重保存在一个 HDF5 文件中,该文件将包含:
- 模型的结构,以便重构该模型
- 模型的权重
- 训练配置(损失函数,优化器等)
- 优化器的状态,以便于从上次训练中断的地方开始
0 1 | from keras.models import load_model
model.save('mnist_model.h5')
|
使用keras.models.load_model(filepath)来重新实例化模型,如果文件中存储了训练配置的话,该函数还会同时完成模型的编译
0 | model = load_model('mnist_model.h5')
|
可视化工具¶
打印模型信息¶
keras.utils 提供一些有用的函数,print_summary 用于打印模型概况。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | from keras.utils import print_summary
print_summary(model)
>>>
Layer (type) Output Shape Param #
=================================================================
conv2d_1 (Conv2D) (None, 28, 28, 32) 320
_________________________________________________________________
activation_1 (Activation) (None, 28, 28, 32) 0
_________________________________________________________________
flatten_1 (Flatten) (None, 25088) 0
_________________________________________________________________
dense_1 (Dense) (None, 10) 250890
_________________________________________________________________
activation_2 (Activation) (None, 10) 0
=================================================================
Total params: 251,210
Trainable params: 251,210
Non-trainable params: 0
|
概况中包括层数,每层的属性,总参数个数以及可训练参数个数。第一层 320 = 32*(3*3 + 1),Flatten 处理后的参数个数 25088 = 28*28*32, 全连接层 Dense 为输入加上偏置,也即 25088*10 + 10 = 250890。
plot_model 可以把上述信息图形化:
0 1 | from keras.utils import plot_model
plot_model(model, to_file='model.png', show_shapes=True)
|
模型训练曲线图¶
模型的 fit 函数会返回一个 History 对象。其 History.history 属性是连续 epoch 训练损失和评估值,以及验证集损失和评估值的记录(如果适用),可以使用这些数据绘制代价函数曲线和准确率曲线。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | H = model.fit(x_train, y_train, validation_split=0.1,
batch_size=256, epochs=epochs, verbose=1,
shuffle=True)
......
plt.figure()
plt.subplot(2,1,1)
plt.title("Training Loss and Accuracy")
plt.plot(np.arange(0, epochs), H.history["acc"], c='black', label="Train")
plt.plot(np.arange(0, epochs), H.history["val_acc"], c='gray', label="Validation")
plt.ylabel("Accuracy")
plt.legend(loc='best')
plt.subplot(2,1,2)
plt.plot(np.arange(0, epochs), H.history["loss"], c='black', label="Train")
plt.plot(np.arange(0, epochs), H.history["val_loss"], c='gray', label="Validation")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend(loc='best')
plt.tight_layout()
plt.show()
|
通过学习曲线可以查看模型的拟合情况,图中可以看出在迭代 6 个周期后可以早停。
Tensorboard¶
TensorBoard 是由 Tensorflow 提供的一个可视化工具。通过 keras.callbacks.TensorBoard 回调函数将训练时的日志写入固定目录,然后通过Tensorboard 命令可视化测试和训练的标准评估的动态图像,也可以可视化模型中不同层的激活值直方图。
0 | tensorboard --logdir=./logs
|
在浏览器中 http://127.0.0.1:6006 查看动态图。
0 1 2 3 4 5 6 7 8 9 10 11 12 | # 在要检测的层添加名称 'features'
from keras.callbacks import TensorBoard
tensorboard = TensorBoard(log_dir='./logs',
batch_size=256,
embeddings_freq=1,
embeddings_layer_names=['features'],
embeddings_metadata='metadata.tsv',
embeddings_data=x_test)
# 添加 callbacks 参数
H = model.fit(x_train, y_train, validation_split=0.1,
batch_size=256, epochs=epochs, verbose=1,
shuffle=True, callbacks=[tensorboard])
|
深度学习笔记¶
基本概念¶
- 人工智能 (AI Artificial Intelligence),努力将通常由人类完成的智力任务自动化。
- 机器学习 (ML Machine Learning)
- 深度学习 (DL Deep Learning)
DL 是 ML 算法中的一种。ML 是实现 AI 的方法。
包含关系,AI > ML > DL。
AI¶
人工智能发展阶段:
- AI 诞生于 20 世纪 50 年代,人们提出疑问:计算机是否能够“思考”?
- 符号主义人工智能(Symbolic AI),人们相信,只要编写足够多的“明确规则”(硬编码)就可以实现 AI。专家系统(Expert System)是此种方式的典型应用。
- ML 机器学习,符号主义人工智能适用于解决另一明确的逻辑问题,比如国际象棋,不适用于更加复杂,模糊的问题,比如图像分类,声音识别,于是 ML 取代了它。
ML¶
如果没有程序员精心编写的数据处理规则,计算机能否通过观察数据自动学会这些规则?
- 符号主义人工智能:输入规则和处理数据,系统输出答案。
0 1 2 3 4 5 6 7 | +------+ +-----------------------+ +---------+
| Data | --> | Classical Programming | --> | Answers |
+------+ +-----------------------+ +---------+
^
|
+-----------------------+
| Rules |
+-----------------------+
|
- ML:人们输入数据(样本)和从这些数据中预期得到的答案,系统输出的是规则。规则可用于新的数据,并使计算机自主生成答案。
0 1 2 3 4 5 6 7 | +---------+ +------------------+ +-------+
| Answers | --> | Machine Learning | --> | Rules |
+---------+ +------------------+ +-------+
^
|
+------------------+
| Data |
+------------------+
|
机器学习的最大特点:机器学习系统是训练出来的,而不是明确用程序编写出来的。举个例子,给 ML 分析一些猫的照片,机器会学习到猫的特征规则,并可以识别新的照片中的动物是否是猫。
机器学习的蓬勃发展的驱动力来源于硬件速度的提升和更大的数据集。例如:
- Flickr 网站上用户生成的图像标签一直是计算机视觉的数据宝库。
- YouTube 视频也是一座宝库。
- 维基百科则是自然语言处理的关键数据集。
机器学习与数理统计密切相关,但是不同于统计学,它是一门以工程为导向的科学,更多的靠实践来证明,而不是理论推导。
机器学习三要素:
- 输入数据集。 比如语音数据,图像数据。
- 预期输入的示例。对于图像输入来说预期输入可能是“猫狗”之类的标签。
- 衡量算法效果好坏的方法。为了计算算法的当前输入出与预期输出的差距。
衡量结果是一种反馈信号,用于调节算法的工作方式,这个调节步骤就是我们所说的 学习。
机器学习的核心问题是为输入数据寻找合适的表示——对数据进行变换,使其更适合手头的任务(比如分类任务)。 例如识别认证码,我们就不关心色彩,而是要把它与背景区分开来并校正扭曲的码字。
机器学习中的 学习 指的是,寻找更好数据表示(根据任务将数据转化为更加有用的表示)的自动搜索过程。
机器学习算法在寻找这些变换时仅仅是遍历一组预先定义好的操作,这组操作叫做 假设空间 (Hypothesis Space)。
这就是机器学习的技术定义:在预先定义好的可能性空间中,利用反馈型号的指引来寻找输入数据的有用表示。整个流程如下所示:
0 1 2 3 4 5 6 7 | +-----------+ +------------------+ +---------+ +-----------+
| Data | --> | Machine Learning | --> | Answers | <-|-> | Expection |
+-----------+ +------------------+ +---------+ | +-----------+
^ |
| Transform | Feedback
+------------------+ +-----------+ |
| Hypothesis Space | <-- | Diff | <-+
+------------------+ +-----------+
|
DL¶
数据模型中包含多少层,这被称为模型的 深度 (depth)。
这一领域的其他名称包括分层表示学习(layered representations learning)和层级表示学习(hierarchical representations learning)。
- 深度学习通常包含数十个甚至上百个连续的表示层,这些表示层全都是从训练数据中自动学习的。
- 其他机器学习方法的重点往往是仅仅学习一两层的数据表示,因此有时也被称为浅层学习(shallow learning)。
在深度学习中,这些表示层通过神经网络(neural network)的模型来学习得到。神经网络的结构是 逐层堆叠 。
深度学习的技术定义:学习数据表示的多级方法。
神经网络中每层对输入数据所做的具体操作保存在该层的 权重 (weight) 。其本质是一串数字,权重也被称为该层的 参数 (parameter)。
学习的意思是为神经网络的每层找到一组权重值,使得该网络能够将每个示例输入与其目标正确地一一对应。
神经网络的 损失函数 (loss function),或目标函数(objective function)用于衡量输出与预期之间的距离,也即效果的好坏。
DL 的基本技巧是利用这个距离值作为反馈信号来对权重值进行微调,以降低损失值,这种调节由优化器(Optimizer)来完成,它实现了所谓的反向传播(backpropagation) 算法。这是 DL 的核心算法。
一开始对权重随机赋值,随着网络处理的示例越来越多,权重值也在向正确方向趋近,损失值也逐渐降低,这就是训练循环(Training Loop)。
深度学习从数据中进行学习时有两个基本特征:
- 第一, 通过渐进的、逐层的方式形成越来越复杂的表示;
- 第二, 对中间这些渐进的表示共同进行学习,每一层的变化都需要同时考虑上下两层的需要。
这两个特征使得深度学习比先前的机器学习方法更加成功。
深度学习已经取得了以下突破,它们都是机器学习历史上非常困难的领域:
- 接近人类水平的图像分类 - 接近人类水平的语音识别 - 接近人类水平的手写文字转录 - 更好的机器翻译 - 更好的文本到语音转换 - 数字助理,比如谷歌即时(Google Now)和亚马逊 Alexa - 接近人类水平的自动驾驶 - 更好的广告定向投放, Google、百度、必应都在使用 - 更好的网络搜索结果 - 能够回答用自然语言提出的问题 - 在围棋上战胜人类
经典机器学习方法¶
概率建模¶
概率建模是统计学原理在数据分析中的应用。它是最早的机器学习形式之一。
- 朴素贝叶斯算法,它假设输入数据的特征都是独立的。
- Logistic 回归(Logistic Regression,简称 Logreg),它是一种分类算法,而不是回归算法。
早期神经网络¶
贝尔实验室于 1989 年第一次成功实现了神经网络的实践应用,当时 Yann LeCun 将卷积神经网络的早期思想与反向传播算法相结合,并将其应用于手写数字分类问题,由此得到名为 LeNet 的网络,在 20 世纪 90 年代被美国邮政署采用,用于自动读取信封上的邮政编码。
核方法¶
核方法(kernel method)。核方法是一组分类算法,其中最有名的就是支持向量机(SVM,support vector machine)。
SVM 的目标是通过在属于两个不同类别的两组数据点之间找到良好决策边界(decision boundary)来解决分类问题。
SVM 通过两步来寻找决策边界。 1. 将数据映射到一个新的高维表示,这时决策边界可以用一个超平面来表示。 2. 尽量让超平面与每个类别最近的数据点之间的距离最大化,从而计算出良好决策边界(分割超平面),这一步叫作间隔最大化(maximizing the argin)。这样决策边界可以很好地推广到训练数据集之外的新样本。
SVM 很难扩展到大型数据集,并且在图像分类等感知问题上的效果也不好。 SVM 是一种比较浅层的方法,因此要想将其应用于感知问题,首先需要手动提取出有用的表示(这叫作特征工程),这一步骤很难,而且不稳定。
决策树、随机森林¶
决策树(decision tree)是类似于流程图的结构,可以对输入数据点进行分类或根据给定输入来预测输出值。
随机森林(random forest)算法,它引入了一种健壮且实用的决策树学习方法,即首先构建许多决策树,然后将它们的输出集成在一起。随机森林适用于各种各样的问题—— 对于任何浅层的机器学习任务来说,它几乎总是第二好的算法。
梯度提升机¶
与随机森林类似, 梯度提升机(gradient boosting machine)也是将弱预测模型(通常是决策树)集成的机器学习技术。它使用了梯度提升方法,通过迭代地训练新模型来专门解决之前模型的弱点,从而改进任何机器学习模型的效果。
神经网络¶
- NN(Neural Network):神经网络,这里还没有和生物上的神经网络相揖别。
- ANN(Artificial Neural Network):人工神经网络,这里就是计算机领域(人工智能/深度学习领域)的神经网络了,通常把最前面的A替换为某种优化算法或特性的缩写,比如 DNN,CNN。
- DNN(Deep Neural Network):深度神经网络,所有当前讨论的神经网络的基石。通常指隐藏层 >=2 的人工神经网络,含一层隐藏层的称为多层感知机(Multi-layer Perceptron)。
- DBM(Deep Boltzmann Machine):深度波尔茨曼机。
- DBN(Deep Belief Network):深度置信网络。
- RNN(RNN(Recurrent Neural Network):循环神经网络,是一类用于处理序列数据的神经网络。
多层感知机¶
最早的神经网络节点的雏形源于感知机(Perceptron),只可处理线性可分的二分类问题,无法解决 XOR 异或问题。
20世纪80年代末期,人工神经网络的反向传播算法(也叫Back Propagation算法或者BP算法)被发明,利用 BP 算法可以让一个人工神经网络模型从大量训练样本中学习到统计规律,从而对未知事件做预测。这种基于统计的机器学习方法比起过去基于人工规则的系统,在很多方面显出优越性。这个时候的人工神经网络,只含有一层隐层节点的浅层模型,被称作多层感知机(Multi-layer Perceptron)。
20世纪90年代,各种各样的浅层机器学习模型相继被提出,例如支撑向量机(SVM,Support Vector Machines)、 Boosting、最大熵方法(如LR,Logistic Regression)等。这些模型的结构基本上可以看成带有一层隐层节点(如SVM、Boosting),或没有隐层节点(如LR)。这些模型无论是在理论分析还是应用中都获得了巨大的成功。相比之下,由于理论分析的难度大,训练方法又需要很多经验和技巧,这个时期浅层人工神经网络反而相对沉寂。
2006年,加拿大多伦多大学教授、机器学习领域的泰斗Geoffrey Hinton和他的学生Ruslan Sala khutdinov 在《科学》上发表了一篇文章,开启了深度学习在学术界和工业界的浪潮。这篇文章有两个主要观点:
- 多隐层的人工神经网络具有优异的特征学习能力,学习得到的特征对数据有更本质的刻画,从而有利于可视化或分类;
- 深度神经网络在训练上的难度,可以通过“逐层初始化”(layer-wise pre-training)来有效克服,这篇文章中,逐层初始化是通过无监督学习实现的。
深度学习¶
比起使用 BP 算法的浅层学习,超过 2 层的神经网络上的学习通常被认为是深度学习(Deeping Learning)。
深度神经网络具有学习能力的本质:利用矩阵的线性变换和激活函数的非线性变换,将原始输入空间投向线性可分/稀疏的空间去分类/回归。增加节点数,即增加线性转换能力。增加层数:增加激活函数的次数,即增加非线性转换能力。
Hornik 1989 年证明只需要一个包含足够多神经元的隐层,BP神经网络就能以任意精度逼近任意复杂度的连续函数。通过增加层数相当于则降低了单层的复杂度。浅层神经网络可以模拟任何函数,但数据量的代价是无法接受的。深层解决了这个问题。相比浅层神经网络,深层神经网络可以用更少的数据量来学到更好的拟合。
CNN¶
CNN(Convolutional Neural Network)卷积神经网络的特定:
- 卷积:对图像元素的矩阵变换,是提取图像特征的方法,多种卷积核可以提取多种特征。一个卷积核覆盖的原始图像的范围叫做感受野(权值共享)。一次卷积运算(哪怕是多个卷积核)提取的特征往往是局部的,难以提取出比较全局的特征,因此需要在一层卷积基础上继续做卷积计算 ,这也就是多层卷积。
- 池化(Pooling):降维的方法,按照卷积计算得出的特征向量维度大的惊人,不但会带来非常大的计算量,而且容易出现过拟合,解决过拟合的办法就是让模型尽量“泛化”,也就是再“模糊”一点,那么一种方法就是把图像中局部区域的特征做一个平滑压缩处理,这源于局部图像一些特征的相似性(即局部相关性原理)。
- 全连接:softmax分类训练过程:卷积核中的因子(×1或×0)其实就是需要学习的参数,也就是卷积核矩阵元素的值就是参数值。一个特征如果有9个值,1000个特征就有900个值,再加上多个层,需要学习的参数还是比较多的。
与普通的 DNN 深度神经网络相比:DNN的输入是向量形式,并未考虑到平面的结构信息,在图像和NLP领域这一结构信息尤为重要,例如识别图像中的数字,同一数字与所在位置无关(换句话说任一位置的权重都应相同),CNN的输入可以是其他维度的张量,例如二维矩阵,通过filter获得局部特征,较好的保留了平面结构信息。
CNN的几个特点:局部感知、参数共享、池化。
CNN 在计算机图像处理领域应用很广,比如寻找相似图片,人脸检测等。
CNN发展至今,已经有很多变种,其中有几个经典模型在CNN发展历程中有着里程碑的意义,它们分别是:LeNet、Alexnet、Googlenet、VGG、DRL等。
LeNet5¶
CNN 的经典模型:手写字体识别模型 LeNet5。
LeNet5 诞生于1994年,是最早的卷积神经网络之一,由Yann LeCun 完成,推动了深度学习领域的发展。在那时候,没有 GPU 帮助训练模型,甚至CPU的速度也很慢,因此,LeNet5 通过巧妙的设计,利用卷积、参数共享、池化等操作提取特征,避免了大量的计算成本,最后再使用全连接神经网络进行分类识别,这个网络也是最近大量神经网络架构的起点,给这个领域带来了许多灵感。
参考:https://my.oschina.net/u/876354/blog/1632862 论文:Gradient-Based Learning Applied to Document Recognition
AlexNet¶
2012年,Alex Krizhevsky、Ilya Sutskever在多伦多大学Geoff Hinton的实验室设计出了一个深层的卷积神经网络AlexNet,夺得了2012年ImageNet LSVRC的冠军,且准确率远超第二名(top5错误率为15.3%,第二名为26.2%),引起了很大的轰动。AlexNet可以说是具有历史意义的一个网络结构,在此之前,深度学习已经沉寂了很长时间,自2012年AlexNet诞生之后,后面的ImageNet冠军都是用卷积神经网络(CNN)来做的,并且层次越来越深,使得CNN成为在图像识别分类的核心算法模型,带来了深度学习的大爆发。
AlexNet之所以能够成功,跟这个模型设计的特点有关,主要有:
- 使用了非线性激活函数:ReLU
- 防止过拟合的方法:Dropout,数据扩充(Data augmentation)
- 其他:多GPU实现,LRN 归一化层的使用
VGGNet¶
2014年,牛津大学计算机视觉组(Visual Geometry Group)和Google DeepMind公司的研究员一起研发出了新的深度卷积神经网络:VGGNet,并取得了ILSVRC2014比赛分类项目的第二名(第一名是GoogLeNet,也是同年提出的)和定位项目的第一名。 VGGNet探索了卷积神经网络的深度与其性能之间的关系,成功地构筑了16~19层深的卷积神经网络,证明了增加网络的深度能够在一定程度上影响网络最终的性能,使错误率大幅下降,同时拓展性又很强,迁移到其它图片数据上的泛化性也非常好。到目前为止,VGG仍然被用来提取图像特征。 VGGNet可以看成是加深版本的AlexNet,都是由卷积层、全连接层两大部分构成。
GoogLeNet¶
2014年,GoogLeNet和VGG是当年ImageNet挑战赛(ILSVRC14)的双雄,GoogLeNet获得了第一名、VGG获得了第二名,这两类模型结构的共同特点是层次更深了。VGG继承了LeNet以及AlexNet的一些框架结构,而GoogLeNet则做了更加大胆的网络结构尝试,虽然深度只有22层,但大小却比AlexNet和VGG小很多,GoogleNet参数为500万个,AlexNet参数个数是GoogleNet的12倍,VGGNet参数又是AlexNet的3倍,因此在内存或计算资源有限时,GoogleNet是比较好的选择;从模型结果来看,GoogLeNet的性能却更加优越。
ResNet¶
ResNet(Residual Neural Network)深度残差网络,应用于CNN,解决网络训练深度。
基础数学¶
比较全面的总结参考这里:机器学习中的基本数学知识 。
张量¶
张量是矩阵向任意维度的推广[注意,张量的维度(dimension)通常叫作轴(axis)]。矩阵是 2D 张量。
张量轴的个数也叫作阶 (rank)。 维度可以表示沿着某个轴上的元素个数,也可以表示张量中轴的个数。
- 0D 张量:仅包含一个数字的张量叫作标量(scalar),对应 Numpy 中 一个 float32 或 float64 数字。
- 1D 张量:数字组成的1维数组叫作向量(vector),它有一个轴。如果一个向量有 5 个元素,称为 5D向量。5D 向量只有一个轴,沿着轴有5个维度。
- 2D 张量:向量组成的数组叫作矩阵(matrix)或者二维张量。矩阵有2个轴,通常被叫作行和列。
- 3D 张量:多个矩阵组合成一个新的数组,可以得到一个 3D 张量。
深度学习处理的一般是 0D 到 4D 张量,但处理视频时会遇到 5D 张量。
我们用几个你未来会遇到的示例来具体介绍数据张量。你需要处理的数据几乎总是以下类别之一。
- 向量数据:2D 张量,形状为 (samples, features)。
- 时间序列数据或序列数据: 3D 张量,形状为 (samples, timesteps, features)。
- 图像: 4D 张量,形状为 (samples, height, width, channels) 或 (samples, channels,height, width)。
- 视频: 5D 张量,形状为 (samples, frames, height, width, channels) 或 (samples, frames, channels, height, width)。
概率论¶
样本和事件¶
样本空间:考虑一个试验,其结果是不可肯定地预测的(是一个具有随机性的变量),则所有可能的结果构成的集合,称为该试验的样本空间(Sample Space),记为 S,所以S 就是随机变量所有的可能值的集合。
例如:试验是考察新生婴儿的性别,那么所有可能结果的集合 S = {girl, boy}。
事件(event):样本空间的任一子集 E 称为事件。一个事件是由试验的部分结果组成的一个集合,如果试验的结果包含在 E 里面,那么就称事件 E 发生了。
例如,E={girl} 就是一个事件,如果考察的出生的婴儿是女孩,那么就表示事件 E (婴儿是个女孩)发生了。
n(E)表示 n 次重复试验中事件E发生的次数,概率 P(E) 就定义成上面的形式。关于概率的几个简单性质:
- 一个事件不发生的概率等于 1 减去它发生的概率。\(P\left( E^{c}\right) =1-P\left( E\right)\)。
- 如果 \(E\subset F\),那么 \(P\left( E\right) \leq P\left( F\right)\)。
- \(P\left( E\cup F\right) =P\left( E\right) +P\left( F\right) -P\left( EF\right)\)。
使用维恩图很容易理解。
信息熵¶
信息熵(Information Entropy)用于对样本集D在样本空间(所有可能结果的集合)分布的纯度的度量:分布越单一,那么纯度越高,熵越小,分布越杂乱,那么纯度越底,熵越大。
举一个直觉上的例子,把公园中的步道和绿化带做对比,步道的规律非常明显,无论从材料还是从铺设上都是有规律可循的,我们只要观察一小部分就能推出其余步道规律,它所包含的信息量就少,可以类比数据“abcabcabc”;而绿化带就没有那么明显的规律,有草有树,还有落叶和有了设施,地势也高低不平,那么它所包含的信息量就大,可以类比于数据“Hello world!”。
以上就是熵的定义,单位为bit。pk 表示每一样本空间的概率,当分类最具规律时,也即全部属于一个分类 i 时,必有 pi = 1,其余分类概率为 0, 此时 Ent(D) 取最小值0,当所有 k 均匀分布时(最杂乱),Ent(D) 取最大值 \(\log_{2}^{n}\) 。
以2为底的信息熵还直接给出了最小二进制编码长度,信息熵的单位为 bit 是有实际意义的。如何度量一篇英文文章的信息量,以及如何最小编码?英文文章中的每一个字符可以使用128个ASCII码表示,如果每个ASCII码等概率出现,那么每个字符的 Ent(D) 就是 7bits,就需要 7bits 进行编码,然而字符 a 比 z 出现的概率要高得多,所以 a 可以使用更短的编码: (\(\log_{2}^{pa}\) 向上取整),z 可以使用更长的编码,平均下来每个字符的 Ent(D) 就远远少于 7bits。
由于编码后的字节是连续的,要区分不同的字符的编码就要加入控制字符,比如前导码,这就是数据冗余。以上就是香农-范诺编码和哈夫曼编码的理论基础。
决策树分类方法使用最大信息增益(或基尼系数)构造决策树,每一步选择分类的属性标准:让数据更趋于有规律,此时信息增益最大,而整个分类的信息熵就会降低最大。
几种概率¶
先验概率: \(P(X=a)\) 仅与单个随机变量有关的概率称为先验概率。又称为 边缘概率 或 统计概率,因为它不考虑其他因素(不限定任何条件)。
例如一个箱子中有 0-9 编号的 10 个球,从中单次取到编号为 0 的球的概率 \(P(X=0) = \frac{1}{10}\) 。
条件概率:在 Y = b 成立的情况之下,X = a 的概率,记作 \(P(X=a|Y=b)\) 或 \(P(a|b)\)。是已知 b 发生后发生 a 的条件概率,也称作 a 在给定 b 条件下的 后验概率。
例如当从第一个箱子取到编号为 0 的情况下,再从箱子中取到 1 号球的概率:\(P(1|0) = \frac{1}{9}\)
联合概率:包含多个条件且所有条件同时成立的概率,记作 \(P(X=a,Y=b)\) 或 \(P(a,b)\)。
例如有两个箱子,一个箱子中有 0-9 编号的10个球,另一个箱子有红绿 2 种颜色的 2 个球。从第一个箱子取到数字 0 并且第二个箱子取到红色球的概率 \(P(0,Red) = \frac{1}{20}\) 。
各类概率间关系¶
联合概率、边缘概率与条件概率之间的关系:
简写为:
由于 \(P(X=a,Y=b)=P(Y=b,X=a)\),所以 :
简写为:
这就是概率论中著名的 贝叶斯定理 的定义。
独立事件:如果事件 Y 发生不会影响事件 X 的发生,则称X, Y相互独立。而在条件概率中,当 \(P(X|Y)=P(X)\) 时也就意味着 Y 发生不会影响 X 发生。 代入 \(P(X|Y)=\frac{P(X,Y)}{P(Y)}\) 得到 \(P(X,Y)={P(X)}{P(Y)}\),它通常用于判断两个事件是否独立。
上面例子中的从两个箱子拿小球的例子就是相互独立,所以 \(P(X,Y)={P(X)}{P(Y)}\),也即 \(\frac{1}{20}=\frac{1}{10}\frac{1}{2}\)。
非独立事件:如果不满足 \(P(X,Y)={P(X)}{P(Y)}\),那么 X, Y 就是非独立事件。
例如在一个箱子里面有编号 0-9 的 10 个小球,其中奇数球为红色,偶数球为绿色,那么取到 0 号球的事件 X 概率为 \(P(X)=\frac{1}{10}\),取到红色球的事件 Y 概率为 \(P(Y)=\frac{1}{2}\),但是同时取到 0 号球和红色球的概率 \(P(X,Y)\neq{P(X)}{P(Y)}\),而是 0。
无论是独立事件还是非独立事件,下面的等式恒成立:
这意味着互为条件的概率比值是一个常数。
举例子:
- 假如发现金矿的地形多在靠近河流的山地,那么如果新发现一个金矿,那么这个新矿的地形很可能是靠近河流的山地。
- 假如统计发现垃圾邮件中多出现 “大奖”,“发票”,“陪聊”,“大乐透”等关键词,那么当一个邮件内出现这些词的时候,它是垃圾邮件的概率就很高。
这就是贝叶斯分类的理论依据。
分类垃圾邮件的算法变成了,计算如下几个概率的问题:
- P(Y):P(Y=垃圾邮件),垃圾邮件出现的概率,这个比较简单,只要有足够的邮件数,将垃圾邮件数除以总邮件数量,P(Y=正常邮件) 等于 1-P(Y=垃圾邮件)。
- P(X|Y):统计Xi(每个分词)在垃圾邮件中出现的概率,P(X|Y=垃圾邮件)是每个分词在垃圾邮件中的联合概率,为了简便计算,这里认为各个分词出现在垃圾邮件中的概率是独立的,所以P(X|Y=垃圾邮件)=P(X0|Y=垃圾邮件)*P(X1|Y=垃圾邮件)*…,由于涉及到多个小数相乘,为了防止下溢出,转化为对数计算。同理可以计算出各个分词在正常邮件中出现概率。由于某些词可能不出现,这样整个后验概率就成 0 了,将所有词出现次数初始化为 1,分母初始化为 2。
- P(X):也即每个分词在所有邮件中出现的频率。
以上概率都是基于大数定律,所以样本一定要足够多,否则并不能体现出真实的概率分布情况。有了以上概率,当收到一封邮件时,首先进行分词,然后计算 P(Y=垃圾邮件|X) 和 P(Y=正常邮件|X),比较概率值即可进行分类。
如果要进行多分类(n 分类)就要分别计算 n 个分类的 P(Yi),预测时也要计算 n 个分类的 P(Yi|X)。
hadoop¶
Hadoop 是一个由 Apache 基金会所开发的分布式大数据存储和处理架构。它实现了一个分布式文件系统(Hadoop Distributed File System),简称 HDFS,用于大数据的存储以及 MapReduce 机制对大数据高效处理。
Hadoop 核心组件:
- HDFS(Hadoop Distributed File System)Hadoop 分布式文件系统.
- YARN,运算资源调度系统
- MapReduce,分布式映射归约编程处理框架。
Hadoop 具有以下优点:
- 高容错性:不依赖于底层硬件,在软件层面维护多个数据副本,确保出现失败后针对这类节点重新进行分布式处理;
- 可以部署在大量的低廉硬件上;
- 针对离线大数据处理的并行处理框架:MapReduce;
- 流式文件访问,一次性写入,多次读取。
与此同时,也有如下缺点:
- 不支持低延迟数据访问;
- 不擅长存储大量的小文件(< block 大小):寻址数据 block 时间长;元数据记录的存储压力极大。
- 为保证数据一致性,不支持数据随机修改,只可追加或删除后再重新提交。
安装和配置¶
版本选择¶
在众多的 Linux 发行版中,Ubuntu 通常作为桌面系统使用,具有漂亮的用户界面和高度的软件安装便利性。
与桌面系统不同,服务器要求高性能和高可靠性,所以通常使用 CentOS,Debian 或者 SuSe。
CentOS (Community Enterprise Operating System,社区企业操作系统)。它基于 RHEL (Red Hat Enterprise Linux)依 照开放源代码规定释出的源代码所编译而成。由于出自同样的源代码,因此有些要求高度稳定性的服务器以 CentOS 替代商业版的 RHEL 使用。两者的不同在于CentOS完全开源。
CentOS/RHEL 版本的生命周期具有 7-10 年之久,基本上可以覆盖硬件的生命周期,在整个周期中,软件漏洞都会得到及时的安全补丁支持。这里使用 CentOS-7-x86_64-DVD-1810.iso。
hadoop 官方在 2.2.0 前默认只提供 32bit 安装包,其后只提供 64bit 安装包,这里选用 hadoop-2.7.5.tar.gz, 它可以运行在 64Bit CentOS 7 系统。
hadoop 使用 java 开发,但是为了提高性能底层代码使用 C 语言开发,这些库文件位于安装包的 lib/native 路径下:
libhadoop.a libhadooppipes.a libhadoop.so libhadoop.so.1.0.0
libhadooputils.a libhdfs.a libhdfs.so libhdfs.so.0.0.0
可以使用 file 命令查看:
[root@promote native]# file libhadoop.so.1.0.0
libhadoop.so.1.0.0: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, BuildID[sha1]=ed024ac48c0f542fa36ddc918a75c51e1c647424, not stripped
如果操作系统和软件 bit 位不匹配,则会在运行 hadoop 时报出如下错误信息:
util.NativeCodeLoader: Unable to load native-hadoop library for your platform...
如果要使用官方未提供的版本,需要配置 maven 环境,并使用源码编译,这个过程非常漫长。所以通常采用 64Bit 操作系统配合相应的 hadoop 官方版本。
环境配置¶
hadoop 支持 3 中配置模式,为了验证集群模式,这里使用虚拟机配置两台 CentOS 虚拟机。在实际的生产环境,通常使用 PXE 来批量安装操作系统,除了 IP 地址和主机名之外,所有操作系统配置应保持一致。以下配置均在 root 用户模式下进行。
环境配置如下:
- 主机名 hadoop0(192.168.10.7)用于 master。
- 主机名 hadoop0(192.168.10.8)用于 slave。
均添加普通用户 hadoop,并设置无密码 ssh 登录。以下为配置主机 hadoop0 为例。
网络配置¶
网络配置包括静态 IP 地址,DNS,网关和主机名配置。首先明确主机需要配置的网络信息,通常这些信息会使用主机 MAC 地址生成,并打印成铭牌附在主机上以方便定位,例如:
IP Address: 192.168.10.7
Netmask: 255.255.255.0
Gateway (Router): 192.168.10.1
DNS Server 1: 192.168.10.1
DNS Server 2: 8.8.8.8
Domain Name: hadoop
集群服务器为了保证网络的稳定性,通常使用静态 IP,而不是动态 IP ,系统默认为动态 IP 地址。
# ifconfig
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 192.168.10.8 netmask 255.255.255.0 broadcast 192.168.10.255
inet6 fe80::ed0:8205:a345:6ea1 prefixlen 64 scopeid 0x20<link>
ether 00:0c:29:d0:81:b0 txqueuelen 1000 (Ethernet)
RX packets 189442 bytes 270275757 (257.7 MiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 33656 bytes 2325644 (2.2 MiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
ifconfig 查看网口名称,如果服务器配置有多块网卡,则注意连入集群中的网卡,或者做多网卡绑定操作。这里网卡对应网口名称为 ens33。
# cd /etc/sysconfig/network-scripts
# cp -f ifcfg-ens33 ifcfg-ens33.bak # 备份原配置文件是个好习惯
编辑 ifcfg-ens33 文件如下:
# 指定网卡 MAC 地址
HWADDR=00:0c:29:d0:81:b0
TYPE=Ethernet
# 设置为静态 IP
BOOTPROTO=staitc
# 静态 IP 地址
IPADDR=192.168.10.7
# 子网地址
NETMASK=255.255.255.0
# 网关
GATEWAY=192.168.10.1
# DNS 地址
DNS1=192.168.10.1
DNS2=8.8.8.8
# 启动时激活
ONBOOT=yes
重启网卡,使新配置生效:
# systemctl restart network
测试网络连通性,可以 ping 网关,如果可以连接外网,可以 ping 外部网站,例如 www.baidu.com:
# ping -c 1 192.168.10.1
PING 192.168.10.1 (192.168.10.1) 56(84) bytes of data.
64 bytes from 192.168.10.1: icmp_seq=1 ttl=64 time=2.05 ms
配置主机名:
# 查看主机名
# hostnamectl status
Static hostname: localhost.localdomain
Transient hostname: promote.cache-dns.local
# 设置主机名
# hostnamectl set-hostname hadoop0
以上配置修改 /etc/hostname 文件,如果直接修改该文件,则需要重启才能生效,测试主机名:
# ping -c 1 hadoop0
PING hadoop0 (192.168.10.7) 56(84) bytes of data.
64 bytes from promote.cache-dns.local (192.168.10.8): icmp_seq=1 ttl=64 time=0.129 ms
主机名映射¶
通过添加内网主机名映射,可以直接使用域名互访主机。编辑 /etc/hosts,追加主机 IP 和主机名信息:
192.168.10.7 hadoop0
192.168.10.8 hadoop0
所有主机均复制相同的一份配置。
关闭防火墙¶
由于 hadoop 会提供各类网络服务用于浏览存储和处理信息,主从节点之间也需要网络通信,这些均会创建动态端口。另外集群在和外部网络连接之间均需通过企业防火墙,所以为方便配置,需要关闭防火墙。
CentOS 7 默认使用 firewall 作为防火墙:
# 查看防火墙状态
# firewall-cmd --state
running
# 停止firewall
# systemctl stop firewalld.service
# 重启防火墙使配置生效
# systemctl restart iptables.service
# 禁止firewall开机启动
# systemctl disable firewalld.service
#设置防火墙开机启动
systemctl enable iptables.service
CentOS 6 版本使用 iptables 设置防火墙,CentOS 7 也可以使用 yum -y install iptables-services 来安装 iptables 服务,
# 查看防火墙状态
# service iptables status
# 关闭防火墙
# service iptables stop
# 开启防火墙
# service iptables start
# 重启防火墙
# service iptables restart
# 关闭防火墙开机启动
# chkconfig iptables off
# 开启防火墙开机启动
# chkconfig iptables on
关闭 SELinux¶
SELinux 提供了程序级别的安全控制机制,hadoop 有些服务,例如 Ambari 需要关闭它:
# 查看 SELinux 的状态
# getenforce
Enforcing
# 查看详细信息
# sestatus
SELinux status: enabled
SELinuxfs mount: /sys/fs/selinux
SELinux root directory: /etc/selinux
......
# 临时关闭
# setenforce 0
# 设置为 enforcing 模式
# setenforce 1
永久关闭需要修改配置文件 /etc/selinux/config,将其中SELINUX 设置为 disabled 并重启系统。
时间同步¶
在集群分布模式,由于主从节点基于时间来进行心跳同步,必须进行时间同步。在进行时间设置时必须调整时区,在安装操作系统时会设定它:
# 查看时区状态
# timedatectl status
# 列出所有时区
# timedatectl list-timezones
# 将硬件时钟调整为与本地时钟一致, 0 为设置为 UTC 时间
# timedatectl set-local-rtc 1
# 设置系统时区为上海
# timedatectl set-timezone Asia/Shanghai
如果不考虑各个 CentOS 发行版的差异,可以直接这样操作:
# cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
date 命令手动指定系统时间:
# date -s "2018-05-13 12:01:30"
修改时间后,需要写入硬件 bios,这样在重启之后时间不会丢失:
# hwclock -w
如果主机可以访问外网,推荐使用 ntp 服务同步系统时间,这样时间同步比较准确:
# 命令格式 ntpdate ntp-server-ip
# ntpdate ntp1.aliyun.com
当然也可以自行在内网搭建 ntp 服务器。
系统运行级别¶
图形界面会耗费系统大量资源,为了提高性能,需要运行在非图形界面,也即多用户模式 3:
# 查看当前运行级别
# systemctl get-default
# 设置默认运行级别,graphical.target 或者 multi-user.target
# systemctl set-default TARGET.target
# 设置为多用户级别
# systemctl set-default multi-user.target
graphical.target 和 multi-user.target 分别对应 5 和 3,默认应该设置为多用户级别。
CentOS 7 默认使用 systemd 服务,可以通过 ps 查看进程,此时不再使用 /etc/inittab 文件来决定系统运行级别。
用户配置¶
基于安全考虑,大多数应用软件应该运行在普通用户状态,所以这里添加普通用户 hadoop,密码初始化为 123456:
# useradd hadoop
# passwd hadoop
Changing password for user hadoop.
New password:
BAD PASSWORD: The password is shorter than 8 characters
Retype new password:
passwd: all authentication tokens updated successfully.
给与 hadoop 用户 sudoer 权限,可以让普通用户通过 sudo 修改系统文件或执行系统命令:
# vi /etc/sudoer
## Allow root to run any commands anywhere
root ALL=(ALL) ALL
# 添加行
hadoop ALL=(ALL) ALL
# 切换用户以进行测试
[root@promote ~]# su hadoop
[hadoop@hadoop0 root]$
免密登录¶
由于 hadoop 的 shell 脚本均是通过 ssh 来统一在主从节点上执行的,其中 rsync 数据同步服务也需要 ssh 支持,所以必须配置免密码登录。
首先切换到普通用户,在所有主机上生成密钥,然后把生成的公钥分发给其他主机。
# 通过 -t 和 -P 非交互模式生成密钥
$ ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa
Generating public/private rsa key pair.
Created directory '/home/hadoop/.ssh'.
Your identification has been saved in /home/hadoop/.ssh/id_rsa.
Your public key has been saved in /home/hadoop/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:uCZ92HSkh3fvvFxp2+wS7dHIXRgS3uyQ+XEdt3tf7e0 hadoop@hadoop0
The key's randomart image is:
+---[RSA 2048]----+
| .. ..|
| ..=. =|
| . =.++o|
| . + +.o+|
| . S + ..o=*|
| . = + . .+oX|
| . = o .=*|
| o . +o++|
| ==E|
+----[SHA256]-----+
查看生成的密钥,其中 .pub 文件为公钥:
$ ll ~/.ssh/
total 8
-rw------- 1 hadoop hadoop 1675 May 25 22:07 id_rsa
-rw-r--r-- 1 hadoop hadoop 396 May 25 22:07 id_rsa.pub
所有当前主机可以免密登录的其他主机的公钥均放在 ~/.ssh/authorized_keys 文件中,本机登录自身也需要将公钥添加到 authorized_keys 信任列表文件中:
$ cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
# 测试本机登录
[hadoop@hadoop0 .ssh]$ ssh hadoop0
Last login: Sat May 25 21:14:25 2018 from hadoop0
所以可以分别复制所有 .pub 文件然后追加到某个主机的 authorized_keys 文件中,然后再分发 authorized_keys 文件。
ssh-copy-id 命令可以将本机的 .pub 追加到目标主机的 authorized_keys 文件中:
$ ssh-copy-id hadoop0
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
hadoop@hadoop0's password:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh 'hadoop0'"
and check to make sure that only the key(s) you wanted were added.
# 登录测试
hadoop@hadoop0:/home$ ssh hadoop0
Last login: Sat May 25 22:20:12 2018 from hadoop0
[hadoop@hadoop0 ~]$
由于在分布式集群模式下,hadoop 命令可以在任一主机上执行并唤醒其他主机进程,所有主机生成的 .pub 文件必须分发给所有其他主机,这样主机之间才能任意互访。
软件安装¶
由于 hadoop 使用 java 编写,需要运行在 Java 虚拟机上,首先配置 JDK 环境。
安装 JDK¶
CentOS 默认安装 OpenJDK,首先需要把它卸载掉:
[root@hadoop0 ~]# java -version
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (build 1.8.0_212-b04)
OpenJDK 64-Bit Server VM (build 25.212-b04, mixed mode)
查询 java 安装包,然后删除:
# 以下四个文件需要删除
[root@hadoop0 ~]# rpm -qa | grep openjdk
java-1.7.0-openjdk-1.7.0.111-2.6.7.8.el7.x86_64
java-1.8.0-openjdk-1.8.0.102-4.b14.el7.x86_64
java-1.8.0-openjdk-headless-1.8.0.102-4.b14.el7.x86_64
java-1.7.0-openjdk-headless-1.7.0.111-2.6.7.8.el7.x86_64
# 使用 rpm -e --nodeps 依次删除
[root@hadoop0 ~]# rpm -e --nodeps java-1.7.0-openjdk-1.7.0.111-2.6.7.8.el7.x86_64
......
# 验证删除完毕
[root@hadoop0 ~]# jave -version
bash: jave: command not found...
这里使用 1.8 版本的 Oracle 官方 64Bit JDK jdk-8u172-linux-x64.tar.gz。
[root@hadoop0 hadoop]# mkdir /lib/jdk/
[root@hadoop0 hadoop]# tar zxf jdk-8u172-linux-x64.tar.gz -C /opt/
在 /etc/profile 在中添加系统环境变量,使得所有用户均可使用;如果限定某个用户使用,则添加环境变量到对应用户的 ~/.bash_profile 文件中。
export JAVA_HOME=/opt/jdk1.7.0_80
export PATH=$PATH:$JAVA_HOME/bin
# souce 执行脚本使其立即生效
# source /etc/profile
# 验证 JDK 是否安装成功
# java -version
java version "1.8.0_172"
Java(TM) SE Runtime Environment (build 1.8.0_172-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)
安装 hadoop¶
由于 hadoop 以普通用户权限运行,所以安装时也使用普通用户,首先切换到普通用户 su hadoop。为了方便修改 hadoop 的配置文件,解压到 hadoop 用户的 home 目录下,这样可以避免使用超级用户权限修改配置文件。
[hadoop@hadoop0 ~]$ sudo tar zxf hadoop-2.7.5.tar.gz -C ~/
[sudo] password for hadoop
为 hadoop 添加环境变量,编辑 /etc/profile 文件:
[hadoop@hadoop0 ~]$ sudo vi /etc/profile export HADOOP_HOME=/home/hadoop/hadoop-2.7.5 export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin
由于 hadoop 进程均是后台启动,所以 shell 中的 JAVA_HOME 环境变量无法被读取,必须通过 etc/hadoop/hadoop-env.sh 设置:
# 设置和 /etc/profile 中保持一致:
export JAVA_HOME=/opt/jdk1.8.0_172
执行 source 命令无需 sudo 权限:
[hadoop@hadoop0 ~]$ source /etc/profile
# 验证安装环境
[hadoop@hadoop0 ~]$ hadoop version
Hadoop 2.7.5
运行模式¶
Hadoop 有三种运行模式:单机模式(Standalone Mode),伪分布模式(Pseudo-Distrubuted Mode)和全分布式集群模式(Full-Distributed Mode)。
单机模式是 Hadoop 安装完后的默认模式,无需进行任何配置。另外针对 hadoop 的所有配置均位于 etc/hadoop 中的 xml 文件中。
单机模式¶
单机模式也被称为独立模式,主要用于开发和调式,不对配置文件进行修改,不会使用 HDFS 分布式文件系统,而直接使用本地文件系统。
同样,hadoop 也不会启动 namenode、datanode 等守护进程,Map 和 Reduce 任务被作为同一个进程的不同部分来执行的,以验证 MapReduce 程序逻辑,确保正确。
官网提供了单词统计操作示例,用于验证单机模式,注意 output 文件不可以存在,否则输出报错。
[hadoop@hadoop0 ~]$ mkdir input
[hadoop@hadoop0 ~]$ cd input/
[hadoop@hadoop0 input]$ echo "hello world" > test.txt
[hadoop@hadoop0 input]$ cd ../
[hadoop@hadoop0 ~]$ hadoop jar hadoop-2.7.5/share/hadoop/mapreduce/hadoop-mapreduce-examples-2.7.5.jar wordcount input output
这里创建只包含 “hello world” 两个单词的测试文件 test.txt,以便验证结果正确性,查看 output 文件:
[hadoop@hadoop1 ~]$ cd output/
[hadoop@hadoop1 output]$ ll
总用量 0
-rw-r--r-- 1 hadoop hadoop 16 5月 26 11:54 part-r-00000
-rw-r--r-- 1 hadoop hadoop 0 5月 26 11:54 _SUCCESS
_SUCCESS 文件用于指示任务运行成功,是一个标记文件,没有内容。part-r-0000 存储结果:
[hadoop@hadoop0 output]$ cat part-r-00000
hello 1
world 1
单机模式使用本地文件系统,可以使用 hadoop fs 命令查看:
# 查看文件系统
[hadoop@hadoop0 ~]$ hadoop fs -df
Filesystem Size Used Available Use%
file:/// 8575254528 6253735936 2321518592 73%
# 当前文件夹文件列表
[hadoop@hadoop0 ~]$ hadoop fs -ls
Found 16 items
-rw------- 1 hadoop hadoop 2600 2018-05-26 11:39 .bash_history
-rw-r--r-- 1 hadoop hadoop 18 2018-10-31 01:07 .bash_logout
......
伪分布模式¶
伪分布模式在单机模式上增加了代码调试功能,允许检查内存使用情况,HDFS 命令,以及其他守护进程间交互。它类似于完全分布式模式,这种模式常用来开发测试 Hadoop 程序的执行是否正确并验证算法效率。
伪分布模式只需要一台主机,这里使用 hadoop1 主机为例。
核心配置文件 etc/hadoop/core-site.xml 配置主节点 namenode:
<configuration>
<property>
<name>fs.defaultFS</name>
<value>hdfs://hadoop1:9000</value>
</property>
<property>
<name>hadoop.tmp.dir</name>
# 此目录需配置在 hadoop 用户具有读写的目录
<value>/home/hadoop/hadooptmp</value>
</property>
</configuration>
- fs.defaultFS 属性指定 namenode 的 hdfs 协议的文件系统通信地址,格式为:协议://主机:端口。
- hadoop.tmp.dir 指定 hadoop 运行时的临时文件存放目录(tmp 文件夹已使用 mkdir 创建),例如存放助理节点数据 namesecondary。默认位置为 /tmp/hadoop-${user.name}。
hdfs-site.xml 配置分布式文件系统的相关属性:
<configuration>
<property>
<name>dfs.namenode.name.dir</name>
<value>/home/hadoop/data/name</value>
</property>
<property>
<name>dfs.datanode.data.dir</name>
<value>/home/hadoop/data/data</value>
</property>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
</configuration>
- dfs.namenode.name.dir 和 dfs.datanode.data.dir 分别配置主从节点的存储位置,默认位置为 /tmp/hadoop-${user.name}/。/tmp 是临时文件夹,空间可能会被系统回收。
- dfs.replication 属性指定每个 block 的冗余副本个数,在伪分布模式下配置为 1 即可,也即不启用副本。
yarn-site.xml 用于配置资源管理系统 yarn :
<configuration>
<property>
<name>yarn.resourcemanager.hostname</name>
<value>hadoop1</value>
</property>
<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
</property>
</configuration>
- yarn.resourcemanager.hostname 配置主资源管理器 resourcemanager 的主机名。
- yarn.nodemanager.aux-services 指明提供 mapreduce 服务。
mapred-site.xml 指定 mapreduce 运行的资源调度平台为 yarn:
# 从模板文件复制,然后编辑
$ cp -f mapred-site.xml.template mapred-site.xml
<configuration>
<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>
</configuration>
配置 salves,指定 datanode 主机名。
# salves
hadoop1
格式化 hdfs:
# 原命令 hadoop namenode -formate 被更新为
$ hdfs namenode -format
查看格式化后的 HDFS 文件系统,位于 /home/hadoop/data/name 下:
[hadoop@hadoop1 data]$ tree
.
└── name # 对应 NameNode 进程,存储主节点信息
└── current
├── fsimage_0000000000000000000
├── fsimage_0000000000000000000.md5
├── seen_txid
└── VERSION
2 directories, 4 files
fsimage 文件是 namenode 中关于元数据的镜像,也称为检查点。
最后启动伪分布式集群的进程。
$ start-dfs.sh
# 查看启动进程
$ jps
13520 Jps
12787 NameNode # 主节点进程
13396 SecondaryNameNode # 助理进程
12885 DataNode # 从节点进程
$ start-yarn.sh
$ jps
13712 Jps
13681 NodeManager # 从管理进程
12787 NameNode
13396 SecondaryNameNode
12885 DataNode
13581 ResourceManager # 主管理进程
也可以通过 WEB 页面查看进程是否启动成功(如果使用 Windows 远程管理,则需要在 hosts 中配置域名映射):
- hdfs 管理界面 http://hadoop1:50070/
- yarn 管理界面 http://hadoop1:8088/
相应的退出进程脚本为:
$ stop-dts.sh
$ stop-yarn.sh
伪分布验证¶
这里依然使用字符统计示例,在 HDFS 文件系统中创建 wordcount/input 文件夹,然后存入 test.txt 文件。
$ hadoop fs -mkdir -p /wordcount/input
$ hadoop fs -ls -R /
drwxr-xr-x - hadoop supergroup 0 2018-05-26 17:23 /wordcount
drwxr-xr-x - hadoop supergroup 0 2018-05-26 17:23 /wordcount/input
使用 put 命令追加文件:
$ hadoop fs -put test.txt /wordcount/input/
$ hadoop fs -ls /wordcount/input/
Found 1 items
-rw-r--r-- 1 hadoop supergroup 12 2018-05-26 17:30 /wordcount/input/test.txt
# 查看 HDFS 目录
[hadoop@hadoop1 data]$ tree
.
├── data # 对应 DataNode 进程,存储 block 数据
│ └── current
│ └── BP-1621093575-192.168.10.8-1558860568281
│ ├── current
│ │ ├── dfsUsed
│ │ ├── finalized
│ │ └── rbw
│ └── tmp
└── name
└── current
├── fsimage_0000000000000000000
├── fsimage_0000000000000000000.md5
├── seen_txid
└── VERSION
统计单词:
$ hadoop jar hadoop-2.7.5/share/hadoop/mapreduce/hadoop-mapreduce-examples-2.7.5.jar \
wordcount /wordcount/input/ /wordcount/output
# 查看输出结果
$ hadoop fs -ls -R /wordcount/output
-rw-r--r-- 1 hadoop supergroup 0 2018-05-26 17:40 /wordcount/output/_SUCCESS
-rw-r--r-- 1 hadoop supergroup 16 2018-05-26 17:40 /wordcount/output/part-r-00000
$ hadoop fs -cat /wordcount/output/part-r-00000
hello 1
world 1
使用 get 下载文件:
$ hadoop fs -get /wordcount/output/* output/
注意
hadoop fs 只有绝对路径的访问方式,没有相对路径的访问方式,使用 $ hadoop fs 打印所有支持的命令。
全分布模式¶
在全分布式模式下,Hadoop 的守护进程分布运行在由多台主机搭建的集群上,是真正的生产环境,所有主机组成相互连通的网络。 在主机间设置 ssh 免密码登录,把各节点生成的公钥添加到各节点的信任列表。
类似伪分布式,但是需要:
- 在所有主机上安装和配置 Hadoop 运行环境;
- 各个节点执行 hadoop 的普通用户名和用户密码均应相同。
- 时间必须同步。
全分布式的存在单节点故障问题(NameNode 节点宕机),通常不用于实际生产环境。
全分布式的配置关键点在于集群规划:各类服务进程的分配,这里以 hadoop0 和 hadoop1 两台主机为例,划分节点进程注意点:
NameNode 和 SecondaryNameNode 分布在不同主机。
DataNode 和 NodeManager 可以分布在所有主机。
ResourceManager 不应和 NameNode,SecondaryNameNode 主机分布在相同主机,以进行负载平衡,因为只有两台主机,考虑到 NameNode 负载较大,把它放在 hadoop1 主机上。
hadoop0 hadoop1 NameNode SecondaryNameNode DataNode DataNode — ResourceManager NodeManager NodeManager
Note
实际生产环境将 NameNode 单独部署在一台主机上,以提高索引速度。
根据以上集群规划配置各文件如下:
# hadoop-env.sh
export JAVA_HOME=/opt/jdk1.7.0_80
# core-site.xml
<configuration>
<property>
<name>fs.defaultFS</name>
<value>hdfs://hadoop0:9000</value> # 指定 hdfs 服务地址
</property>
<property>
<name>hadoop.tmp.dir</name>
# 此目录需配置在 hadoop 用户具有读写的目录
<value>/home/hadoop/hdata/tmp</value>
</property>
</configuration>
# hdfs-site.xml
<configuration>
<property>
<name>dfs.namenode.name.dir</name>
<value>/home/hadoop/hdata/name</value>
</property>
<property>
<name>dfs.datanode.data.dir</name>
<value>/home/hadoop/hdata/data</value>
</property>
<property>
<name>dfs.replication</name>
# 由于只有两台主机,这里配置为 2,默认为 3
<value>2</value>
</property>
<property>
<name>dfs.secondary.http.address</name>
# 配置助理运行节点 SecondaryNameNode
<value>hadoop1:50090</value>
</property>
</configuration>
# yarn-site.xml
<configuration>
<property>
# ResourceManager 运行在 hadoop1 节点上
<name>yarn.resourcemanager.hostname</name>
<value>hadoop1</value>
</property>
<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
</property>
</configuration>
# mapred-site.xml
<configuration>
<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>
</configuration>
# slaves,配置运行 DataNode 的主机名
hadoop0
hadoop1
实际操作中,首先配置 ssh 免密登录,然后在一个主机上将配置文件修改完毕后(可以将 sbin 下用于 windows 平台的 cmd 文件删除,以防命令提示时需要进行补全),通过 scp 将配置后的 hadoop 分发到其他主机上,其他配置文件如 /etc/profile 进行同样操作。
配置完毕后,进行格式化,必须在主节点上进行:
$ hdfs namenode -format
在格式化成功后,将创建 namenode 的存储数据,其中的 VERSION 文件记录了集群的 HDFS 版本信息:
hadoop@hadoop0:~/hdata/name/current$ cat VERSION
namespaceID=421950326
clusterID=CID-158c8cd0-40c7-4ebe-ab52-2e4ef69a8571
cTime=0
storageType=NAME_NODE
blockpoolID=BP-321273679-192.168.10.7-1558927063408
layoutVersion=-47
每次格式化生成的版本信息都是不同的,由 clusterID 唯一确定。
start-dfs.sh 启动可以在任意主机上操作,这里以 hadoop0 启动为例。
hadoop@hadoop0:~$ start-dfs.sh
Starting namenodes on [hadoop0]
hadoop0: starting namenode, logging to /home/hadoop/hadoop-2.7.5/logs/hadoop-hadoop-namenode-hadoop0.out
hadoop0: starting datanode, logging to /home/hadoop/hadoop-2.7.5/logs/hadoop-hadoop-datanode-hadoop0.out
hadoop1: starting datanode, logging to /home/hadoop/hadoop-2.7.5/logs/hadoop-hadoop-datanode-hadoop1.out
Starting secondary namenodes [hadoop1]
hadoop1: starting secondarynamenode, logging to /home/hadoop/hadoop-2.7.5/logs/hadoop-hadoop-secondarynamenode-hadoop1.out
# 查看 hadoop0 上进程
hadoop@hadoop0:~$ jps
17367 NameNode
17515 DataNode
17711 Jps
# 查看 hadoop1 上进程
[hadoop@hadoop1 ~]$ jps
10216 DataNode
10301 SecondaryNameNode
10398 Jps
datanode 的存储数据在启动 start-dfs.sh 时生成,其中的 VERSION 同样记录有 clusterID,
hadoop@hadoop0:~/hdata/data/current$ cat VERSION
#Mon May 27 11:30:10 CST 2018
storageID=DS-1182442983-192.168.10.7-50010-1558927457305
clusterID=CID-158c8cd0-40c7-4ebe-ab52-2e4ef69a8571
cTime=0
storageType=DATA_NODE
layoutVersion=-47
此 clusterID 必须和 namenode 中的 clusterID 一致,指明它们属于同一个集群。
Note
在全分布集群模式一旦格式化成功,不可重复格式化,否则将导致 clusterID 不一致,DataNode 进程无法启动。如确需重新格式化,应该删除所有主机上的存储信息,也即这里的 hdata 文件夹。
start-yarn.sh 必须在 yarn 的主节点上执行,这里在 hadoop1 上执行:
[hadoop@hadoop1 ~]$ start-yarn.sh
starting yarn daemons
starting resourcemanager, logging to /home/hadoop/hadoop-2.7.5/logs/yarn-hadoop-resourcemanager-hadoop1.out
hadoop0: starting nodemanager, logging to /home/hadoop/hadoop-2.7.5/logs/yarn-hadoop-nodemanager-hadoop0.out
hadoop1: starting nodemanager, logging to /home/hadoop/hadoop-2.7.5/logs/yarn-hadoop-nodemanager-hadoop1.out
# 查看 hadoop1 上进程
[hadoop@hadoop1 ~]$ jps
11075 ResourceManager
10216 DataNode
11225 Jps
11180 NodeManager
10301 SecondaryNameNode
# 查看 hadoop0 上进程
hadoop@hadoop0:~$ jps
18816 Jps
18688 NodeManager
17367 NameNode
17515 DataNode
此时的 WEB 管理界面地址如下:
- hdfs 管理界面 http://hadoop0:50070/
- yarn 管理界面 http://hadoop1:8088/
完全分布式验证与伪分布式验证完全相同,不再赘述。
不同模式配置对比¶
三种模式配置的属性列表如下:
组件名称 属性名称 单机模式 伪分布式 完全分布式 Common fs.defaultFs file:///(默认) hdfs://localhost/ hdfs://namenode HDFS dfs.replication N/A 1 2 (默认3) MapReduce mapreduce.framework.name local(默认) yarn yarn Yarn yarn.resoucemanager.hostname N/A localhost resoucemanager Yarn yarn.nodemanager.auxservice N/A mapreduce_shuffle mapreduce_shuffle
生产环境¶
实际生产环境需要集群可以持续 7*24 小时不间断提供服务,由于全分布集群模式属于一主多从架构,存在单点宕机问题(SecondaryNameNode 属于静态备份,需手动恢复,而不能热备),所以无法满足这一需求。zookeeper 模块解决了这一问题。
高可用集群¶
高可用(High Available)模式属于双主多从,有两个节点 namenode 节点,同一时间只有一个主节点处于激活(active)状态,另一主节点处于热备份状态,所以该节点也被称为 standby,两个主节点存储的数据是完全一致的。当活跃主节点失活时,standy 后备节点立刻被激活。
当原主节点重新被激活后,自动成为 standy 热备节点,不再主动成为激活节点。
高可用模式可以支撑数百台主机集群。当主机达到上千台时,主节点由于元数据激增导致压力变大,热备节点无法分担激活节点的压力。
联邦机制¶
联邦机制(federation)与高可用集群类似,同一集群中可以有多个主节点,但它们是对等的,也即同一时间可以有多个激活的主节点,它们之间共享集群中所有元数据,每个 NameNode 进程只负责一部分元数据处理,这些元数据对应不同的文件。
联邦机制也同样存在主节点宕机问题,而导致部分数据无法访问。所以当数据量极大时,需要联邦机制结合高可用集群模式,每一个主节点均有一个热备主节点。
spark¶
Spark最初由美国加州伯克利大学(UCBerkeley)的AMP实验室于2009年开发,是基于内存计算的大数据并行计算框架,可用于构建大型的、低延迟的数据分析应用程序。
2013 年 Spark 加入 Apache 孵化器项目后发展迅猛,Spark在2014年打破了 Hadoop 保持的基准排序纪录:Spark用十分之一的计算资源,获得了比 Hadoop 快 3 倍的速度。
Spark具有如下几个主要特点:
- 运行速度快:使用 DAG(Directed Acyclic Graph,有向无环图)执行引擎以支持循环数据流与内存计算。
- 容易使用:同时支持Scala、Java、Python 和 R 语言进行编程,可以通过Spark Shell进行交互式编程。
- 通用性:Spark 提供了完整而强大的技术栈,包括 SQL 查询、流式计算、机器学习和图算法组件。
- 运行模式多样:可运行于独立的集群模式中,可运行于 Hadoop 中,也可运行于Amazon EC2 等云环境中,并且可以访问 HDFS、Cassandra、HBase 等多种数据源。
spark 集群配置¶
spark 官网 http://spark.apache.org/downloads.html 下载 spark-2.4.3-bin-hadoop2.7.tgz,这里要注意匹配集群环境的 hadoop 版本。
修改 spark 运行环境变量:
$ cd spark-2.4.3-bin-hadoop2.7/conf
$ cp -f spark-env.sh.template spark-env.sh
# 在该文件中添加如下配置
export JAVA_HOME=/opt/jdk1.8.0_172
export SPARK_MASTER_IP=hadoop0
export SPARK_MASTER_PORT=7077
# 在 slaves 文件中添加所有工作节点
$ cp -f slaves.template slaves
$ cat slaves
# A Spark Worker will be started on each of the machines listed below.
hadoop0
hadoop1
注意所有工作节点上 spark 的安装位置必须相同,且进行相同如上配置。可以在一个节点配置好后,再一次打包分发。
环境变量只配置 bin 目录,由于 spark/sbin 目录下的脚本命名与 hadoop 相冲突,所以不要添加 spark 环境变量到 /etc/profile 中,而是使用绝对路径启动。
export SPARK_HOME=/home/hadoop/spark-2.4.3-bin-hadoop2.7
export PATH=$PATH:${JAVA_HOME}/bin:${HADOOP_HOME}/bin:${HADOOP_HOME}/sbin:${SPARK_HOME}/bin
# 在主节点上启动 spark 进程
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/sbin$ ./start-all.sh
# 查看进程
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/sbin$ jps
25538 Jps
25461 Worker
25355 Master
# 查看 7077 端口
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/sbin$ lsof -i :7077
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 25355 hadoop 256u IPv6 245516 0t0 TCP hadoop0:7077 (LISTEN)
spark 的工作进程 Worker 和主进程 Master 之间使用 TCP 7077 端口通信。在 hadoop1 上查看工作进程是否启动:
# hadoop1 上查看工作进程
[hadoop@hadoop1 conf]$ jps
8311 Worker
8408 Jps
# 查看 7077 端口状态
[hadoop@hadoop1 conf]$ lsof -i :7077
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 8311 hadoop 301u IPv6 55206 0t0 TCP hadoop1:42802->hadoop0:7077 (ESTABLISHED)
# 查看使用 42802 的进程 8311
hadoop 8311 5.7 12.2 5491992 124580 ? Sl 15:37 0:12 /opt/jdk1.8.0_172/bin/java -cp
/home/hadoop/spark-2.4.3-bin-hadoop2.7/conf/:/home/hadoop/spark-2.4.3-bin-hadoop2.7/jars/*
-Xmx1g org.apache.spark.deploy.worker.Worker --webui-port 8081 spark://hadoop0:7077
启动流程为 start-all.sh 分别调用 start-master.sh 和 start-slaves.sh,start-slaves.sh 调用 slaves.sh 通过 ssh 通知子节点启动 Worker 进程。
spark shell¶
本地模式¶
本地模式也称为单机模式,单机模式运行无需进行集群配置,直接执行 bin 下的 ./spark-shell 即可,常用语简单应用的验证。
# 成功运行后将进入 spark 的交互环境
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/bin$ ./spark-shell
......
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.4.3
/_/
Using Scala version 2.11.12 (Java HotSpot(TM) Server VM, Java 1.8.0_31)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
# 本地模式只启动 SparkSubmit 进程
$ jps
5606 Jps
5241 SparkSubmit
可以键入 scala> :help 查询帮助,:quit 退出交互界面。也通过 local[n] 可以指定执行线程数:
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/bin$ spark-shell --master local[4]
本地模式只启动 SparkSubmit 进程,它自身作为 Master 并启动指定个数的执行线程。
集群版启动¶
首先在 sbin 下启动 start-all.sh 启动集群服务,然后在启动 spark-shell 时,指定主节点 spark 服务的地址以启动集群服务。
# --master 参数指定集群主节点地址和端口
hadoop@hadoop0:~/spark-2.4.3-bin-hadoop2.7/bin$ ./spark-shell --master spark://hadoop0:7077
# 查询参数
$ $ ./spark-shell --help
# --executor-memory MEM 指定单个节点使用的内存数,默认 1G
#
# 如果使用虚拟机模拟集群运行,则需要限制每个节点的内存使用
$ ./spark-shell --master spark://hadoop0:7077 --executor-memory 512m
# 查看启动进程
hadoop@hadoop0:~$ jps
7236 CoarseGrainedExecutorBackend # 执行任务进程
7655 Jps
7147 SparkSubmit # 提交任务继承
6635 Worker
6524 Master
# 查看子节点进程
[hadoop@hadoop1 ~]$ jps
7474 Worker
7917 Jps
7775 CoarseGrainedExecutorBackend
可以通过浏览器访问主节点 http://192.168.10.7:8081/ 查看相关进程和任务信息。
# 查看更详细的 java 进程信息
$ jps -lvm
13456 sun.tools.jps.Jps -lvm -Denv.class.path=.:/opt/jdk1.8.0_31/lib:/opt/jdk1.8.0_31/jre/lib
-Dapplication.home=/home/red/sdc/toolchains/jdk1.8.0_31 -Xms8m
6635 org.apache.spark.deploy.worker.Worker --webui-port 8081 spark://hadoop0:7077 -Xmx1g
6524 org.apache.spark.deploy.master.Master --host hadoop0 --port 7077 --webui-port 8080 -Xmx1g
任务提交¶
可以直接在 spark-shell 交互式界面中输入 scala 程序命令,实际上它将读取的命令调用 spark-submit 进行提交,所以我们也可以使用 spark-submit 来提交一个任务。
$ ./spark-submit --class org.apache.spark.examples.SparkPi --executor-memory 512m
--master spark://hadoop0:7077
~/spark-2.4.3-bin-hadoop2.7/examples/jars/spark-examples_2.11-2.4.3.jar 10000
......
18/06/01 19:14:39 INFO TaskSetManager: Finished task 9997.0 in stage 0.0 (TID 9997) in 196 ms on 192.168.10.7 (executor 1) (9998/10000)
18/06/01 19:14:39 INFO TaskSetManager: Finished task 9986.0 in stage 0.0 (TID 9986) in 300 ms on 192.168.10.8 (executor 0) (9999/10000)
18/06/01 19:14:39 INFO TaskSetManager: Finished task 9985.0 in stage 0.0 (TID 9985) in 300 ms on 192.168.10.8 (executor 0) (10000/10000)
18/06/01 19:14:39 INFO DAGScheduler: ResultStage 0 (reduce at SparkPi.scala:38) finished in 251.837 s
18/06/01 19:14:39 INFO TaskSchedulerImpl: Removed TaskSet 0.0, whose tasks have all completed, from pool
18/06/01 19:14:39 INFO DAGScheduler: Job 0 finished: reduce at SparkPi.scala:38, took 252.849893 s
Pi is roughly 3.141681075141681
18/06/01 19:14:39 INFO SparkUI: Stopped Spark web UI at http://hadoop0:4040
18/06/01 19:14:39 INFO StandaloneSchedulerBackend: Shutting down all executors
18/06/01 19:14:39 INFO CoarseGrainedSchedulerBackend$DriverEndpoint: Asking each executor to shut down
18/06/01 19:14:40 INFO MapOutputTrackerMasterEndpoint: MapOutputTrackerMasterEndpoint stopped!
18/06/01 19:14:40 INFO MemoryStore: MemoryStore cleared
18/06/01 19:14:40 INFO BlockManager: BlockManager stopped
18/06/01 19:14:40 INFO BlockManagerMaster: BlockManagerMaster stopped
18/06/01 19:14:40 INFO OutputCommitCoordinator$OutputCommitCoordinatorEndpoint: OutputCommitCoordinator stopped!
18/06/01 19:14:40 INFO SparkContext: Successfully stopped SparkContext
18/06/01 19:14:40 INFO ShutdownHookManager: Shutdown hook called
18/06/01 19:14:40 INFO ShutdownHookManager: Deleting directory /tmp/spark-089079cd-699c-475b-bc63-3b78013bf9b6
18/06/01 19:14:40 INFO ShutdownHookManager: Deleting directory /tmp/spark-5a5af16b-a97f-4a87-8744-e6199b6c2333
spark-examples_2.11-2.4.3.jar 中提供了很多实例,这里以其中的 SparkPi 为例。spark-submit 将启动 org.apache.spark.deploy.SparkSubmit 进程。
上例中可以看到只要指定任务 *.jar 和 jar 中的主程序名 org.apache.spark.examples.SparkPi 即可,所以我们只要编写自己的任务 .jar 文件即可进行提交执行。
spark 任务创建¶
安装 scala¶
因为 Scala 是运行在JVM平台上的,所以安装 Scala 之前要安装 JDK,注意安装时路径不要有空格或者中文。
访问 Scala官网 下载 Scala 编译器安装包,由于目前大多数框架都是用 2.10.x 编写开发的,推荐安装 2.10.x 版本,Windows 平台直接下载 scala-2.10.6.msi 安装即可,会自动配置环境变量。
Scala 安装包会自动添加环境变量,直接验证安装环境:
E:\>scala -version
Scala code runner version 2.10.6 -- Copyright 2002-2018, LAMP/EPFL and Lightbend, Inc.
Linux 环境下载 .tgz 文件,解压后在 /etc/profile 下修改环境变量
# 解压缩
$ tar -zxvf scala-2.10.6.tgz -C /opt/
vi /etc/profile
export JAVA_HOME=/opt/jdk1.8.0_172
export PATH=$PATH:$JAVA_HOME/bin:/opt/scala-2.10.6/bin
Idea和Maven环境配置¶
Idea 是用户开发 Java 项目的优秀IDE,由于 spark 使用 scala 语言开发,需要安装 scala 插件以支持 spark 开发。
从 http://www.jetbrains.com/idea/download/ 下载社区免费版并安装,由于 Idea 启动时加载比较慢,建议安装在固态硬盘,安装时如果有网络可以选择在线安装 scala 插件。
如果网速较慢,可以选择离线安装,从地址 http://plugins.jetbrains.com/?idea_ce 搜索 Scala 插件,然后下载。
首次启动 Idea 安装Scala插件:Configure -> Plugins -> Install plugin from disk -> 选择Scala插件 -> OK -> 重启IDEA。
如果当前已经进入 Idea,可以通过 File->Settings 搜索 Plugins 标签页,在标签页面右下角选择 Install plugin from disk,然后从本地磁盘安装插件。
maven 用于自动配置软件包依赖。它的下载地址,http://maven.apache.org/download.cgi。maven 安装过程:
- 解压 maven 安装包,例如 E:sparkapache-maven-3.6.1。
- 配置本地类库路径,例如 E:sparkmaven_repo,打开配置文件 E:sparkapache-maven-3.6.1confsettings.xml 做以下配置:
# 指定本地类库路径
<localRepository>E:\\spark\\maven_repo</localRepository>
# 添加 aliyun 镜像,加速类库下载
<mirrors>
<mirror>
<id>nexus-aliyun</id>
<mirrorOf>*</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>
</mirrors>
# 配置 JDK 版本 1.8,和系统中安装 JDK 版本一致
<profile>
<id>jdk-1.8</id>
<activation>
<jdk>1.8</jdk>
</activation>
<repositories>
<repository>
<id>jdk18</id>
<name>Repository for JDK 1.8 builds</name>
<url>http://www.myhost.com/maven/jdk14</url>
<layout>default</layout>
<snapshotPolicy>always</snapshotPolicy>
</repository>
</repositories>
</profile>
Idea 配置 maven:File->Settings 搜索 Maven 标签页,进行如下配置:
单词统计¶
首先使用 spark-shell 交互环境,进行单词统计,以验证 spark 在 hadoop 环境的运行是否正常。
# 启动 hdfs 服务
$ start-dfs.sh
# 查看 hdfs 路径验证 hdfs 服务
$ hadoop fs -ls
# 启动交互式 spark 环境
$ spark-shell --master spark://hadoop0:7077 --executor-memory 512m
# 进入交互环境,交互环境中自动创建上下文句柄 sc,使用 textFile 方法打开 hdfs 路径或文件
scala> val textFile = sc.textFile("hdfs://hadoop0:9000/input/")
textFile: org.apache.spark.rdd.RDD[String] = hdfs://hadoop0:9000/input/ MapPartitionsRDD[5] at textFile at <console>:24
# 查看文件数
scala> textFile.count
res2: Long = 1
# 统计单词
scala> val wordCount = textFile.flatMap(line => line.split(" ")).map(word => (word, 1)).reduceByKey(_+_)
wordCount: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[8] at reduceByKey at <console>:25
# 对结果进行排序
scala> wordCount.sortBy(_._2, ascending=false)
res3: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[13] at sortBy at <console>:26
# 打印统计信息
scala> wordCount.collect()
res4: Array[(String, Int)] = Array((hello,1), (world,1))
以上均是使用 spark 原生支持的 scala 语言提交任务, pyspark 提供了 python 接口,让应用更容易。
远程提交¶
远程任务提交可以通过 ssh 登录主节点,然后运行 spark-shell 或者 spark-submit 提交任务。这是推荐的做法。
另一种方式是通过 spark-shell 使用 master 参数指定远程主节点,这种方式比较麻烦。
- 首先,登录主机需要配置 spark 运行环境,这包括 jdk,scale,hadoop 以及 spark (SPARK_HOME 和 PATH)自身的环境变量。
- 由于 spark 连接依靠域名(即便配置了 SPARK_LOCAL_IP 为 IP 地址依然会被解析为域名),所以必须将登录主机的域名添加到所有节点上,所以如果没有配置域名服务器,这种操作将很繁琐。
- 关闭登录主机的防火墙。
spark 需要配置 spark-env.sh 和 slaves,网络链接导致的错误会打印大量资源不足信息:
WARN TaskSchedulerImpl: Initial job has not accepted any resources; check your
cluster UI to ensure that workers are registered and have sufficient resources
此时应通过主节点 http://hadoop0:8080/ 进入 Running Applications ,然后查看 Executor 的 stderr 日志来判断具体错误原因。
通常开发环境位于 Windows 上,此时使用本地模式来测试代码,验证无误后,再 ssh 登录到远程主机提交任务。
pyspark¶
pyspark 是 Spark 提供的一个 Python_Shell,可以以交互的方式使用 Python 编写并提交 Spark 任务。它位于 spark 安装文件的 bin 目录下,所以一旦配置了 SPARK_HOME 环境变量,并添加 bin 目录到 PATH 环境变量就可以直接运行 pyspark 了。
需要注意的是如果系统中有多个版本的 Python,那么需要指定 pyspark 使用的版本:
$ which python3
/usr/bin/python3
# /etc/profile 指定 pyspark 使用的 python 版本
export PYSPARK_PYTHON=/usr/bin/python3
# 使用ipython3作为默认启动交互界面
export PYSPARK_DRIVER_PYTHON=ipython3
$ pyspark
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 2.4.3
/_/
Using Python version 3.4.3 (default, Nov 12 2018 22:20:49)
SparkSession available as 'spark'.
In [1]:
当然也可以使用 jupyter-notebook 作为交互界面:
# 安装 jupyter
$ pip3 install jupyter
# 配置 juypter
$ jupyter notebook --generate-config
# 生成配置文件位于用户home 下 /home/hadoop/.jupyter/jupyter_notebook_config.py
# 配置 notebook 的工作目录
c.NotebookApp.notebook_dir = '/home/hadoop/notebooks'
# 配置 jupyter 登录密码
$ jupyter notebook password
# 解决 jupyter 权限 bug
$ unset XDG_RUNTIME_DIR
# 指定 ip 和 port 可以远程访问 jupyter 进行 pyspark 操作
export PYSPARK_DRIVER_PYTHON=jupyter
export PYSPARK_DRIVER_PYTHON_OPTS="notebook --no-browser --ip 192.168.10.7 --port 10000"
使用 jupyter 作为交互界面,启动后日志提示如下:
$ pyspark
[I 13:35:15.689 NotebookApp] Serving notebooks from local directory: /home/hadoop/notebooks
[I 13:35:15.689 NotebookApp] The Jupyter Notebook is running at:
[I 13:35:15.689 NotebookApp] http://192.168.10.7:10000/
[I 13:35:15.689 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).
此时可以通过 http://192.168.10.7:10000/ 访问 jupyter notebook。
附录¶
参考书目¶
机器学习相关¶
- Deep Learning Book by Ian GoodFellow
- Neural Networks and Deep Learning (Michael Nielsen)
- Distill Clear Explanations of machine learning
- 最新论文发布
- CS231 斯坦福大学汇总
- CS224n:深度自然语言处理课程
- 吴恩达 CS229 课程讲义中文翻译
- cs229 Notes
- ml-cheatsheet
- 莫烦PYTHON
- Keras 参考文档
- scipy-lectures
- scikit-learn和tensorflow的区别
- 主流机器学习框架对比
- 判断欠拟合还是过拟合
- 马里兰大学,机器学习课程资料
- 机器学习
- 标准化和归一化详解
- Artificial Intelligence, Deep Learning, and NLP
- 常用代价函数
NLP 自然语言相关¶
数学相关¶
图像相关¶
manim 环境配置¶
嵌入式相关¶
LaTeX数学表达式¶
此行必须添加,否则后序表达式出错:
# 分段函数 .. math:
P(y^i|x^i;w) = \left\{ \begin{array}{ll}
\phi (z^i) & \textrm{$y^i=1$}\\
1 - \phi (z^i) & \textrm{$y^i=0$}\\
\end{array} \right.
This: \((x+a)^3\) This: \((x+a)_3\)
this: \(W \approx \sum{f(x_k) \Delta x}\)
this: \(W = \int_{a}^{b}{f(x) dx}\)
\(\sqrt{x}\),不好处理
inline \(\frac{ \sum_{t=0}^{N}f(t,k) }{N}\) inline
and this:
# 多公式对齐,&号相当于表格分隔符,\ 用于换行
When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\) and they are \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\)
其他语法¶
驱动器 C 中的卷是 系统专区
卷的序列号是 78E7-2220
注意
任何对文件的读取和写入动作,都会自动改变文件的指针偏移位置。
重点(emphasis)通常显示为斜体
重点强调(strong emphasis)通常显示为粗体
解释文字(interpreted text)通常显示为斜体
时间: | 2016年06月21日 |
---|
- 枚举列表1
- 枚举列表2
- 枚举列表3
- 枚举列表1
- 枚举列表2
- 枚举列表3
- 枚举列表1
- 枚举列表2
- 枚举列表3
下面是引用的内容:
“真的猛士,敢于直面惨淡的人生,敢于正视淋漓的鲜血。”
—鲁迅
“人生的意志和劳动将创造奇迹般的奇迹。”
—涅克拉索
0 1 2 | def AAAA(a,b,c):
for num in nums:
print(Num)
|
-a | command-line option “a” |
-b file | options can have arguments and long descriptions |
--long | options can be long also |
--input=file | long options can also have arguments |
/V | DOS/VMS-style options toofdsfds
fdsafdsafdsafsafdsafsa
fdsafdsafsd
|
John Doe wrote:
>> Great idea!
>
> Why didn't I think of that?
You just did! ;-)
A one, two, a one two three fourHalf a bee, philosophically,must, ipso facto, half not be.But half the bee has got to be,vis a vis its entity. D’you see?But can a bee be said to beor not to be an entire bee,when half the bee is not a bee,due to some ancient injury?Singing…
col 1 | col 2 |
---|---|
1 | Second column of row 1. |
2 | Second column of row 2. Second line of paragraph. |
3 |
|
Row 4; column 1 will be empty. |
功能
- 你好 list item. The blank line above the first list item is required; blank lines between list items (such as below this paragraph) are optional.
函数
你好 is the first paragraph in the second item in the list.
This is the second paragraph in the second item in the list. The blank line above this paragraph is required. The left edge of this paragraph lines up with the paragraph above, both indented relative to the bullet.
- This is a sublist. The bullet lines up with the left edge of the text blocks above. A sublist is a new list so requires a blank line above and below.
原始文本块内的任何标记都不会被转换,随便写。
`Bary.com <http://www.bary.com/>`_
这还会显示在原始文本块中。
缩进都会原样显示出来。
只要最后有空行,缩进退回到 :: 的位置,就表示退出了\ `原始文本块`_。
会自动把网址转成超链接,像这样 http://www.bary.com/ ,注意结束的地方要跟空格。
如果你希望网址和文本之间没有空格,可以用转义符号反斜杠 \ 把空格消掉,由于反斜杠是转义符号,所以如果你想在文中显示它,需要打两个反斜杠,也就是用反斜杠转义一个反斜杠。
渲染后紧挨文本和句号的超链接http://www.bary.com/。
其实遇到紧跟常用的标点的情况时,不需要用空格,只是统一使用空格记忆负担小。你看http://www.bary.com/,这样也行。
Note
写完本文我发现我用的渲染器对中文自动消除了空格,行尾不加反斜杠也行,但我不保证其他渲染器也这么智能,所以原样保留了文内的反斜杠。
如果希望硬断行且不自动添加空格(例如中文文章),在行尾添加一个反斜杠。折上去的部分就不会有空格。注意所有的硬换行都要对齐缩进。
打开模式 | r | r+ | w | w+ | a | a+ |
---|---|---|---|---|---|---|
可读 | ||||||
可写 | ||||||
创建 | ||||||
覆盖 | ||||||
指针在开始 | ||||||
指针在结尾 | ||||||
以空格作分隔符,间距均匀。决定了这个表格最多可以有5列,下划线的长度应不小于字符长度。 每一行的下划线,决定了相应列是否合并,如果不打算合并列,可以取消表内分隔线
11 12 | 13 14 15 | |||
21 | 22 | 23 | 24 | 25 |
31 | 32 33 | 34 35 | ||
41 42 42 44 45 |
Date: | 2001-08-16 |
---|---|
Version: | 1 |
Authors: |
|
Indentation: | Since the field marker may be quite long, the second and subsequent lines of the field body do not have to line up with the first line, but they must be indented relative to the field name marker, and they must line up with each other. |
Parameter i: | integer |