1. 使用Caffe完成图像分类
本节将以著名的图像分类深度学习网络AlexNet为例子,通过Python Caffe来进行图像分类。虽然不同的网络的结构是不样的,但其大体的过程都是一致的,因此大家可以通过这个例子,熟悉如何利用Caffe进行图像分类。
关于AlexNet的原理,可以参见其论文:Krizhevsky A, Sutskever I, Hinton G E. Imagenet classification with deep convolutional neural networks[C]. In Proc. Advances in Neural Information Processing Systems (NIPS). 2012: 1097-1105.
基于深度学习的图像分类网络,一般是将原始图像经过深度网络后,最后将得到一组向量,这组向量的长度等于分类种类的数目,向量中每个值对应于该图像在相应的分类种类下的概率。因此通常的情况将向量最大值对应的序号视为最可能的分类。
1.1 准备文件
deploy.prototxt
: 网络结构配置文件bvlc_alexnet.caffemodel
: 网络权重文件synset_words.txt
: 分类名称- 测试图像
本文的AlexNet是在
ilsvrc12
下进行训练的,bvlc_alexnet.caffemodel
是在该数据库下训练得到的权重文件,而synset_words.txt
也是该数据库下的分类种类名称,该文件的每一行代表一种类,每一种类可能会有多个相似的名称,当然近似名称并不重要,但要知道每一行代表一类,行号则代表其在网络最终输出向量的序号,举个例子,比如说,如果最终输出向量的第50个值最大,则代表该图像的分类为synset_words.txt
第50行表示的分类。
1.2 加载网络
caffe_root = '../../'
# 网络参数(权重)文件
caffemodel = caffe_root + 'models/bvlc_alexnet/bvlc_alexnet.caffemodel'
# 网络实施结构配置文件
deploy = caffe_root + 'models/bvlc_alexnet/deploy.prototxt'
net = caffe.Net(deploy, # 定义模型结构
caffemodel, # 包含了模型的训练权值
caffe.TEST) # 使用测试模式(不执行dropout)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
1.3 测试图像预处理
预处理主要包含两个部分:
- 减去均值
- 调整大小
# 加载ImageNet图像均值 (随着Caffe一起发布的)
mu = np.load(caffe_root + 'python/caffe/imagenet/ilsvrc_2012_mean.npy')
mu = mu.mean(1).mean(1) # 对所有像素值取平均以此获取BGR的均值像素值
# 图像预处理
transformer = caffe.io.Transformer({'data': net.blobs['data'].data.shape})
transformer.set_transpose('data', (2,0,1))
transformer.set_mean('data', mu)
transformer.set_raw_scale('data', 255)
transformer.set_channel_swap('data', (2,1,0))
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
1.4 运行网络
- 导入输入数据
- 通过forward()运行结果
# 加载图像
im = caffe.io.load_image(img)
# 导入输入图像
net.blobs['data'].data[...] = transformer.preprocess('data', im)
start = time.clock()
# 执行测试
net.forward()
end = time.clock()
print('classification time: %f s' % (end - start))
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
1.5 查看分类结果
用于图像分类的网络的最后一层一般是一个名为’prob’的SoftMax网络,这个名为’prob’层的输出即为反应该图像在各分类下的概率向量。而prob层的输出Blob也名为prob
layer {
name: "prob"
type: "Softmax"
bottom: "ip2"
top: "prob" # 输出Blob
}
因此我们可以通过net.blobs['prob']
确定最终的输出结果。找到该向量的最大值即可确定最可能的分类,当然对于图像可能存在多个目标的情况下,可以提取最大概率的多个分类。
# 查看目标检测结果
# 加载分类名称文件
labels = np.loadtxt(synset_words, str, delimiter='\t')
# 得到分类网络的最终结果
category = net.blobs['prob'].data[0].argmax() # 最大概率的分类
# 得到分类名称
class_str = labels[int(category)].split(',')
class_name = class_str[0]
# 在图像中标记分类名称
cv2.putText(im, class_name, (0, im.shape[0]), cv2.cv.CV_FONT_HERSHEY_SIMPLEX, 1, (55, 255, 155), 2)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
1.6 分类结果展示
1.7 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的classification.py
2. 使用Caffe完成图像目标检测
本节将以一个快速的图像目标检测网络SSD作为例子,通过Python Caffe来进行图像目标检测。
必须安装windows-ssd版本的Caffe,或者自行在caffe项目中添加SSD的新增相关源代码.
图像目标检测网络同图像分类网络的大体原理及结构很相似,不过原始图像再经过深度网络后,并不是得到一组反映不同分类种类下概率的向量,而得到若干组位置信息,其反映不同目标在图像中的位置及相应分类等信息。但与分类网络的总体实施结构是一致的。
关于SSD的原理,可以参见其论文:Liu W, Anguelov D, Erhan D, et al. SSD : Single shot multibox detector[C]. In Proc. European Conference on Computer Vision (ECCV). 2016: 21-37.
2.1 准备文件
deploy.prototxt
: 网络结构配置文件VGG_VOC0712_SSD_300x300_iter_60000.caffemodel
: 网络权重文件labelmap_voc.prototxt
: 数据集分类名称- 测试图像
本文的SSD是在
VOC0712
数据集下进行训练的,labelmap_voc.prototxt
也是该数据库下的各目标的名称,该文件对于目标检测网络的训练任务是必须的,在下节中,我们将重点介绍如何生成LMDB数据库及Labelmap文件。
2.2 加载网络
加载网络的方法,目标检测网络同目标分类网络都是一致的。
caffe_root = '../../'
# 网络参数(权重)文件
caffemodel = caffe_root + 'models/SSD_300x300/VGG_VOC0712_SSD_300x300_iter_60000.caffemodel'
# 网络实施结构配置文件
deploy = caffe_root + 'models/SSD_300x300/deploy.prototxt'
labels_file = caffe_root + 'data/VOC0712/labelmap_voc.prototxt'
# 网络实施分类
net = caffe.Net(deploy, # 定义模型结构
caffemodel, # 包含了模型的训练权值
caffe.TEST) # 使用测试模式(不执行dropout)
2.3 测试图像预处理
预处理主要包含两个部分:
- 减去均值
- 调整大小
# 加载ImageNet图像均值 (随着Caffe一起发布的)
mu = np.load(caffe_root + 'python/caffe/imagenet/ilsvrc_2012_mean.npy')
mu = mu.mean(1).mean(1) # 对所有像素值取平均以此获取BGR的均值像素值
# 图像预处理
transformer = caffe.io.Transformer({'data': net.blobs['data'].data.shape})
transformer.set_transpose('data', (2,0,1))
transformer.set_mean('data', mu)
transformer.set_raw_scale('data', 255)
transformer.set_channel_swap('data', (2,1,0))
2.4 运行网络
- 导入输入数据
- 通过forward()运行结果
# 加载图像
im = caffe.io.load_image(img)
# 导入输入图像
net.blobs['data'].data[...] = transformer.preprocess('data', im)
start = time.clock()
# 执行测试
net.forward()
end = time.clock()
print('detection time: %f s' % (end - start))
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
2.5 查看目标检测结果
SSD网络的最后一层名为'detection_out'
,该层输出Blob结构'detection_out'
中包含了多组元组结构,每个元组结构包含7个参数,其中第2参数表示分类类别序号,第3个参数表示概率置信度,第4~7个参数分别表示目标区域左上及右下的坐标,而元组的个数表明该图像中可能的目标个数。
当然可能不同网络模型的结构不一样,可能会有不同的设置,但至少对于SSD是这样设置的。
# 查看目标检测结果
# 打开labelmap_voc.prototxt文件
file = open(labels_file, 'r')
labelmap = caffe_pb2.LabelMap()
text_format.Merge(str(file.read()), labelmap)
# 得到网络的最终输出结果
loc = net.blobs['detection_out'].data[0][0]
confidence_threshold = 0.5
for l in range(len(loc)):
if loc[l][2] >= confidence_threshold:
# 目标区域位置信息
xmin = int(loc[l][3] * im.shape[1])
ymin = int(loc[l][4] * im.shape[0])
xmax = int(loc[l][5] * im.shape[1])
ymax = int(loc[l][6] * im.shape[0])
# 画出目标区域
cv2.rectangle(im, (xmin, ymin), (xmax, ymax), (55 / 255.0, 255 / 255.0, 155 / 255.0), 2)
# 确定分类类别
class_name = labelmap.item[int(loc[l][1])].display_name
cv2.putText(im, class_name, (xmin, ymax), cv2.cv.CV_FONT_HERSHEY_SIMPLEX, 1, (55, 255, 155), 2)
2.6 目标检测结果展示
2.7 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的detection.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
3. 图像训练测试数据集LMDB的生成
Caffe深度学习网络在训练和测试中所使用的的数据集,并不是一张张的图像,而将图像集打包成LMDB或者LevelDB的数据库形式,这样做好处是能提高读取图像的时间。而本节的目的在于介绍如何将图像数据生成可用于测试和训练LMDB格式的数据集。
3.1 原始图像的采集
可以通过爬虫等方法从互联网中获得许多张图像(图像格式不限,JPG或者PNG都是可以的)
然后将所有图像用顺序的数字重命名。
def rename_img(Img_dir):
# 重新命名Img,这里假设图像名称表示为000011.jpg、003456.jpg、000000.jpg格式,最高6位,前补0
# 列出图像,并将图像改为序号名称
listfile=os.listdir(Img_dir) # 提取图像名称列表
total_num = 0
for line in listfile: #把目录下的文件都赋值给line这个参数
if line[-4:] == '.jpg':
newname = '{:0>6}'.format(total_num) +'.jpg'
os.rename(os.path.join(Img_dir, line), os.path.join(Img_dir, newname))
total_num+=1 #统计所有图像
3.2 确定类别标签
将所有类别用一组从0开始的数字标记,比如背景可以用0来表示,而狗可以用1来表示,其余类别依次用以下数字标记。前面我们知道,对于分类网络来说,其最终输出为一组向量,而这里的各个类别的数字标记代表了该组向量的某个值,如果该位置的值最大,表明该位置所代表的分类类别可能性最大。
前面的分类实验中的synset_words.txt
实际就是一个列表类别文件,其包含1000行文本,每一行文本代表一个类别(每个类别有多个同名名称),而行号则代表对应于分类网络最终输出向量的位置。
# synset_words.txt的内容
n01440764 tench, Tinca tinca
n01443537 goldfish, Carassius auratus
n01484850 great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias
n01491361 tiger shark, Galeocerdo cuvieri
n01494475 hammerhead, hammerhead shark
n01496331 electric ray, crampfish, numbfish, torpedo
n01498041 stingray
n01514668 cock
n01514859 hen
... ...
而目标检测实验有的类别列表文件labelmap_voc.prototxt
却不一样:
item {
name: "none_of_the_above"
label: 0
display_name: "background"
}
item {
name: "aeroplane"
label: 1
display_name: "aeroplane"
}
item {
name: "bicycle"
label: 2
display_name: "bicycle"
}
... ...
生成该文件的Python实现例子:
# 生成分类标签文件
def labelmap(labelmap_file, label_info):
labelmap = caffe_pb2.LabelMap()
for i in range(len(label_info)):
labelmapitem = caffe_pb2.LabelMapItem()
labelmapitem.name = label_info[i]['name']
labelmapitem.label = label_info[i]['label']
labelmapitem.display_name = label_info[i]['display_name']
labelmap.item.add().MergeFrom(labelmapitem)
with open(labelmap_file, 'w') as f:
f.write(str(labelmap))```
```label_info = [
dict(name='none', label=0, display_name='background'), # 背景
dict(name="cat",label=1, display_name='cat'), # 背景
dict(name="dog",label=2, display_name='dog'), # 背景
]
labelmap(data_root+'labelmap_voc.prototxt', label_info)
3.3 图像的标记
对于监督的深度学习网络来说,其训练数据一般是带标签的,因此所采集的原始图像进行标记。
对于分类任务来说,每张图像所代表的分类可以用一个数字来表示,而该数字代表了某个类别。
而对于目标检测任务来说,每张图像中可以会有多个目标,每个目标都有一组位置信息和分类信息,因此通常是通过xml文件来表示。这里推荐使用labelImg来进行图像标记,该软件可以直接通过pip install labelImg
来进行安装,在Anaconda中安装完成后,在使用时可能会提示PyQt4错误,可以将所有import PyQt4的部分及使用PyQt4的部分删除。
通过labelImg进行图像标记里,会对每张图像生成对应的同名的xml文件。
3.4 生成训练及测试数据列表文件
所谓的数据列表文件主要是标明数据中各个图像的位置及其对应的标签。
对于深度学习网络的训练任务中一般会需要三组数据库:
训练集,验证集及测试集。这里训练集中主要是用于训练网络,而验证集中主要是在训练网络中验证测试部分网络超参数,而测试集是用于考察网络的精度。因此,虽然三组数据集都是带标签的数据集,但训练集中的标签是网络可以已知的,也就是说网络会利用标签信息进行梯度更新,而验证集中的标签对于网络本身是未知的,但对于实验者是已知的,也就是说实验者可以通过验证集来验证一些网络在训练中不可改变的超参数。而测试集的标签对于实验者和网络都是未知的,其是用于考察实验者所设计的网络本身的有效性。
有时,我们也可以将训练集和验证集合二为一trainval.txt
,但必须要有测试集test.txt
,如果没有测试集,将无法考察网络的泛化能力(也即真实的检测精度)。因此在得到的大量的图像中,我们一般会随机抽取的一组图像作为测试集数据,而其他数据作为训练验证数据。
对于分类任务来说,数据列表文件中每一行包含图像地址及该图像对应的分类标号,比如
# ILSVRC2012 测试数据列表文件test.txt中的内容
ILSVRC2012_test_00000001.JPEG 0
ILSVRC2012_test_00000002.JPEG 0
ILSVRC2012_test_00000003.JPEG 0
ILSVRC2012_test_00000004.JPEG 0
ILSVRC2012_test_00000005.JPEG 0
ILSVRC2012_test_00000006.JPEG 0
ILSVRC2012_test_00000007.JPEG 0
ILSVRC2012_test_00000008.JPEG 0
... ...
而对于目标检测数据集来说,其中每一行包含图像地址及该图像标记文件xml的地址,比如
# VOC2007 测试数据列表文件train_val.txt中的内容
VOC2007/JPEGImages/003746.jpg VOC2007/Annotations/003746.xml
VOC2007/JPEGImages/004854.jpg VOC2007/Annotations/004854.xml
VOC2007/JPEGImages/001589.jpg VOC2007/Annotations/001589.xml
VOC2007/JPEGImages/000606.jpg VOC2007/Annotations/000606.xml
VOC2007/JPEGImages/008234.jpg VOC2007/Annotations/008234.xml
VOC2007/JPEGImages/001389.jpg VOC2007/Annotations/001389.xml
VOC2007/JPEGImages/008650.jpg VOC2007/Annotations/008650.xml
VOC2007/JPEGImages/007239.jpg VOC2007/Annotations/007239.xml
... ...
用Python生成列表文件的例子
def detection_list(Img_dir, Ano_dir, Data_dir, test_num):
# 造成目标检测图像数据库
# Img_dir表示图像文件夹
# Ano_dir表示图像标记文件夹,用labelImg生成
# Data_dir生成的数据库文件地址
# test_num测试图像的数目
# 列出图像
listfile=os.listdir(Img_dir) # 提取图像名称列表
# 列出图像,并将图像改为序号名称
total_num = 0
for line in listfile: #把目录下的文件都赋值给line这个参数
if line[-4:] == '.jpg'
total_num+=1 #统计所有图像`
trainval_num = total_num-test_num # 训练图像数目
# 生成训练图像及测试图像列表
test_list_file=open(Data_dir+'/test.txt','w')
train_list_file=open(Data_dir+'/trainval.txt','w')
test_list = np.random.randint(0,total_num-1, size=test_num)
train_list = range(total_num)
for n in range(test_num):
train_list.remove(test_list[n])
random.shuffle(train_list)
# 测试图像排序,而训练图像不用排序
test_list = np.sort(test_list)
# train_list = np.sort(train_list)
for n in range(trainval_num):
train_list_file.write(Img_dir + '{:0>6}'.format(train_list[n]) +'.jpg '+ Ano_dir + '{:0>6}'.format(train_list[n]) +'.xml\n')
for n in range(test_num):
test_list_file.write(Img_dir + '{:0>6}'.format(test_list[n]) +'.jpg '+ Ano_dir + '{:0>6}'.format(test_list[n]) +'.xml\n')
3.5 生成lmdb文件
生成lmdb或者leveldb文件主要要用到caffe的exe工具create_annoset,该软件可以由caffe的VS项目所生成,其是由C++所编写的。
因此可以直接通过Python调用create_annoset接口得到。
def create_annoset(anno_args):
if anno_args.anno_type == "detection":
cmd = "E:\Code\windows-ssd/Build/x64/Release/convert_annoset.exe" \
" --anno_type={}" \
" --label_type={}" \
" --label_map_file={}" \
" --check_label={}" \
" --min_dim={}" \
" --max_dim={}" \
" --resize_height={}" \
" --resize_width={}" \
" --backend={}" \
" --shuffle={}" \
" --check_size={}" \
" --encode_type={}" \
" --encoded={}" \
" --gray={}" \
" {} {} {}" \
.format(anno_args.anno_type, anno_args.label_type, anno_args.label_map_file, anno_args.check_label,
anno_args.min_dim, anno_args.max_dim, anno_args.resize_height, anno_args.resize_width, anno_args.backend, anno_args.shuffle,
anno_args.check_size, anno_args.encode_type, anno_args.encoded, anno_args.gray, anno_args.root_dir, anno_args.list_file, anno_args.out_dir)
elif anno_args.anno_type == "classification":
cmd = "E:\Code\windows-ssd/Build/x64/Release/convert_annoset.exe" \
" --anno_type={}" \
" --min_dim={}" \
" --max_dim={}" \
" --resize_height={}" \
" --resize_width={}" \
" --backend={}" \
" --shuffle={}" \
" --check_size={}" \
" --encode_type={}" \
" --encoded={}" \
" --gray={}" \
" {} {} {}" \
.format(anno_args.anno_type, anno_args.min_dim, anno_args.max_dim, anno_args.resize_height,
anno_args.resize_width, anno_args.backend, anno_args.shuffle, anno_args.check_size, anno_args.encode_type, anno_args.encoded,
anno_args.gray, anno_args.root_dir, anno_args.list_file, anno_args.out_dir)
print cmd
os.system(cmd)
3.6 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的generate_lmdb.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
4. 设计自己的网络结构
通过前文的例子,我们都知道了Caffe的网络都是一个prototxt的网络结构配置文件定义的,该文件可以用文本工具打开,打开后,我们可以看到如下结构:
layer {
name: "data"
type: "Data"
top: "data"
top: "label"
transform_param {
scale: 0.00390625
}
data_param {
source: "../../data/mnist/mnist_train_lmdb"
batch_size: 64
backend: LMDB
}
}
layer {
name: "conv1"
type: "Convolution"
bottom: "data"
top: "conv1"
param {
lr_mult: 1
decay_mult: 1
}
param {
lr_mult: 2
decay_mult: 0
}
convolution_param {
num_output: 20
pad: 0
kernel_size: 5
stride: 1
weight_filler {
type: "xavier"
}
bias_filler {
type: "constant"
value: 0
}
}
}
layer {
name: "pool1"
type: "Pooling"
bottom: "conv1"
top: "pool1"
pooling_param {
pool: MAX
kernel_size: 2
stride: 2
}
}
...
在prototxt文件中,层都是用layer{}的结构表示,而里面包含的层的参数可以在caffe.proto文件中找到,比如说Data类型的结构由message DataParameter
所定义,Convolution
类型的结构由message ConvolutionParameter
所定义。
具体说明下:
name
表示该层的名称type
表示该层的类型,如Data
或者Convolution
类型top
表示该层后接的Blob
结构的名称bottom
表示该层前接的Blob
数据结构的名称*_param
表示该层的参数,比如对于某卷积层conv1
来说,convolution_param
中num_output
表示输出通道数pad
表示卷积pad
kernel_size
表示卷积核的大小- 关于卷积操作的具体解释可以参考
Theano
的对卷积的介绍。
对于如何使用层结构,一方面我们可以从model_libs.py
及caffenet.py
等文件找到如何使用层结构的例子。另外可能参考这个教程,里面给出caffe的所有层的Python函数使用例子。
另一方面我们可以参照caffe的C++类定义。在我们知道各层的描述后,可以将这些层结构拼接成我们自己的网络,手动在文本工具中直接编写prototxt文件。
但对于一些非常复杂的网络,很多情况下可能有成百上千的层。这样的情况下,我们能难人工一层接一层的编写prototxt文件,而且还很容易出错,因此可以通过python来直接自动生成结构文件。
在caffe目录下的example/pycaffe文件夹的caffenet.py给出了如何直接生成一个多层线性分类器的网络配置文件prototxt文件,而本节还将介绍一个直接生成mnist LeNet5网络的训练、测试及实施的三个网络配置文件prototxt。而我们将以后章节中介绍通过三个文件完成LeNet5网络的训练及测试任务。
Tips:可以将一些常用的网络结构打包成基础体,比如在model_lib.py文件中将VGG网络、AlexNet等常用网络打包成基础网络。另外训练、测试及实施的三个网络的中间结构是一致的,只是输入层和最终输出层是不一样的,因此将可以中间相同层都用一个固定函数生成。
生成网络配置主要分为如下几个部分:
4.1 配置基础网络结构
这里生成训练、测试及实施的三个网络中都相同的网络中间部件
# 此函数生成LeNet5的主体结构
def lenet5_body(net, from_layer):
# 网络参数
kwargs = {
# param定义学习率,这里是指基础学习率step的情况,lt_mult乘以基础学习率为实际学习率,为0表示权重不更新,decay_mult同权重衰减相关
'param': [dict(lr_mult=1, decay_mult=1), dict(lr_mult=2, decay_mult=0)],
'weight_filler': dict(type='xavier'), # 权重初始化模式
'bias_filler': dict(type='constant', value=0)} # 权重偏差初始化模式
# 判断是否存在from_layer层
assert from_layer in net.keys()
# conv1
net.conv1 = L.Convolution(net[from_layer], kernel_size=5, stride=1, num_output=20, pad=0, **kwargs)
net.pool1 = L.Pooling(net.conv1, pool=P.Pooling.MAX, kernel_size=2, stride=2)
net.conv2 = L.Convolution(net.pool1, kernel_size=5, stride=1, num_output=50, pad=0, **kwargs)
net.pool2 = L.Pooling(net.conv2, pool=P.Pooling.MAX, kernel_size=2, stride=2)
net.ip1 = L.InnerProduct(net.pool2, num_output=500, **kwargs)
net.relu1 = L.ReLU(net.ip1, in_place=True)
net.ip2 = L.InnerProduct(net.relu1, name='ip2', num_output=10, **kwargs)
4.2 构建整体网络
训练、测试及实施的三个网络的结构有些许不同,主要集中在输入层和输出层。
# 训练网络
train_net = caffe.NetSpec() # 基础网络
# 带标签的数据输入层
train_net.data, train_net.label = L.Data(source=train_data,backend=P.Data.LMDB, batch_size=64,ntop=2,transform_param=dict(scale=0.00390625))
# 生成LeNet5的主体结构
lenet5_body(train_net, 'data')
# 生成误差损失层
train_net.loss = L.SoftmaxWithLoss(train_net.ip2, train_net.label)
# 测试网络
test_net = caffe.NetSpec() # 基础网络
# 带标签的数据输入层
test_net.data, test_net.label = L.Data(source=test_data, batch_size=100, backend=P.Data.LMDB, ntop=2,transform_param=dict(scale=0.00390625))
# 生成LeNet5的主体结构
lenet5_body(test_net, 'data')
# 生成误差损失层
test_net.loss = L.SoftmaxWithLoss(test_net.ip2, test_net.label)
# 添加一个精确层
test_net.accuracy = L.Accuracy(test_net.ip2, test_net.label)
# 实施网络
deploy_net = caffe.NetSpec() # 基础网络
# 带标签的数据输入层
deploy_net.data = L.Input(input_param=dict(shape=dict(dim=[64,1,28,28])))
# 生成LeNet5的主体结构
lenet5_body(deploy_net, 'data')
deploy_net.prob = L.Softmax(deploy_net.ip2)
4.3 prototxt文件的保存
通过to_proto将网络保存成prototxt文件
# 保存训练文件
with open(model_root+'train.prototxt', 'w') as f:
print('name: "LenNet5_train"', file=f)
print(train_net.to_proto(), file=f)
with open(model_root+'test.prototxt', 'w') as f:
print('name: "LenNet5_test"', file=f)
print(test_net.to_proto(), file=f)
with open(model_root+'deploy.prototxt', 'w') as f:
print('name: "LenNet5_test"', file=f)
print(deploy_net.to_proto(), file=f)
4.4 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的prototxt.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
5. 生成solver文件
网络训练一般是通过solver来进行的。对于caffe来说,其是通过solver文件来生成solver训练器进行网络训练及测试的,该solver文件中包含了训练及测试网络的配置文件的地址,及相关训练方法及一些训练的超参数,该文件一般不是很大,可以直接在一些solver.prototxt文件上更改。也可以通过Python结合caffe_pb2.SolverParameter()结构自动生成solver.prototxt文件
def solver_file(model_root, model_name):
s = caffe_pb2.SolverParameter() # 声明solver结构
s.train_net = model_root+'train.prototxt' # 训练网络结构配置文件
s.test_net.append(model_root+'test.prototxt') # 测试时网络结构配置文件,测试网络可有多个
# 每训练迭代test_interval次进行一次测试。
s.test_interval = 500
# 每次测试时的批量数,测试里网络可有多个
s.test_iter.append(100)
# 最大训练迭代次数
s.max_iter = 10000
# 基础学习率
s.base_lr = 0.01
# 动量,记忆因子
s.momentum = 0.9
# 权重衰减值,遗忘因子
s.weight_decay = 5e-4
# 学习率变化策略。可选参数:fixed、step、exp、inv、multistep
# fixed: 保持base_lr不变;
# step: 学习率变化规律base_lr * gamma ^ (floor(iter / stepsize)),其中iter表示当前的迭代次数;
# exp: 学习率变化规律base_lr * gamma ^ iter;
# inv: 还需要设置一个power,学习率变化规律base_lr * (1 + gamma * iter) ^ (- power);
# multistep: 还需要设置一个stepvalue,这个参数和step相似,step是均匀等间隔变化,而multistep则是根据stepvalue值变化;
# stepvalue参数说明:
# poly: 学习率进行多项式误差,返回base_lr (1 - iter/max_iter) ^ (power);
# sigmoid: 学习率进行sigmod衰减,返回base_lr ( 1/(1 + exp(-gamma * (iter - stepsize))))。
s.lr_policy = 'inv'
s.gamma = 0.0001
s.power = 0.75
s.display = 100 # 每迭代display次显示结果
s.snapshot = 5000 # 保存临时模型的迭代数
s.snapshot_prefix = model_root+model_name+'shapshot' # 模型前缀,就是训练好生成model的名字
s.type = 'SGD' # 训练方法(各类梯度下降法),可选参数:SGD,AdaDelta,AdaGrad,Adam,Nesterov,RMSProp
s.solver_mode = caffe_pb2.SolverParameter.GPU # 训练及测试模型,GPU或CPU
solver_file=model_root+'solver.prototxt' # 要保存的solver文件名
with open(solver_file, 'w') as f:
f.write(str(s))
5.1 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的solver.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
6. 权重预设、预训练及微调
通过上一节,我们将得到solver文件,得到该文件后,进行网络的训练及测试将变得非常简单。
在通过solver = caffe.SGDSolver(solver_proto)
初始化解决器后,训练一般有两种方式,一是通过solver.solve()
直接进行训练,二是通过solver.step(1)
进行单步训练。
6.1 solver直接训练及单步训练
if is_step==False:
# 直接完成训练
solver.solve()
else:
# 迭代次数
max_iter = 10000
# 每隔100次收集一次数据
display = 100
# 每次测试进行100次解算,10000/100
test_iter = 100
# 每500次训练进行一次测试(100次解算),60000/64
test_interval = 500
# 初始化
train_loss = np.zeros(int(math.ceil(max_iter * 1.0 / display)))
test_loss = np.zeros(int(math.ceil(max_iter * 1.0 / test_interval)))
test_acc = np.zeros(int(math.ceil(max_iter * 1.0 / test_interval)))
# iteration 0,不计入
solver.step(1)
# 辅助变量
_train_loss = 0
_test_loss = 0
_accuracy = 0
# 分步训练
for it in range(max_iter):
# 进行一次解算
solver.step(1)
# 每迭代一次,训练batch_size张图片
_train_loss += solver.net.blobs['loss'].data # 最后一层的损失值
if it % display == 0:
# 计算平均train loss
train_loss[int(it / display)] = _train_loss / display
_train_loss = 0
# 测试
if it % test_interval == 0:
for test_it in range(test_iter):
# 进行一次测试
solver.test_nets[0].forward()
# 计算test loss
_test_loss += solver.test_nets[0].blobs['loss'].data
# 计算test accuracy
_accuracy += solver.test_nets[0].blobs['accuracy'].data
# 计算平均test loss
test_loss[it / test_interval] = _test_loss / test_iter
# 计算平均test accuracy
test_acc[it / test_interval] = _accuracy / test_iter
_test_loss = 0
_accuracy = 0
6.2 权重预设
当然很多情况下,我们可能需要从已经训练好的文件中重新开始训练。这种情况下,可以通过solver.net.copy_from(caffemodel)
来导入已训练模型,从已知的权重连接处开始新的训练。
6.3 截取已知网络作为初始训练权重
而有时,我们只需要截取某些其他网络结构的部分参数作为权重预值,比如说SSD会截取VGG网络前段部分的参数作为SSD的特征提取层的初始参数,来减少总体的训练时间,这时我们可以通过如下方法来解决
def crop_network(prune_proto, caffemodel, prune_caffemodel):
# 截取已知网络的部分层
# caffemodel网络权重值并不要求其结构与proto相对应
# 网络只会取train_proto中定义的结构中权重作为网络的初始权重值
# 因此,当我们需要截取某些已训练网络的特定层作为新网络的某些层的权重初始值,只需要在其train_proto定义同名的层
# 之后caffe将在caffemodel中找到与train_proto定义的同名结构,并将其权重作为应用权重初始值。
# prune_deploy: 选择保留的网络结构层:prototxt
# caffemodel: 已知网络的权重连接
# prune_caffemodel:截断网络的权重连接文件
net = caffe.Net(prune_proto, caffemodel, caffe.TEST)
net.save(prune_caffemodel)
6.4 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的train_val.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
7. 网络结构的修剪
网络结构的压缩是近年来研究热点,接下来的两节,我们将介绍Deep Compression的两个策略网络修剪和网络权重共享量化的实现方法,我们通过mnist的LeNet5作为例子,而其他网络的实现也是类似的。
关于Deep Compression的原理,可以参见其论文:Han S, Mao H, Dally W J. Deep compression: Compressing deep neural networks with pruning, trained quantization and huffman coding[C]. In Proc. International Conference on Learning Representations. 2016.
所谓的网络修剪的概念,并不复杂,其大体思想是将不重要的权重连接删除,只保留最重要的连接,而什么是最重要的连接,一般作为是权重值接近0的连接越不重要,有些也认为权重值的Hession值越小的越不重要,而计算Hession值的计算太复杂了,这里参考Deep Compression通用思想将权重值接近0的视为不重要的权重的连接。
网络修剪的第二个问题是,网络修剪率如何设置,对某层到底应该删除多少连接。直观上说,如何某层连接越多,其该删除的连接也越多,比如全连接层的修剪率就要比卷积层多。但如何设置呢?目前的方法一般是采用试错实验来考察不同修剪率对网络的影响来确定合适的修剪率。
网络修剪的第三个问题是如何在修剪后,保证网络精确度不变。可以明确的说,在直接删除网络部分连接后,网络精度肯定会下降的。因此要保证网络精确度不变,需要对修剪后的网络进行重新训练,在多次重新训练后,网络的精度会提升,达到原始网络的精度,甚至在一些情况下,由于网络的稀疏度提高,减少了网络的过拟合,从而达到提高网络精度的效果。
值得注意的是,修剪后网络中,值为0的权重连接在重新训练过程中,将会一直保持为0。
7.1 网络权重的修剪
def prune(threshold, test_net, layers):
sqarse_net = {}
for i, layer in enumerate(layers):
print '\n============ Pruning %s : threshold=%0.2f ============' % (layer,threshold[i])
W = test_net.params[layer][0].data
b = test_net.params[layer][1].data
hi = np.max(np.abs(W.flatten()))
hi = np.sort(-np.abs(W.flatten()))[int((len(W.flatten())-1)* threshold[i])]
# abs(val) = 0 ==> 0
# abs(val) >= threshold ==> 1
interpolated = np.interp(np.abs(W), [0, hi * threshold[i], 999999999.0], [0.0, 1.0, 1.0])
# 小于阈值的权重被随机修剪
random_samps = np.random.rand(len(W.flatten()))
random_samps.shape = W.shape
# 修剪阈值
# mask = (random_samps
7.2 考察不同修剪率下的网络精度变化
def eval_prune_threshold(threshold_list, test_prototxt, caffemodel, prune_layers):
def net_prune(threshold, test_prototx, caffemodel, prune_layers):
test_net = caffe.Net(test_prototx, caffemodel, caffe.TEST)
return prune(threshold, test_net, prune_layers)
accuracy = []
for threshold in threshold_list:
results = net_prune(threshold, test_prototxt, caffemodel, prune_layers)
print 'threshold: ', results[0]
print '\ntotal_percentage: ', results[1]
print '\npercentage_list: ', results[2]
print '\ntest_loss: ', results[3]
print '\naccuracy: ', results[4]
accuracy.append(results[4])
plt.plot(accuracy,'r.')
plt.show()
下图显示不同层的不同修剪率对整个网络精度的影响,以下是修剪率实验设置
test_threshold_list = [
[0.3, 1 ,1 ,1], [0.4, 1 ,1 ,1], [0.5, 1 ,1 ,1], [0.6, 1 ,1 ,1], [0.7, 1 ,1 ,1],
[1, 0.05, 1, 1], [1, 0.1, 1, 1], [1, 0.15, 1, 1], [1, 0.2, 1, 1], [1, 0.3, 1, 1],
[1, 1, 0.05, 1], [1, 1, 0.1, 1], [1, 1, 0.15, 1], [1, 1, 0.2, 1], [1, 1, 0.3, 1],
[1, 1, 1, 0.05], [1, 1, 1, 0.1], [1, 1, 1, 0.15], [1, 1, 1, 0.2], [1, 1, 1, 0.3]]
上面每个数组都有4个值,分别表示'conv1','conv2','ip1','ip2'
各层的修剪率,为1表示不修剪,为0.3表示只保留权重值最大的30%的连接。
根据图上,我们可以选择'conv1','conv2','ip1','ip2'
各层的修剪率分别为[0.3, 0.1, 0.01, 0.2]
7.3 修剪网络的重新训练
# 迭代训练修剪后网络
def retrain_pruned(solver, pruned_caffemodel, threshold, prune_layers):
#solver = caffe.SGDSolver(solver_proto)
retrain_iter = 20
accuracys = []
for i in range(retrain_iter):
solver.net.copy_from(pruned_caffemodel)
# solver.solve()
solver.step(500)
_,_,_,_,accuracy=prune(threshold, solver.test_nets[0], prune_layers)
solver.test_nets[0].save(pruned_caffemodel)
accuracys.append(accuracy)
plt.plot(accuracys, 'r.-')
plt.show()
重新迭代训练时,其精度的变化图,可以看出随着迭代次数增加,其精确度逐渐增加。最终大概只保留了2%左右的权重连接,就达到了原来的精确度。
7.4 稀疏结构的存储
实际上这里的网络修剪并不会在实际内存上减少网络的大小,只会减少网络模型的存储空间,因为该稀疏结构并不是一个通用结构,而是一组随机分布的结构,因此该稀疏结构我们是通过spicy的CSC格式来存储的。
所谓CSC格式,即为按行展开的形式,其将稀疏的矩阵按行展开成一列,只保存不为0的权重值及该值在矩阵中的相对位置。同理还有按列展开的形式CSR。
test_net.params[layer][0].data[...] = W
# net.params[layer][0].mask[...] = mask
csc_W, csc_W_indx = dense_to_sparse_csc(W.flatten(), 8)
dense_W = sparse_to_dense_csc(csc_W, csc_W_indx)
sqarse_net[layer + '_W'] = csc_W
sqarse_net[layer + '_W_indx'] = csc_W_indx
7.5 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的prune.py
项目地址:https://github.com/tostq/Caffe-Python-Tutorial
8. 网络结构的权重共享量化
网络权重共享量化也是一类重要的网络压缩方法,其本质在于先通过聚类方法得到该层权重的聚类中心,然后通过聚类中心值来表示原权重值。因此权重值并不是由32位的浮点数来表示,而是由其对应的聚类中心的序号表示,如果聚类级别为8位,此时权重值只需要用8位就能表示。
对于网络权重量化也有三个问题:
- 量化级别的确定,同修剪率一样,可以通过试错的试验的方法来确定
- 量化后网络重新训练问题
- 量化中心的初始选择问题:聚类中心采用线性方法初始化,将初始点均匀分散,这种初始化方法不仅操作简单,而且能够将对网络影响较大但实际分布较少的较大权重值也包含到初始中心点中,因此不容易造成较大权重的丢失。
8.1 Kmean聚类得到每层的聚类中心
对于Kmean聚类方法,这里调用的是scipy库的聚类函数
# 获得各层的量化码表
def kmeans_net(net, layers, num_c=16, initials=None):
# net: 网络
# layers: 需要量化的层
# num_c: 各层的量化级别
# initials: 初始聚类中心
codebook = {} # 量化码表
if type(num_c) == type(1):
num_c = [num_c] * len(layers)
else:
assert len(num_c) == len(layers)
# 对各层进行聚类分析
print "==============Perform K-means============="
for idx, layer in enumerate(layers):
print "Eval layer:", layer
W = net.params[layer][0].data.flatten()
W = W[np.where(W != 0)] # 筛选不为0的权重
# 默认情况下,聚类中心为线性分布中心
if initials is None: # Default: uniform sample
min_W = np.min(W)
max_W = np.max(W)
initial_uni = np.linspace(min_W, max_W, num_c[idx] - 1)
codebook[layer], _ = scv.kmeans(W, initial_uni)
elif type(initials) == type(np.array([])):
codebook[layer], _ = scv.kmeans(W, initials)
elif initials == 'random':
codebook[layer], _ = scv.kmeans(W, num_c[idx] - 1)
else:
raise Exception
# 将0权重值附上
codebook[layer] = np.append(0.0, codebook[layer])
print "codebook size:", len(codebook[layer])
return codebook
8.2 量化各层
通过各层聚类来进行各层权重的量化
def quantize_net_with_dict(net, layers, codebook, use_stochastic=False, timing=False):
start_time = time.time()
codeDict = {} # 记录各个量化中心所处的位置
maskCode = {} # 各层量化结果
for layer in layers:
print "Quantize layer:", layer
W = net.params[layer][0].data
if use_stochastic:
codes = stochasitc_quantize2(W.flatten(), codebook[layer])
else:
codes, _ = scv.vq(W.flatten(), codebook[layer])
W_q = np.reshape(codebook[layer][codes], W.shape)
net.params[layer][0].data[...] = W_q
maskCode[layer] = np.reshape(codes, W.shape)
codeBookSize = len(codebook[layer])
a = maskCode[layer].flatten()
b = xrange(len(a))
codeDict[layer] = {}
for i in xrange(len(a)):
codeDict[layer].setdefault(a[i], []).append(b[i])
if timing:
print "Update codebook time:%f" % (time.time() - start_time)
return codeDict, maskCode
8.3 重新训练及聚类中心的更新
@static_vars(step_cache={}, step_cache2={}, count=0)
def update_codebook_net(net, codebook, codeDict, maskCode, args, update_layers=None, snapshot=None):
start_time = time.time()
extra_lr = args['lr'] # 基础学习速率
decay_rate = args['decay_rate'] # 衰减速率
momentum = args['momentum'] # 遗忘因子
update_method = args['update'] # 更新方法
smooth_eps = 0
normalize_flag = args['normalize_flag'] # 是否进行归一化
if update_method == 'rmsprop':
extra_lr /= 100
# 对码表与量化结果的初始化
if update_codebook_net.count == 0:
step_cache2 = update_codebook_net.step_cache2
step_cache = update_codebook_net.step_cache
if update_method == 'adadelta':
for layer in update_layers:
step_cache2[layer] = {}
for code in xrange(1, len(codebook[layer])):
step_cache2[layer][code] = 0.0
smooth_eps = 1e-8
for layer in update_layers:
step_cache[layer] = {}
for code in xrange(1, len(codebook[layer])):
step_cache[layer][code] = 0.0
update_codebook_net.count = 1
else:
# 读入上次运算的结果
step_cache2 = update_codebook_net.step_cache2
step_cache = update_codebook_net.step_cache
update_codebook_net.count += 1
# 所有层名
total_layers = net.params.keys()
if update_layers is None: # 所有层都需要进行更新
update_layers = total_layers
# 权重码表的更新
for layer in total_layers:
if layer in update_layers:
diff = net.params[layer][0].diff.flatten() # 误差梯度
codeBookSize = len(codebook[layer])
dx = np.zeros((codeBookSize)) # 编码表的误差更新
for code in xrange(1, codeBookSize):
indexes = codeDict[layer][code] # codeDict保存属于某编码的权重的序号
#diff_ave = np.sum(diff[indexes]) / len(indexes)
diff_ave = np.sum(diff[indexes]) # 统计该编码所有的误差更新和
# 针对于不同方法进行更新
if update_method == 'sgd':
dx[code] = -extra_lr * diff_ave
elif update_method == 'momentum':
if code in step_cache[layer]:
dx[code] = momentum * step_cache[layer][code] - (1 - momentum) * extra_lr * diff_ave
step_cache[layer][code] = dx
elif update_method == 'rmsprop':
if code in step_cache[layer]:
step_cache[layer][code] = decay_rate * step_cache[layer][code] + (1.0 - decay_rate) * diff_ave ** 2
dx[code] = -(extra_lr * diff_ave) / np.sqrt(step_cache[layer][code] + 1e-6)
elif update_method == 'adadelta':
if code in step_cache[layer]:
step_cache[layer][code] = step_cache[layer][code] * decay_rate + (1.0 - decay_rate) * diff_ave ** 2
dx[code] = -np.sqrt((step_cache2[layer][code] + smooth_eps) / (step_cache[layer][code] + smooth_eps)) * diff_ave
step_cache2[layer][code] = step_cache2[layer][code] * decay_rate + (1.0 - decay_rate) * (dx[code] ** 2)
# 是否需要进行归一化更新参数
if normalize_flag:
codebook[layer] += extra_lr * np.sqrt(np.mean(codebook[layer] ** 2)) / np.sqrt(np.mean(dx ** 2)) * dx
else:
codebook[layer] += dx
else:
pass
# maskCode保存编码结果
W2 = codebook[layer][maskCode[layer]]
net.params[layer][0].data[...] = W2 # 量化后权重值
print "Update codebook time:%f" % (time.time() - start_time)
重新训练时,其精度的变化图,可以看到随着迭代次数增加,其精度也逐渐提升
8.4 网络压缩未来的方向
从上面可以看出来,在训练中,各网络中的权重仍是32位的浮点数,而不是用8位来表示,而即使在实际运行中,也必须通过聚类中心表将量化后权重值转换为32位的浮点数,因此并不能在减少网络的实际运行内存,只是减少网络的内存消耗。
要真正减少网络内存消耗,从而达到网络实际运行速度的提高,目前有两类主流方法:
-
让网络保证结构上的稀疏性,而不是随机分布的稀疏性。可以参考论文:
Wen W, Wu C, Wang Y, et al. Learning Structured Sparsity in Deep Neural Networks[J]. 2016. -
设计量化运算的网络,比如QNN及BNN等等。可以参考论文:Hubara I, Courbariaux M, Soudry D, et al. Quantized neural networks: Training neural networks with low precision weights and activations[J]. Journal of Machine Learning Research. 2016, 1: 1–29.
8.5 具体代码下载
GitHub仓库Caffe-Python-Tutorial中的quantize.py