Hi, Thinking

将MatConvNet预训练模型转成Keras模型

总字数:约10000字,阅读时间:约10分钟

在最开始接触深度学习的时候,很经常使用MatConvNet。主要是因为里面的预训练模型比较丰富,在fine-tune的过程中能获得比较好的结果,很适合新手使用。但是随着对深度网络越来越深入之后,很多就需要自己去设计层结构,损失函数,规则项等等,这个时候MatConvNet就显得力不从心了。而keras的出现就大大简化了这一过程,良好的接口让构建模型简单快速,而且可扩展性强。对于需要深入底层的实现,也可以使用TensorFlow或Theano来实现,确实是一个很好的工具。

不过虽然Keras上面也有很多预训练好的模型,可以用来进行fine-tune,但是其丰富程度远没有MatConvNet的多。思来想去,决定自己写一个函数来实现MatConvNet模型对Keras模型的一个转变。

读取mat文件

在Python中,要读取Matlab的.mat文件需要借助Scipy库中的io模块。在Scipy.io中有一个loadmat函数,可以实现对.mat文件的读取。注:本文以vgg-face模型为例进行说明。

1
mat_model = loadmat('vgg-face.mat', squeeze_me=True, struct_as_record=False)

这里有两个参数,squeeze_me是用于将Matlab中空的二维结构压缩成一维结构,因为Matlab中常出现(1, N)的数组,所以直接读取,在Python也会保留二维结构。但实际上它并没有任何意义,反倒会因为需要做两层索引而增加编程负担,而被压缩成一维之后在后续操作中会更为便捷。struct_as_record默认是True,在这种情况下struct类型数据会被存在dict结构中。当把这个设置成False时,所有的struct数据会被当成属性,调用起来比较直观。

前期处理

在vgg-face.mat中,只有layers里面的内容是涉及到网络结构的,而meta中的内容是关于数据集的一些信息,在这里我们暂时用不上。

这里是我们需要用到的模块。

1
2
3
4
from scipy.io import loadmat
from keras.models import Model
from keras.layers import Convolution2D, MaxPooling2D, AveragePooling2D, \
Flatten, Dense, Input, Activation

然后,我们可以定义一个函数,来专门完成模型的转换功能。

1
def convert_model_from_matconvnet(mat_model_layers, input_shape, only_architecture=False):

mat_model_layers指的是MatConvNet模型中的layers数据,传入的时候只需要传入这个参数就可以了。input_shape是模型的输入大小,这个是在meta中,所以我们也单独把它提出来,作为参数传入。only_architecture是指只保留结构,而不导入权重,在个别情况下,我们可能只需要结构,而不需要预训练模型。

函数接口定义完之后,我们就可以开始进入细节了。首先做一些准备工作。

1
2
3
4
5
6
7
inputs = Input(shape=input_shape)
x = inputs
pooling_dict = {'max': MaxPooling2D, 'avg' : AveragePooling2D}
activation_set = ['relu', 'sigmod', 'tanh', 'linear', 'softmax']
is_flatten = False

我们这里使用泛型模型的API来构建Keras模型,这样的好处是以后遇到了新的结构,可以很好的做扩展。其次是pooling_dict和activation_set,我们事先将pooling层类型和activation类型放置在这两个结构之中,然后内部需要调用时直接从这里找,这样在之后需要扩展的时候直接将新的层结构加到这里面就可以了。最后is_flatten是用来判断是否被flatten,在MatConvNet中,没有flatten的操作,它直接用conv层来表示全连接层,最后出来的结果用squeeze函数来压缩冗余的维度,但是在Keras并不能这样做,所以我们需要判定一下在全连接层前是否被flatten,如果没有的话我们需要添加Flatten层

构建层

构建层是整个函数的核心。先将所有代码都放上来,在后面我将会一步一步详细说明实现逻辑。

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
for mat_layer in mat_model_layers:
layer_type = mat_layer.type
layer_name = mat_layer.name
if layer_type == 'conv':
if layer_name.startswith('conv'):
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape[:2]
nb_filter = mat_weights.shape[-1]
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Convolution2D(nb_filter, weights_shape[0], weights_shape[1],
border_mode='same',
name=layer_name,
weights=keras_weights)(x)
elif layer_name.startswith('fc'):
if not is_flatten:
# conv layer is be used as fc layer in matconvnet, so we need to flatten in keras
# and used dense instead in keras.
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape[:2]
nb_filter = mat_weights.shape[-1]
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Convolution2D(nb_filter, weights_shape[0], weights_shape[1],
border_mode='valid',
name=layer_name,
weights=keras_weights)(x)
x = Flatten()(x)
is_flatten = True
else:
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape
nb_filter = 1
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Dense(weights_shape[1], name=layer_name, weights=keras_weights)(x)
else:
raise TypeError('Layer name "%s" from matconvnet can"t match keras type' % layer_name)
elif layer_type == 'pool':
method = mat_layer.method
stride = mat_layer.stride
pool_size = mat_layer.pool
x = pooling_dict[method](pool_size, (stride, stride), name=layer_name)(x)
elif layer_type in activation_set:
x = Activation(layer_type, name=layer_name)(x)
else:
raise TypeError('%s from matconvnet not implement in keras.' % layer_type)

整体代码逻辑并不复杂,但是涉及一些细节处理的问题,需要详细说明一下。我们需要对每一层进行迭代。进入循环后,获取层的类型和层的名字,在MatConvNet中,层类型和层名在type和name两个属性中,这里我们可以直接获取。

1
2
3
for mat_layer in mat_model_layers:
layer_type = mat_layer.type
layer_name = mat_layer.name

获取到这两个参数之后,开始进行验证。如果是conv类型,则是MatConvNet中的卷积层,这个时候对应的层中,会有weights属性,这里就是我们需要的权重。weights是一个列表,第一个元素是filter的权重,第二个元素是bias的值。我查阅了MatConvNet的文档,里面给出他的权重是按照HWD*D’的结构定义的,分别代表高度、宽度、深度和卷积核数。在Keras中,卷积层只需要给定卷积核的高度、宽度和核数。深度在底层会自动进行匹配,所以我们只需要将这几个值切片出来,然后创建卷积层,并传入相关权重即可。

但是,这里有一点需要格外注意,因为MatConvNet中没有单独定义全连接层,其直接使用卷积层作为替换,最后结果使用squeeze函数来压缩冗余的维度。而这两种层唯一的区别在于name属性中,卷积层为’conv’开头的字样,如’conv1_1’,而全连接层以’fc’开头的字样,如’fc8’。所以这个时候我们就以name属性作为判断依据来判定是否为全连接层。

1
2
3
4
5
6
7
8
9
10
11
if layer_type == 'conv':
if layer_name.startswith('conv'):
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape[:2]
nb_filter = mat_weights.shape[-1]
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Convolution2D(nb_filter, weights_shape[0], weights_shape[1],
border_mode='same',
name=layer_name,
weights=keras_weights)(x)

值得一提的是,在MatConvNet中,存在stride和padding属性来确定遍历情况。但是在Keras中却没有,Keras只提供了三种border_mode(’valid’:只在特征内遍历,不进行扩充。’same’:保持特征大小不变, ‘full’:遍历所有元素,卷积核超出特征边界的部分设置为0)。如下图所示(下图来源于深度学习Keras群@情笔M医学影像)。

border_mode.jpg

而VGGFACE模型中,所使用的卷积层均为stride=1,padding=1,即为Keras中的same模式,所以我们定义卷积层的时候直接用这个border_mode即可。

回过头来,如果是全连接层,我们就需要做一个判定,看是否被flatten。这里是MatConvNet设计上的一个区别,因为没有flatten层,所以它只能采用类似于全卷积的方法,定义一个卷积核与特征维度相同的卷积层,使得最后出来的特征是单维的。而这一层实际上是一个全连接层,所以MatConvNet使用’fc’字样开头的名字来定义name属性,也就是说这一个包含卷积核的全连接层。而Keras中因为有Flatten层,所以不需要考虑这么多细节问题。但是在这里,我们就需要依照MatConvNet的结构,对第一个遇到的全连接层,再多定义一个卷积层,并且将border_mode设置为valid(相当于stride为1)。

其次还有一个问题是权重维度,因为对于全连接层而言,权重维度为2,但是对于卷积层而言,则是4。所以对于其后的全连接层,它是按照input_node*output_node的结构来定义的,所以我们取权重的第二维大小作为全连接层的节点数即可。最后对未实现的结构抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
elif layer_name.startswith('fc'):
if not is_flatten:
# conv layer is be used as fc layer in matconvnet, so we need to flatten in keras
# and used dense instead in keras.
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape[:2]
nb_filter = mat_weights.shape[-1]
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Convolution2D(nb_filter, weights_shape[0], weights_shape[1],
border_mode='valid',
name=layer_name,
weights=keras_weights)(x)
x = Flatten()(x)
is_flatten = True
else:
mat_weights = mat_layer.weights[0]
mat_bias = mat_layer.weights[1]
weights_shape = mat_weights.shape
keras_weights = None if only_architecture else [mat_weights, mat_bias]
x = Dense(weights_shape[1], name=layer_name, weights=keras_weights)(x)
else:
raise TypeError('Layer name "%s" from matconvnet can"t match keras type' % layer_name)

然后就是对pooling层和activation层的一个添加,这部分比较好理解。由于我们事先将pooling层类型和activation类型添加入了pooling_dict和activation_set中,所以检测和添加过程都比较简单。需要提一下的是,Keras中的pooling层需要给出pooling size和stride,这两个参数分别对应MatConvNet中的pool和stride属性,其中stride属性在MatConvNet中是一个数,因为它在两个方向上的遍历步长都是一样的,而Keras需要分别给出,所以我们多复制一个就好了。

最后对未实现的层抛出一个异常。如果之后又新的模型出来,需要转变新的层结构,只需要在这里的分支结构添加一个新的分支即可。

1
2
3
4
5
6
7
8
9
elif layer_type == 'pool':
method = mat_layer.method
stride = mat_layer.stride
pool_size = mat_layer.pool
x = pooling_dict[method](pool_size, (stride, stride), name=layer_name)(x)
elif layer_type in activation_set:
x = Activation(layer_type, name=layer_name)(x)
else:
raise TypeError('%s from matconvnet not implement in keras.' % layer_type)

最后,创建用输入和输出创建整个模型并返回,就完成了。

‘’’
model = Model(input=inputs, output=x)
return model
‘’’

测试

在MatConvNet中,给了每一个模型的测试程序,这里我们就仿照着他的测试程序,写一个Keras版本的,对了,在meta中,包含了整个模型的元信息,包括输入大小,训练集统计数据,标签表述等等。需要的话可以去对应的.mat文件中查看。

‘’’
import os
from PIL import Image

if name == ‘main‘:
mat_model = loadmat(os.path.join(‘..’, ‘matconvnet_model’, ‘vgg-face.mat’), squeeze_me=True, struct_as_record=False)
model = convert_model_from_matconvnet(mat_model[‘layers’], input_shape=(224, 224, 3))

im = Image.open('Aamir_Khan_March_2015.jpg')
im = im.crop(box=(0, 0, im.size[0], 250))
im = im.resize(size=(224, 224))
im = np.array(im, dtype=np.float32) - mat_model['meta'].normalization.averageImage

score = model.predict(np.array(im)[np.newaxis, :])
idx = np.argmax(score)
print(mat_model['meta'].classes.description[idx])

‘’’

最后输出结果。

1
Aamir_Khan
Kivi.记