下面我们实现一个感知机模型。感知机模型需要哪些参数呢?首先,需要知道输入的维度,或者说每个输入样本作为向量的长度,用参数 input_size 表示。然后,还要设置其他的 元参数 ,所谓元参数,就是参数之上的参数。感知机模型本身可以学习的参数是输入连接的权值,然而,还有两个元参数可以控制学习权值参数的过程,这两个元参数就是学习率和训练次数,用 alpha 和 n_iter 表示。
在确定了输入维度之后,我们就知道了权值的数量,可以初始化权值了。权值可以初始化为0,也可以初始化为随机值。对于感知机模型来说,这个选择并不是非常重要。但是,对于更加复杂的模型,比如深度神经网络来说,权值的初始化很可能影响模型训练的难度。当我们把梯度下降算法想象为小球滚落山谷,如果恰好小球落入一个海拔较高的盆地,那么,它很可能陷在里面而没有机会到达更深的谷底。如果有一类问题,由于其本身的某些特性,使得误差函数构成的曲面在权值为0的地方总是形成一个海拔较高的盆地,那么,用0初始化权值就会使得这类问题难以训练出好的模型。我们选择将权值初始化为随机值,这样可以在概率上规避类似的问题。
import numpy class Perceptron(object): def __init__(self, input_size, alpha, n_iter): #产生长度为(input_size+1)的随机向量作为初始权重 #将bias视作最后一个权重 #随机值取在[0,1]区间上,减去0.5使随机值的期望变成0 #即初始权重是0附近的小随机数 self.weight=numpy.random.rand(input_size+1)-0.5 #学习率 self.alpha=alpha #迭代次数 self.n_iter=n_iter
下面,我们实现感知机的计算过程。值得注意的是,我们仿照scikit-learn将感知机用面向对象的编程方法实现为一个类,所以,所有函数都是缩进在类定义里面的,而不是独立的函数。另外,我们对偏置的处理通过一个小技巧进行了简化。在感知机的计算过程中,需要计算输入的加权和。
我们不希望单独处理偏置b,而是将它作为一个普通的权重。于是,我们增加一个权重w N+1 =b,相应地,增加一个输入维度x N+1 =1,这个输入维度取值恒等于1。这样,我们就简化了对偏置的处理,只需要处理权重的计算即可。
下面的 predict 函数可以批量处理多组输入样本,每个样本作为矩阵 X 的一行,因此,我们在矩阵右侧添加了一列常量1。加权求和的过程可以视作矩阵 X 和权值向量(视作列向量)的矩阵乘法。这样,我们批量计算出了每个样本对应的加权和,然后通过激活函数得到每个样本对应的输出值。
class Perceptron(object): ……#此处省略前面列出的部分类定义 #感知机的计算过程 def predict(self, X): #在每一行数据之后增加一列常量1 #该常量与最后一个权重相乘作为bias #这样我们不需要单独处理bias #参数((0,0),(0,1))中, #第1组(0,0)表示在第1维(行)前后不补齐数据 #第2组(0,1)表示在第2维(列)之前不补齐数据,在之后补齐1列 #补齐方式是'constant'即常量,常量值为1 X=numpy.pad(X, ((0,0),(0,1)), 'constant', constant_values=1) #然后将X和权重做矩阵乘法 Y=numpy.matmul(X, self.weight) #经过激活函数后输出结果 return self.sigmoid(Y) def sigmoid(self, X): return 1/(1+numpy.exp(-X))
如同感知机的计算过程一样,感知机的训练过程也是批量进行的。在前面介绍的权值更新公式推导过程中,我们使用了单个样本误差的导数进行计算。实际上,使用所有样本总体误差的导数更为合理。每次只利用一个样本的误差信息确定权值修正的方向,就如同盲人摸象,有时触碰到鼻子,有时触碰到象腿,无法产生对全局的正确认识。只有样本总体误差的导数才能产生真实正确的权值更新方向。由于“加法和”的导数恰好等于导数的加法和,我们可以将每个样本的权值更新量求均值作为最后的权值更新量。
当我们不采用批量训练的策略,而是使用单个样本逐个训练感知机时,如果学习率设置得足够小,也能近似地等效于使用了样本总体误差。然而,这会使得训练的速度大打折扣。当我们面临比较大的数据集时,一次性计算样本总体误差常常需要消耗较长时间,这也会影响训练的速度。这时,我们可以将原数据集分割为大小适当的“批次”(batch),每次投入一个批次进行训练,这样既能够保证对数据有全局的认识,又避免了计算过程过于耗时。这就如同在大象身上不同部位多摸几次之后,就能够比只摸某个局部所取得的认识更加全面准确,相当于对大象身体的各个部位进行了采样。每个“批次”就是对样本总体的一组采样。这种策略对于很多模型,特别是神经网络模型,是非常必要而且非常有效的。
class Perceptron(object): ……#此处省略前面列出的部分类定义 #感知机的训练过程 def fit(self, X, Y): #仍然要处理输入,增加一列常量1 X=numpy.pad(X, ((0,0),(0,1)), 'constant', constant_values=1) Y=numpy.array(Y) #重复n_iter次训练过程 #每次叫作一个iteration或者一个epoch for i in range(self.n_iter): #计算当前输出 y=self.sigmoid(numpy.matmul(X, self.weight)) #计算权值更新 #将输出偏差整形为列向量,以便与输入对应行相乘delta_y=numpy.reshape(Y-y, (-1,1)) #这是激活函数的导数部分 deriv_y=numpy.reshape(y*(1-y), (-1,1)) #注意下面是每行对应相乘,而不是矩阵乘法 #这样我们实际上对每一行数据都得到了对应的权值更新量 delta_w=delta_y*deriv_y*X*self.alpha #由于我们批量计算出了所有样本产生的权值更新量 #因此,我们需要对权值更新量进行平均 #对所有样本进行平均,因此取平均值的维度是第1维 delta_w=numpy.mean(delta_w, axis=0) #然后更新权值 self.weight=self.weight+delta_w #输出平均误差,帮助我们观察训练的过程 print('第{0}轮误差为:{1}'.format( i, numpy.mean(numpy.power(delta_y, 2))))
下面我们可以让感知机做一些简单的事情,比如,区分输入的两个数的大小。当输入的第1个数较小时,目标输出为1;当输入的第1个数较大时,目标输出为0。我们准备了6组数字,4组作为训练样本,2组用于测试。
perceptron=Perceptron(2, 0.5, 100) perceptron.fit([[1,2],[2,3],[4,3],[3,2]],[1,1,0,0]) print(perceptron.predict([[3,4],[2,1]])) #输出样例:[0.72107778 0.24065793] #表明感知机区分出了两个输入数字的大小差别 print(perceptron.weight) #输出样例:[-1.53422978 1.15957956 0.84045471] #这说明感知机输出0.5的位置是直线:-1.53 x_1+1.16 x_2+0.84=0
在上述训练过程中,我们输出了每一次迭代时的平均误差。因此,我们可以观察到误差逐渐下降的过程,这个过程在训练的开始阶段下降较快,随着模型逐渐收敛,误差的下降速度减缓,如图3.5所示。对于感知机能够处理的数据,模型会收敛在一个较小的误差,否则,就会收敛在一个较大的误差,甚至产生波动而不收敛。
图3.5 感知机的训练误差随着迭代次数的变化
感知机能够处理什么样的问题呢?观察刚刚训练得到的感知机的权重,当加权和为0的时候,Sigmoid的函数输出值为0.5,这就是感知机能够分类的数据的边界。显然,这个边界是一条直线,如图3.6所示。对于上面这个简单的区分数字大小的问题,输入维度是2,感知机的分类边界在x 1 w 1 +x 2 w 2 +b=0这条直线上。当问题的维度变高时,这个直线就变成了平面,或者高维空间中的超平面。但是,它们都具有同样的性质,就是它们都是输入的线性函数。这就是感知机能力的“天花板”。
图3.6 感知机的分类边界是一条直线
在后面的章节中,我们会更加深入地了解这个问题。我们会看到一些非线性问题的例子,然后通过把感知机组成神经元网络,突破这种线性的限制。
如同决策树等模型一样,我们可以用scikit-learn软件包中提供的接口来实现感知机的训练。该软件包不仅提供了感知机模型的编程接口,还提供了一些常用数据集,可供读者进行一些实验和验证。
下面我们用感知机来完成一个视觉识别任务——识别手写数字。比较常用的手写数字数据集是美国国家标准与技术研究院的MNIST数据集 。而scikit-learn软件包中提供的是加州大学尔湾分校(UCI)的数据集 。
UCI的手写数字数据集包含1797张8×8像素灰度图片,每个图片是一个手写的数字,如图3.7所示。为了使它能够作为感知机的输入,我们把每张图片展开成长度为64的向量。向量的值表示像素的灰度,通常我们会把数值归一化到[0,1]这个区间,防止过大的值给感知机学习权重带来不必要的难度。较大的输入值通常意味着需要较大的权重。同时在训练感知机的过程中,权重的更新量也受到输入大小的影响,过大的输入可能会使得权值更新幅度过大,造成模型不稳定。
图3.7 UCI手写数字数据集中的一些样本
下面我们通过scikit-learn软件包加载手写数字数据集,并显示其中一些手写数字样本。我们使用matplotlib 软件包来显示数据集中的图片。
import matplotlib.pyplot as plt from sklearn.datasets import load_digits #加载UCI手写数字数据集 digits=load_digits() for i in range(10): #显示1行10列中的第i+1个图 plt.subplot(1,10,i+1) plt.imshow(digits.images[i]) #隐藏坐标轴刻度 plt.axis('off') plt.show()
我们要把图像的每个像素点作为一个输入维度,因此,分类手写数字这个任务的输入维度比我们之前看到的各种分类任务都要高。这个问题看似稍有些难度。然而,由于每两个数字之间在一些特定区域都有显著的不同之处,在所有像素点构成的高维空间里(对于UCI数据集来说是64维空间),这些数字其实分布在几乎线性可分的不同区域。所以,分类手写数字这个任务恰好落在感知机的能力范围之内。
单个感知机的输出通常可以用来区分两个类别:超过某阈值表明输入样本属于类别A,低于该阈值则表明属于类别B。通常,我们不会采用单一输出来区分更多类别,即使激活函数的输出值是连续的,也通常仅用来表示两种状态,因为输出值通常集中于0或者1附近,0和1的中间部分不具有很好区分更多类别的解析度。而且,将类别嵌入连续值表示的区间上,隐含了类别之间的顺序关系,然而这种强加的顺序关系通常会给模型的训练徒增难度。
我们采用10个感知机分别识别10个不同的数字。每个感知机的输出用于区分是或者不是某个数字。如果输入图像包含某个数字i,那么,我们希望第i个感知机的输出接近1,而其他感知机的输出接近0。当几个感知机对同一个输入都给出接近1的输出时,我们选取输出值最大的那个感知机所代表的数字。
读者可以尝试采用我们前面实现的感知机,为每个数字建立一个感知机进行训练。这里,我们介绍使用scikit-learn中的感知机模型,因为该模型能够简化我们的操作。在scikit-learn软件包中,感知机 Perceptron 的训练函数 fit 输入的目标值 y 并不是单个感知机输出的目标值,而是样本的类别。这个类别可以是数值,也可以是字符串标签。模型会根据输入 y 中不同值的数量,确定不同类别的个数,从而分别建立感知机。对于手写数字识别的实验,模型会建立10个感知机,用来构造和训练10组权值。
from sklearn.linear_model import Perceptron from sklearn.datasets import load_digits # X是1797行、64列的矩阵 #每一行是一个手写数字样本 #图像中的像素被拉平展开为长度为64的行向量 # y是长度为1797的数组,包含样本对应的数字值 X, y=load_digits(return_X_y=True) #创建感知机模型 # max_iter是最大迭代次数 # tol参数可以控制当误差不再减小时提前结束训练 #当本轮误差减去前一轮的差值大于tol时,结束训练 # eta0是学习率 perceptron=Perceptron(max_iter=1000,tol=0.001,eta0=1) #与我们实现的感知机模型的训练方法略有不同 #这里y值表示样本的类别 #根据y中不同值的数量(也就是类别数量)分别建立若干感知机 perceptron.fit(X,y)
当感知机训练完成后,我们把每个像素点对应的权值重新按照像素点位置排列起来,将权值大小转换为颜色(权值越小颜色越偏向红色,权值越大颜色越偏向蓝色,中间值为白色),就可以观察到感知机是如何工作的了。在scikit-learn软件包训练出的感知机中, coef_ 属性包含了感知机的权值向量。当分类数量为2时,只需要一个感知机,那么该属性包含一个向量。当分类数量大于2时,该属性为矩阵,矩阵的行数即类别的数量,每一行为该类别对应的感知机权值向量。我们用手写数字数据集训练出的模型的权值矩阵包含10行,这说明我们训练得到了10个感知机。将这些感知机的权值画成图像,可以明显地看到,权值的分布大体反映了数字笔画的平均走向,我们依稀可以从中看出数字的轮廓,如图3.8所示。
图3.8 UCI手写数字数据集训练出的感知机权重图( 见彩插 )
import numpy import matplotlib.pyplot as plt for i in range(10): #显示1行10列中的第i+1个图 plt.subplot(1,10,i+1) #显示第i个类别对应的感知机权值 #将权值向量整形为矩阵和输入图像像素位置对应 #使用红蓝颜色表RdBu,红色表示负值,蓝色表示正值 plt.imshow(numpy.reshape(perceptron.coef_[i,:], (8,8)), cmap=plt.cm.RdBu) #隐藏坐标轴刻度 plt.axis('off') plt.show()
下面,我们取数据集的前10个样本,观察感知机对应的输出。我们通过 coef_ 和 intercept_ 属性得到感知机的权值和偏置,用矩阵乘法批量计算出10个样本对应的感知机输出,每行表示一个感知机,每列表示一个样本。由于感知机的输出过于接近0或者1,为了显示出它们接近0或者1的程度上的细微差别,我们对输出进行了缩放。我们采用σ(t)=(1+e −t/1000 ) −1 作为激活函数,这是一个横向拉伸了1000倍的Sigmoid函数,可以更明显地观察到输出值的差异。
import numpy #取数据集的前10个样本验证感知机的输出 #感知机的权值,每行表示一个感知机的权值 w=perceptron.coef_ #感知机的偏置,列向量,每行表示一个感知机的偏置 b=perceptron.intercept_ #对数据集的前10个样本,计算每个感知机的输出 #将数据转置,每列表示一个样本 x=numpy.transpose(X[0:10,:]) out=numpy.matmul(w,x)+b #由于输出太接近0或者1,我们进行了缩放,以显示差异 out=1/(1+numpy.exp(-out/1000)) for i in range(10): print(list(map(lambda x:'{0:.2f}'.format(x), out[i,:])))
将手写数字图片输入感知机后得到的输出值如表3.1所示。输出值较大,说明对应的感知机识别出了图片中的数字。取10个感知机输出中最大的那个,也就是表中每一列的最大值,就可以用来对手写数字进行分类。从前10个样本的输出结果可以看到,大部分数字都能够根据感知机的输出进行正确分类,只有数字5被误认为数字1。读者可以采用更多的样本进行试验,验证感知机输出结果的正确性。
表3.1 10个感知机对UCI手写数字数据样本的输出值