上一次实验我们介绍了卷积神经网络的基本原理,本次实验我们将学习如何使用深度学习框架caffe构建卷积神经网络,你将看到在深度学习框架上搭建和训练模型是一件非常简单快捷的事情(当然,是在你已经理解了基本原理的前提下)。如果上一次实验中的一些知识点你还理解的不够透彻,这次以及之后的实验正是通过实际操作加深对它们理解的好机会。
在正式介绍和使用caffe
之前,我们先来大致确定我们的深度神经网络结构。和课程814一样,本课程也是识别图片中的英文字母。不过这次我们是使用caffe
来训练我们的模型,并且我们会向网络模型中加入前面介绍的卷积层。我们的网络模型大致是这样的:
图中有一些你还没见过的东西,不过现在你只需要关注绿色的数据输入层、紫色的损失函数层、三个橙色的卷积层和两个红色的卷积层。
可以看到,我们的神经网络先用三个卷积层提取图片中的特征,然后是一个含有100个节点的全连接层ip1
,最后是含有26个节点的用于分类的全连接层ip2
和损失函数层。
和课程814相比,我们似乎只是多了卷积层。
caffe(Convolutional Architecture for Fast Feature Embedding)
和好多软件项目一样,它看起来复杂且有些让人摸不着头脑的英文全称很可能只是为了获得一个有意思的缩写名。所以不要被它的名字迷惑,不过名字里的Convolutional
也暗示了caffe
特别擅长构建卷积神经网络。目前,caffe
是使用最多的深度学习框架之一。其作者Yangqing Jia(没错,他是中国人)在Facebook。caffe
支持使用CPU
或GPU
进行训练,当使用GPU
时,能够获得更快的训练速度(实验楼环境中目前没有GPU资源可以使用,不过这不影响我们的实验)。
caffe
支持多种训练数据输入方式,其中最常用的一种是先将训练图片存储到lmdb
数据库中,在训练的过程中直接从lmdb
数据库中读取数据。除非你有兴趣,不然你暂时不必关心如何掌握lmdb
数据库,因为caffe已经为我们提供了将图片转化成lmdb
数据的脚本。
我已经事先准备好了一些训练图片,过为了方便,我把图片的分辨率改成了16*16。你可以使用以下命令获取这些训练数据:
wget http://labfile.oss.aliyuncs.com/courses/820/cnndata.tar.gz
tar zxvf cnndata.tar.gz
注:
cnndata.tar.gz
中也包括之后我们会用到的network.prototxt
和solver.prototxt
。
解压之后,你当前的目录结构应该如下:
.
├── cnndata.tar.gz
├── network.prototxt
├── pic
├── solver.prototxt
├── test.txt
├── train.txt
└── validate.txt
1 directory, 6 files
注意之后我们会编写的network.prototxt
和solver.prototxt
也包含在了里面,进行下面的实验时,你可以打开network.prototxt
对照着查看完整的网络定义。
其实这个项目对于caffe
来说有些太简单了,而且也无法完全体现出caffe
的优势,本来我是想做人脸性别识别的项目的,但最后考虑到训练时间的问题,还是决定采用这个更加简单的项目。完成本课程后,你可以自己尝试其他更具有挑战性的项目。
实验楼环境中的caffe
安装在/opt/caffe
目录下,在/opt/caffe/build/tools
目录下你可以找到一个名为convert_imageset
的可执行程序,不过实验楼使用的环境,在~/.zshrc
文件中已经将convert_imageset
程序所在的目录添加到了环境变量PATH
中,所以你可以直接在terminal中输入"convert_imageset"执行命令。如果你自己的电脑中安装有caffe
而没有配置环境变量PATH
, 可能无法直接执行convert_imageset
命令。
~/.zshrc
文件中,caffe
(也包括torch
)相关环境的配置如下:
输入“convert_imageset”命令,你可以看到这个命令有哪些选项:
你可以自己研究如何利用这些选项来自定义训练数据的生成过程,对于我们的训练数据,可以直接输入以下命令将图片转化成lmdb
数据:
convert_imageset --check_size --gray -shuffle ./ train.txt train
convert_imageset --check_size --gray -shuffle ./ validate.txt validate
convert_imageset --check_size --gray -shuffle ./ test.txt test
这三条命令的第一个参数--check_size
检查每一张图片的尺寸是否相同。第二个参数--gray
将图片转换为单通道灰度图片。第三个参数-shuffle
将所有图片的顺序打乱。第三个参数./
指明图片文件所在的父目录,由于这里的train.txt
等文件中已经包含了前缀pic
,所以这里的父目录就是当前目录./
。第四个参数指明图片列表文件。第五个参数指明最后生成的lmdb
数据库文件夹的位置。
这三条命令执行完毕后,你的目录结构应该是这样的:
.
├── cnndata.tar.gz
├── network.prototxt
├── pic
├── solver.prototxt
├── test
├── test.txt
├── train
├── train.txt
├── validate
└── validate.txt
4 directories, 6 files
多出的三个文件夹train
validate
test
包含我们生成的lmdb
数据库。
对于神经网络,我们希望输入的数据分布能够有正有负(具体原因这里不赘述,若有兴趣你可以查阅资料了解为什么希望数据有正有负)。而图片像素值都是大于0的,所以caffe
为我们提供了另一个脚本compute_image_mean
,运行这个命令获得训练数据在每个通道上的均值,在处理训练数据时减去这个均值就可以保证图片有正有负且其分布“以0为中心”。
需要注意的是,我们只对训练集train
计算均值,训练和测试的时候都是减去这个均值,而不是对于测试集单独计算。因为如果对于训练集和测试集的预处理操作不一样的话,可能会影响模型在测试集上的实际效果。
运行以下命令计算训练集图片均值:
compute_image_mean train train.binaryproto
其中第一个参数train
指定对在我们刚生成的lmdb
数据库train
中的数据计算均值,第二个参数train.binaryproto
指定计算出的均值保存在train.binaryproto
文件中。稍后我们会用到这个均值文件。
就像课程814中所说的,好多深度学习框架都提供层次化的网络结构,网络结构中的每一层为一个小的模块,将多个模块组合起来,就构成了一个神经网络模型,就像是搭积木一样。caffe
正是如此。
caffe
通过编写probobuf
文件来定义网络(network)结构, protobuf
是一种数据交换格式。protobuf
使用起来很简单,为了完成本课程的实验,你不需要专门去学习protobuf
,只需要参照别人写好的protobuf
文件照葫芦画瓢就行了。
使用编辑器创建一个network.prototxt
(注意这里的后缀名是prototxt)文件, 这里使用的是vim
编辑器,如果你不会使用vim
, 也可以使用其他编辑器。
vim network.prototxt
在新建文件的第一行,你可以定义网络模型的名字:
name: "AlphaNet"
在课程814中,我们直接使用numpy
ndarray
存储网络中的数据。在caffe
中,网络中的数据使用Blobs
存储,其实你可以直接把Blobs
看成一个n*c*h*w
的四维数组,其中n
代表一个batch
中图片的数量,c
代表图片通道数(对于卷积层,代表特征个数),h
代表图片高度,w
代表图片宽度。
caffe
中的每一个网络层可以有多个bottom
和top
,bottom
其实就是一个网络层的数据流入口,top
就是一个网络层的数据流出口。当层A的bottom
和层B的top
相同时,就代表层A以层B的输出作为输入。
我们已经准备好了训练和测试数据,为了让我们的卷积神经网络能够读取这些数据,需要在network.prototxt
添加数据输入层。
数据层需要从lmdb
中读取数据,然后产生两个输出top
:一个data
代表图片数据,一个label
代表该图片的标签。
layer{
name:"data"
type:"Data"
top:"data"
top:"label"
data_param{
source: "train"
batch_size:16
backend:LMDB
}
transform_param{
scale: 0.00390625
mean_file: "train.binaryproto"
}
include{
phase:TRAIN
}
}
我们逐个对这个数据层的各部分进行解释。
name
代表这一层的名字。type
代表类型,caffe
中提供了很多种类型的网络层,你可以到这里查看有哪些层,这里的Data
指定是从数据库中读取数据。
两个top
代表了数据层有两个输出,一个是data
(与层的名字相同),代表lmdb
数据库中的图片数据,一个是label
代表每张图片对应的标签。
data_param
中的内容指定了Data
类型数据层需要的参数,其中source
指定数据库的位置,在这里就是我们之前生成的train
文件夹;batch_size
指定每一个batch
一次性处理多少张图片(还记得课程814里说的batch吗);backend
指定数据库的种类,我们使用的是lmdb
数据库,所以这里为LMDB
。
接下来的transform_param
指定对图片进行的预处理操作,这里的mean_file
指定平均值文件的位置,同时,我们指定了对图片像素值的缩放比例scale
为0.00390625(其实就是1/255), 将像素值的范围缩小到1。
include
包含了其他一些信息,这里的phase
指定这个数据层是在训练还是在测试阶段使用,TRAIN
表明是在训练阶段。
对于测试阶段的数据,可以直接再增加一个数据层,同时设置phase
为TEST
就可以了,如下:
layer{
name:"data"
type:"Data"
top:"data"
top:"label"
data_param{
source: "validate"
batch_size:100
backend:LMDB
}
transform_param{
scale: 0.00390625
mean_file: "train.binaryproto"
}
include{
phase:TEST
}
}
当进行训练时,caffe
就调用phase
为TRAIN
的数据层,当测试时,caffe
就调用phase
为TEST
的数据层。
除了phase
、source
和batch_size
,第二个数据层的设置与第一个数据层一模一样。注意这里的两个top
名必须和第一个数据层一样,因为后面的网络层的输入bottom
通过名称指定数据来源,所以两个数据层的输出top
名设置成一样就可以保证在训练和测试时,后面的网络层都能读取到数据。
我们一共有三个卷积层,让我们先来看看第一个卷积层的定义:
layer{
name: "conv1"
type: "Convolution"
bottom: "data"
top: "conv1"
param{
lr_mult: 1
}
param{
lr_mult: 2
}
convolution_param{
num_output: 32
kernel_size: 3
stride: 1
pad: 1
weight_filler{
type: "xavier"
}
bias_filler{
type:"constant"
}
}
}
和数据层有一些类似(比如name,bottom,top的作用),但又有很多不一样的地方。首先这里的type
变成了Convolution
代表这一层是卷积层。
两个param
中的lr_mult
作用有些特殊,它们代表卷积层中对参数的学习速率乘以多少倍(就像它的名字暗示的那样--learning rate multiply),其中第一个lr_mult
代表对卷积核中到的参数weight
学习速率相乘的值,第二个lr_mult
代表对偏移量bias
学习速率相乘的值,一般我们总是把第一个设置为1(即学习速率不变),第二个设置为2。这里你可能对这两个参数的作用感到摸不着头脑,但以后你就会明白这两个参数非常有用(它们在caffe的迁移学习transfer learning中发挥作用)。
数据层中的参数在data_param
中定义,类似的,卷积层中的参数在convolution_param
中定义。这里kernel_size
stride
pad
你已经知道它们的作用了,分别代表卷积核的尺寸、卷积核移动步长、图片边缘填充的像素数。而num_output
其实就是我们第一次实验所说的特征个数feature
。
最后剩下weight_filler
和bias_filler
,这两个参数指明weight
和bias
使用什么方式初始化(填充),如果type
为constant
,代表用常数0填充,而xavier
所代表的填充算法就稍微有点复杂了。这里对xavier
填充算法不做介绍,如果你有兴趣,可以自己查阅资料或者查看提出xavier
算法的论文
这里需要注意的是,我们设置kernel_size=3
stride=1
pad=1
可以保证卷积层输出的宽和高与输入相同,你可以代入第一次实验给出的公式计算验证。我们的输入图片尺寸为16x16,带入公式和卷积层的参数,得到:(16+2*1-3)/1+1=16
。
卷积层2和卷积层3的定义与卷积层1几乎一模一样,除了卷积层的num_output
参数被设置成64。
池化(Pooling)
层是卷积神经网络中几乎必然出现的网络层,第一次实验为了突出卷积层,没有介绍池化层,放到了这里来介绍。
我们之前说过,合理的设置卷积层的参数,可以保证卷积层的输出和输入在宽和高上不变。但我们有时候又希望能减小训练数据的尺寸,这样可以降低模型的复杂度,减少参数的数量,让模型训练的更快,池化层
就具有这样的作用。
池化层其实和卷积层非常相似,也是有一个“池化核”对整张图片中的所有可能位置进行计算,不同的是,池化层中没有参数,一般池化层会返回“池化核”中最大(或者最小,或者随机)的数字,且池化层的步长stride一般设置成与“池化核”的尺寸相同,返回“池化核”内最大数值的池化层效果如下:
在第一个卷积层之后,就有一个池化层。
layer{
name:"pool1"
type: "Pooling"
bottom: "conv1"
top: "pool1"
pooling_param{
pool: MAX
kernel_size:2
stride: 2
}
}
其中参数的作用已经很明显了,注意pool
设置为MAX
代表这个“池化核”返回最大值。
注意
池化层的作用不止是降低模型复杂度,比如你可以把返回最大值池化层理解为只保留一个区域最明显的特征。如果你想弄清楚池化层更深层次的作用,请自行查阅资料理解。
caffe
将全连接层称为內积层(InnerProduct)
, 其计算方式大体上与课程814中的全连接层一样,內积层的定义如下:
layer{
name: "ip1"
type: "InnerProduct"
bottom: "conv3"
top: "ip1"
param{
lr_mult:1
}
param{
lr_mult:2
}
inner_product_param{
num_output: 100
weight_filler{
type: "xavier"
}
bias_filler{
type:"constant"
}
}
}
其中的参数我们都已经见过,注意这里的type
为InnerProduct
代表是全连接层,num_output
代表的是全连接层的输出节点的数量。
这里我们可以感受到深度学习框架带给我们的便利,课程814中我们为了实现全连接层绞尽脑汁,而这里只需要十几行的定义就可以了,其他一切都由caffe
帮我们实现。
课程814中,我们介绍了QuadraticLoss
和CrossEntropyLoss
两种损失函数,但这里我们打算使用caffe
提供的另一种损失函数:SoftmaxWithLoss
。 为了弄清楚SoftmaxWithLoss
损失函数是如何工作的,我们先要介绍什么是Softmax
,Softmax
函数的表达式如下:
不要被它的表达式吓到,其实Softmax
的计算很简单,就是对一个数组中的每一个元素先求它对于自然数e的指数e^x,然后每一个元素的Softmax
函数值就是e^xi除以所有元素对自然数e的指数的和。Softmax
函数的性质也很好分析,数组中原本最大的数的函数值最接近1,最小的数的函数值最接近0,同时,数组中所有元素的Softmax
函数值加起来为1。这刚好可以作为概率来看待,实际上,caffe
中有单独的Softmax
层存在,我们可以直接用Softmax
层的输出作为我们的模型对每个类别(比如这里是26个类别)的概率值的预测。
现在我们清楚了Softmax
的作用,但你可能仍然会困惑,Softmax
的名字从何而来,为什么把它叫做“软的最大”呢?其实,与Softmax
对应的还有一种“硬的最大”函数,这里我把它叫做Hardmax
, 它的表达式如下:
观察它的表达式,你会发现Hardmax
与Softmax
的性质非常相似,都是数组中原本最大的元素的函数值最大,但不同的是,对于Hardmax
,数组中最大的元素的函数值固定为1,最小的元素的函数值固定为0。最大元素的函数值一定为1,所以我把它称为“硬最大”,而Softmax
却相对更加“柔和”,更加的“软”。Softmax
的这种特性恰恰适合于作为神经网络中的概率输出,而Hardmax
则会总是把最大可能的类别概率值设置为1,这是不合理的。
搞清楚了Softmax
的作用,理解SoftmaxWithLoss
损失函数就非常简单了。caffe
中的SoftmaxWithLoss
损失函数层其实就是在Softmax
层上增加了一些运算,SoftmaxWithLoss
层的定义如下:
layer{
name: "loss"
type: "SoftmaxWithLoss"
bottom: "ip2"
bottom: "label"
top: "loss"
}
SoftmaxWithLoss
层有两个bottom
, 一个ip2
是我们模型对于每种类别可能性大小的预测,注意这个预测值在经过Softmax
层之前是不能作为概率值的(可能有负值,和可能不为1)。另一个label
就是我们数据层的输出label
, 代表一个训练图片上实际英文字母的类别。
SoftmaxWithLoss
层先用Softmax
函数计算出模型对每种类别的预测概率。再根据label
的值,选择出预测值中的对应概率。比如Softmax
的输出在这里是一个长度为26的数组,而label
中的值为0(代表图片上的字母实际为A),就选择出Softmax
函数输出数组中的第一个概率值。显然,当这个概率值接近1的时候,说明我们的模型预测的比较准确,SoftmaxWithLoss
的输出值应该接近于0,当这个概率值接近于0的时候,说明我们的模型预测的不太准确,SoftmaxWithLoss
的输出值应该是一个很大的正值。
实际上,SoftmaxWithLoss
层对概率值进行的运算很简单,就是对该值求负对数, 这样就满足了上面说的,预测越准损失函数值越小的要求。即SoftmaxWithLoss
层的运算大致如下:
之前我们说过,为了检验模型的泛化性能
,需要在验证/测试集上检验模型的预测准确率。caffe
为我们提供了Accuracy
准确率层,其定义如下:
layer{
name: "accuracy"
type: "Accuracy"
bottom: "ip2"
bottom: "label"
top: "accuracy"
include{
phase:TEST
}
}
其bottom
同样有两个,但注意这里的第一个bottom
是ip2
而不需要是概率值,因为Accuracy
层只需要第一个bottom
的最大值的下标与第二个bottom
相同就认为预测是准确的。
在课程814中我们说过,Sigmoid
激活函数容易导致梯度消失
问题,消失的梯度使得神经网络的训练变得非常困难。而这里我们将介绍的ReLU(Rectified Linear Unit)
激活函数层则非常好的避免了梯度消失问题。
ReLU
的函数表达式如下:Sigmoid
和ReLU
函数图像对比如下:
ReLU
执行的运算非常简单,就是只让大于0的节点的值向前传递。你需要特别注意的是,当反向传播梯度的时候,大于0的节点的梯度是由之前的“部分梯度”乘以1得到的,而小于等于0的节点的梯度则为0。对于大于0的节点,ReLU
不会导致梯度值减小,非常有效的避免了梯度消失问题。
同时,ReLU
的计算非常简单,而Sigmoid
涉及到的求指数运算对于计算机来说则非常复杂,ReLU
激活函数具有更高的执行速度。
我们的网络中层与层之间都存在着ReLU
激活函数层,其定义如下:
layer{
name: "relu1"
type: "ReLU"
bottom: "conv1"
top: "conv1"
}
ReLU
不需要参数,所以这里的定义非常简单,不过你需要注意的是,这里的bottom
和top
名字可以设置成一样的,当设置成一样的时候,ReLU
的输出结果会保存到输入的Blobs
里,这样能节省显存(或内存)。
至此我们的项目中会用到的各种网络层都已经介绍过了,完整的网络定义请你打开network.prototxt
查看。我建议你仔细一行一行的查看这个文件,理解每个网络层的功能和它的特性,理解每一个参数的作用。这对你之后自己动手编写神经网络定义文件非常重要。
我们现在只写好了网络定义文件,但为了让模型开始训练,我们还有一些东西没有确定,比如超参数学习速率、测试间隔、最大训练周期(epoch)等,下次实验,我们将讲解如何编写solver.prototxt
文件。
虽然看起来我们的网络定义文件network.prototxt
的行数有点多,但和我们之前自己动手实现神经网络比起来,这里的网络模型构建还是简单多了,熟练之后你可以在几分钟之内就搭建好一个神经网络。caffe
还提供了很多其他种类的网络层,如果你有兴趣可以到caffe官网查看。理解这些层的原理是科学合理地使用这些网络层的基础。
本次实验,我们学习了:
SoftmaxWithLoss
损失函数就是对概率值取负对数。ReLU
激活函数可以有效避免梯度消失。/opt/caffe/src/caffe/proto/caffe.proto
文件,里面包含了caffe中所有参数的定义。