本文将介绍使用Python书写“卷积神经网络”代码的具体步骤和细节,本文会采用Python开源库Theano,Theano封装了卷积等操作,使用起来比较方便。具体代码可参考神经网络和深度学习教程。在本文中,卷积神经网络对MNIST手写数字的预测性能,测试集准确率可以达到99.30%。
Theano安装
Anaconda
Theano依赖于numpy等库,故首先安装集成包Anaconda,这个软件内嵌了python,并且还包含了很多用于计算的库。本文使用的是4.3.24版本,内置python为2.7.13版本。下载地址为:https://www.continuum.io/downloads
MinGW
Theano底层依赖于C编译器。一种方式是,在控制台输入conda install mingw libpython即可。注意libpython也必须要安装,否则后面导入theano后会报“no module named gof”的错误。另一种也可以手动去MinGW安装包安装,但libpython仍然需要安装。
Theano
最后安装Theano,使用pip install theano即可。
数据集加载
本文使用的数据集同样是来自MNIST手写数字库。
1 | def load_data_shared(filename="../data/mnist.pkl.gz"): |
这里使用了theano的shared变量,shared变量意味着shared_x和shared_y可以在不同的函数中共享。在使用GPU时,Theano能够很方便的拷贝数据到不同的GPU中。borrow=True代表了浅拷贝。
卷积神经网络结构
本文采取的神经网络结构如下:
输入层是5万条手写数字图像,每条数据是28*28的像素矩阵。然后在右边插入一个卷积层,使用5*5的局部感受野,跨距为1,20个特征映射对输入层进行特征提取。因此得到20*(28-5+1)*(28-5+1)=20*24*24规格的卷积层,这意味着每一副图像经过特征映射后都会得到20副24*24的卷积结果图像。接着再插入一个最大值混合层,使用2*2的混合窗口来合并特征。因此得到规格为20*(24/2)*(24/2)=20*12*12规格的混合层。紧接着再插入一个全连接层,有100个神经元。最后是输出层,和全连接层采用全连接的方式,这里使用柔性最大值来求得激活值。
运行代码
让我们先总体看一下最终运行的代码:
1 | training_data,validation_data,test_data=load_data_shared() |
可以看到运行的代码包括加载数据集、构建神经网络结构、运行学习算法SGD。下面将介绍不同层的代码细节。
卷积层和混合层
下面将描述运行代码中的细节。首先介绍卷积层和混合层的代码。这里面将卷积层和混合层统一封装,使得代码更加紧凑。首先看一下代码:
1 | class ConvPoolLayer(object): |
初始化
首先看一下初始化方法。其中参数filter_shape是一个四元组,由卷积核(filter)个数、输入特征集(input feature maps)个数、卷积核行数、卷积核的列数构成。这里的滤波器个数对应的是前面图中的20,也就是使用20个卷积核进行特征提取。行数和列数大小也就是局部感受野的大小。而输入特征集个数代表的是输入数据,例如一般图都有3个通道Channel,每个图初始有3张feature map(R,G,B),对于一张图,其所有feature map用一个filter卷成一张feature map。用20个filter,则卷成20张feature map。本文的数据使用的是手写图像的灰度值,因此只有1个feature map。image_shape参数也是一个四元组,由随机梯度下降选择的样本量mini_batch_size,输入特征集个数,每张图的宽度和高度构成。poolsize是卷积层到混合层使用的混合窗口大小。
首先进行参数的保存,前面的层使用的是sigmoid激活函数。n_out在初始化权重的时候指定方差使用,在本文中n_out=125,具体该数值含义目前不是非常理解。np.random.normal(loc=0, scale=np.sqrt(1.0/n_out), size=filter_shape使用高斯分布初始化了一个四维数组,规格为(20L,1L,5L,5L),由于一个特征映射有5*5=25个参数,则20个特征映射
就有500个权重参数。np.random.normal(loc=0, scale=1.0, size=(filter_shape0,))初始化偏置值,每个特征映射只需要对应1个偏置,20个特征映射需要20个偏置即可。可以看出这里的参数个数总共有520个。如果不使用共享权重的方式,而是使用全连接的话,若隐藏层有30个神经元,则有784*30+30=23550个参数,几乎比卷积层多了40倍的参数。使用shared变量包装w和b后,w就是一个TensorType
卷积操作
set_inpt方法会根据参数来计算当前层的输出。参数inpt是前一层的输出,参数inpt_dropout是经过弃权策略后的前一层的输出。self.inpt = inpt.reshape(self.image_shape)将输入数据按照(10L,1L,28L,28L)的规格进行重塑。10代表每个小批量有10条数据,1L代表只有1个输入特征集,28*28则代表图像的大小。
接着是卷积操作:
1 | conv_out = conv.conv2d(input=self.inpt, filters=self.w, filter_shape=self.filter_shape,image_shape=self.image_shape) |
使用的是theano.tensor.nnet.conv封装好的conv2d卷积操作方法。对于该方法的理解,下面通过一个例子来理解。
1 | import numpy as np |
上述使用两个卷积核,因此对于每幅图,有两个输出。对于第一个卷积结果[ 6. 10. 14. 18.],是如下计算得到的:
利用第一个卷积核前半部分[1,1]扫描第一个通道数据,根据线性组合wx+b,得到[1*1+1*2,1*2+1*3,1*3+1*4,1*4+1*5]=[3,5,7,9];同理利用第一个卷积核后半部分[1,1]扫描得到[3,5,7,9];两个向量相加得到[6. 10. 14. 18.]
同样,对于第二个卷积结果[ 9. 15. 21. 27.]。利用第二个卷积核扫描两个通道的数据,再相加即可得到。
池化操作
1 | pooled_out = pool.pool_2d( |
对卷积结果进行池化。这里使用的是最大池化(混合)方法。使用poolsize大小的窗口扫描卷积后的结果,取poolsize范围内的最大值作为结果。我们可以把最大值混合看作一种网络询问是否有一个给定的特征在一个图像区域中。然后扔掉确切的位置信息。直观上,一旦一个特征被发现,它的确切位置并不如它相对于其它特征的位置重要。同时这有助于减少在以后的层所需的参数的数目。
激活操作
接着使用激活函数来求得输出。由于池化后的结果仍然是四维的TensorType
1 | self.output = self.activation_fn( |
上式dimshuffle的参数可以’0,1或’x’。0代表原始的行数,1代表原始的列数,x代表增加1维。因此张量后的b为(1L,20L,1L,1L)。
全连接层
全连接层和之前讨论前馈神经网络中的隐藏层类似。
1 | class FullyConnectedLayer(object): |
初始化
初始化参数包括该层神经元个数和后一层神经元个数。该层神经元个数n_in=20*12*12=2880,下一层神经元个数n_out=100。因此共有2880*100+100=288100个参数。这里激活函数仍然使用sigmoid。p_dropout指定了弃权概率,是为了防止过拟合。
dropout弃权
首先按照(mini_batch_size, self.n_in)调整输入结构,再计算激活值,计算激活值时使用到了弃权策略。弃权策略是为了防止过拟合,对于神经网络单元,按照一定的概率将其暂时从网络中丢弃。注意是暂时,对于随机梯度下降来说,由于是随机丢弃,故而每一个mini-batch都在训练不同的网络。具体过程如下所示:
这是原始的结构。特别地,假设我们有一个训练数据x和对应的目标输出y。通常我们会通过在网络中前向传播x,然后进行反向传播来确定对梯度的贡献。使用弃权技术,这个过程就改了。我们会随机(临时)地删除网络中部分的隐藏神经元,输入层和输出层的神经元保持不变。在此之后,我们会得到最终如下线条所示的网络。注意那些被弃权的神经元,即那些临时被删除的神经元,用虚圈表示在图中:
具体关于dropout的理解可参考理解dropout。
代码中的self.output和self.output_dropout是有区别的。
self.output是在测试阶段使用的,计算时需要乘以(1-self.p_dropout)。即在网络前向传播到输出层前时隐含层节点的输出值都要缩减到(1-v)倍。例如正常的隐层输出为a,此时需要缩减为a(1-v)。这里我的解释是:假设比例v=0.5,即在训练阶段,以0.5的比例忽略隐层节点;那么假设隐层有80个节点,每个节点输出值为1,那么此时只有40个节点正常工作;也就是说总的输出为40个1和40个0;输出总和为40;而在测试阶段,由于我们的权值已经训练完成,此时就不在按照0.5的比例忽略隐层输出,假设此时每个隐层的输出还是1,那么此时总的输出为80个1,明显比dropout训练时输出大一倍(由于dropout比例为0.5);所以为了得到和训练时一样的输出结果,就缩减隐层输出为a(1-v);即此时输出80个0.5,总和也为40.这样就使得测试阶段和训练阶段的输出“一致”了。可参考机器学习——Dropout原理介绍中测试阶段一节。
而self.output_dropout是在训练阶段使用的。self.output_dropout的计算是根据上述dropout定义来做的。使用伯努利分布生成掩码,来随机忽略部分神经元。公式参考如下:
具体代码如下:
1 | def dropout_layer(layer, p_dropout): |
另外,还有一个问题,为什么只对全连接层应用弃权?
原则上我们可以在卷积层上应用一个类似的程序。但是,实际上那没必要:卷积层有相当大的先天的对于过度拟合的抵抗。原因是共享权重意味着卷积核被强制从整个图像中学习。这使他们不太可能去选择在训练数据中的局部特质。于是就很少有必要来应用其它规范化,例如弃权。
输出层
1 | class SoftmaxLayer(object): |
输出层和全连接层大同小异。主要区别包括,使用softmax激活函数代替sigmoid以及使用对数似然代价函数代替交叉熵代价函数。
柔性最大值
柔性最大值公式如下:
aLj=ezLj∑kezLk
同时有:
∑jaLj=∑jezLj∑kezLk=1
因此柔性最大值层的输出可以看作是一个概率分布。在MNIST分类问题中,可以将aLj理解为网络估计正确数字分类为j的概率。
对数似然代价函数
前面说到可以把柔性最大值的输出看作是一个概率分布,因此我们可以使用对数似然代价函数。根据:
L(θ={W,b},D)=|D|∑i=0log(P(Y=y(i)|x(i),W,b)) ℓ(θ={W,b},D)=−L(θ={W,b},D)
具体代码:
1 | loss = -T.mean(T.log(p_y_given_x)[T.arange(y.shape[0]), y]) |
具体解释可参考上述注释部分。每次求得是一个mini-batch-size里的总代价。y的索引不一定是从0开始,因此使用[T.arange(y.shape[0]), y]来索引。
注意每次计算代价的时候,需要用到self.output_dropout,而self.output_dropout是在set_inpt定义的,cost和self.output_dropout都是预定义的符号运算,实际上每次计算代价时,都会进行前向传播计算出self.output_dropout。
Network初始化
1 | class Network(object): |
self.x定义了数据的符号化矩阵,参数是名称;self.y定义了分类的符号化向量,参数同样是名称。接着定义符号化前向传播算法,来计算输出。实际上初始化的时候,没有代入具体实际数据来进行前向传播计算输出,而是定义了前向传播计算图,后面层的输入依赖于前面层的前向传播输出,这样后面计算某一层的输出时,直接调用该层的输出符号,即可自动根据计算图去计算出前面层的输出结果。
学习算法
最后,让我们关注下核心的学习算法SGD。这里面还会介绍theano符号化计算,Theano会将符号数学化计算表示成graph,之后实际运行时才开始真正的计算。
1 | def SGD(self, training_data, epochs, mini_batch_size, eta, |
正则化和梯度求解
首先求每个迭代期需要计算多少次mini-batch。接着定义正则化代价函数,这里使用的是L2正则化。紧接着定义符号化求梯度方法以及梯度更新方法。具体代码含义如下:
1 | grads = T.grad(cost, self.params)#第一个参数是要求梯度的式子,第二个参数是要对什么参数求梯度 |
注意上述都是符号化定义,并未实际运行。
训练方法定义
紧接着定义训练方法,使用theano.function方法。第一个参数是输入,即mini-batch的编号,使用的是long类型,即T.lscalar。第二个参数是返回值,即代价值。其他参数updates定义参数的更新,givens用于提供mini-batch训练数据及分类。
对于验证方法和测试方法,第一个参数仍然是输入编号,第二个参数是返回值,即正确率,updates参数用于提供mini-batch数据及分类。
上述都是属于符号化定义。下面开始实际的训练。
实际训练
最外层循环是epoch轮次,再里面一层是每个轮次需要运行的Mini_batch数目。首先,计算目前为止的迭代数,一个mini_batch的计算代表一次迭代。每1000次迭代就输出提示,按照一次训练mini-batch-size=10条数据,1000次迭代就有10000条数据经过了训练。每次训练cost_ij = train_mb(minibatch_index),都会先进行前向传播计算输出,然后计算代价,进而计算梯度并更新参数。(iteration+1) % num_training_batches == 0代表一次轮次epoch结束。此时对验证集进行验证,验证过程中,会对验证集数据进行前向传播计算输出,具体计算符号定义是在set_inpt中定义的。如果此时验证集准确率比之前轮次的高,则使用得到的模型计算测试集准确率,测试集准确率计算同验证集。
最后输出最好的结果以及迭代次数。
结果分析
迭代过程
尝试对比不采取弃权策略和采取弃权策略的结果。
下面是不采取弃权策略,即p_dropout=0的结果,截取了20个轮次。
1 | Training mini-batch number 0 |
下面是采取弃权策略,并设p_dropout=0.3的结果。
1 | Training mini-batch number 0 |
可以发现,刚开始的时候,不采取弃权策略的结果更好。但是随着轮次的增加,采取弃权策略的结果马上就超过了不采取弃权策略的结果,并且采取弃权策略的准确率提升也更快。例如在弃权策略中第16轮次测试集准确率就达到了98.95%,而不采取弃权策略,在更多的第20轮次的测试集结果反而才98.47%。
可以看到弃权策略最优结果为第22轮次的99%。
实际上可以通过再插入一个卷积层,实现准确率进一步提升到99.22%,如下代码:
1 | net = Network([ |
具体结果:
1 | Training mini-batch number 0 |
可以看到,第28 Epoch的时候,测试集准确率达到最高的99.22%。
后面,我闲暇之余又进行了更多次的迭代,测试集准确率最终在68 epoch时达到了99.30%。
可视化
本部分将挑选出10000条测试集数据中被误分类的数字进行可视化,选择的分类模型是使用弃权策略并且包含一个卷积层、一个全连接层、一个柔性最大值输出层的神经网络结构,测试集准确率达到90%。因此10000条测试集中有100条数据被误分类。可视化代码如下,该部分代码根据示例代码进行改造得到:
1 | def get_error_locations(net, test_data): |
得到下图:
如图是误分类的数字,每个数字的右上角是真实的分类,右下角是模型预测的分类。我们可以观察下这100个数字,有些数字即使是我们自己去分类,也很难分辨出来,确实很模棱两可,甚至有些分类我们更认同模型的结果。比如,第一行第8个数,我更认为是9而不是4;第一行第9个数,长得更像9而不是8。因此模型的输出很大程度上是可以接受的。
更进一步,我使用上述两层卷积结构的神经网络达到的99.30%测试集准确率模型,进行了一次绘图,得到下图,只有70个误分类的数字:
我们可以对比一下上面两幅图,看看哪些之前误分类的数字,双层卷积神经网络进行了正确的识别。
保存和加载模型
根据训练过程,我们可以知道上述过程是非常缓慢的。为了测试方便,我们不希望每次都要重新进行训练。因此需要找一个保存和加载模型的方法,这也是书上课后的一个小作业。具体代码如下:
1 | import cPickle |
这里有个保存模型小小的技巧。如果不想在所有迭代都跑完才进行模型保存的话,可以使用该技巧在任意迭代期手动进行模型的保存。这里针对的是使用pycharm进行python代码coding的同学,可以直接debug整个程序,开始时忽略断点直接运行,然后在你感觉某个epoch后,模型的结果不错的时候,想保存下模型,那么可以打开断点,使程序暂时停止执行,然后使用evaluate expression(alt+F8)功能,直接调用save(net.layers,”../model/param-epoch-轮次数-准确率.pkl”)进行模型的保存。当然,也可以直接写代码,让结果超过你的预期性能的时候,自动进行保存也是可以的。
这样,只需要简单的修改下SGD方法,当模型是从文件中加载进来的时候,就不需要进行迭代训练,直接进行结果的预测即可。修改过的完整代码如下。
完整代码
1 | """network3.py |