“让Keras更酷一些!”:层与模型的重用技巧
By 苏剑林 | 2019-09-29 | 109283位读者 |今天我们继续来深挖Keras,再次体验Keras那无与伦比的优雅设计。这一次我们的焦点是“重用”,主要是层与模型的重复使用。
所谓重用,一般就是奔着两个目标去:一是为了共享权重,也就是说要两个层不仅作用一样,还要共享权重,同步更新;二是避免重写代码,比如我们已经搭建好了一个模型,然后我们想拆解这个模型,构建一些子模型等。
基础 #
事实上,Keras已经为我们考虑好了很多,所以很多情况下,掌握好基本用法,就已经能满足我们很多需求了。
层的重用 #
层的重用是最简单的,将层初始化好,存起来,然后反复调用即可:
x_in = Input(shape=(784,))
x = x_in
layer = Dense(784, activation='relu') # 初始化一个层,并存起来
x = layer(x) # 第一次调用
x = layer(x) # 再次调用
x = layer(x) # 再次调用
要注意的是,必须先初始化好一个层,存为一个变量好再调用,才能保证重复调用的层是共享权重的。反之,如果是下述形式的代码,则是非共享权重的:
x = Dense(784, activation='relu')(x)
x = Dense(784, activation='relu')(x) # 跟前面的不共享权重
x = Dense(784, activation='relu')(x) # 跟前面的不共享权重
模型重用 #
Keras的模型有着类似层的表现,在调用时可以用跟层一样的方式,比如:
x_in = Input(shape=(784,))
x = x_in
x = Dense(10, activation='softmax')(x)
model = Model(x_in, x) # 建立模型
x_in = Input(shape=(100,))
x = x_in
x = Dense(784, activation='relu')(x)
x = model(x) # 将模型当层一样用
model2 = Model(x_in, x)
读过Keras源码的朋友就会明白,之所以可以将模型当层那样用,是因为Model
本身就是继承Layer
类来写的,所以模型自然也包含了层的一些相同特性。
模型克隆 #
模型克隆跟模型重用类似,只不过得到的新模型跟原模型不共享权重了,也就是说,仅仅保留完全一样的模型结构,两个模型之间的更新是独立的。Keras提供了模型可用专用的函数,直接调用即可:
from keras.models import clone_model
model2 = clone_model(model1)
注意,clone_model
完全复制了原模型模型的结构,并重新构建了一个模型,但没有复制原模型的权重的值。也就是说,对于同样的输入,model1.predict
和model2.predict
的结果是不一样的。
如果要把权重也搬过来,需要手动set_weights
一下:
model2.set_weights(K.batch_get_value(model1.weights))
进阶 #
上述谈到的是原封不等的调用原来的层或模型,所以比较简单,Keras都准备好了。下面介绍一些复杂一些的例子。
交叉引用 #
这里的交叉引用是指在定义一个新层的时候,沿用已有的某个层的权重,注意这个自定义层可能跟旧层的功能完全不一样,它们之间纯粹是共享了某个权重而已。比如,Bert在训练MLM的时候,最后预测字词概率的全连接层,权重就是跟Embedding层共享的。
参考写法如下:
class EmbeddingDense(Layer):
"""运算跟Dense一致,只不过kernel用Embedding层的embedding矩阵
"""
def __init__(self, embedding_layer, activation='softmax', **kwargs):
super(EmbeddingDense, self).__init__(**kwargs)
self.kernel = K.transpose(embedding_layer.embeddings)
self.activation = activation
self.units = K.int_shape(self.kernel)[1]
def build(self, input_shape):
super(EmbeddingDense, self).build(input_shape)
self.bias = self.add_weight(name='bias',
shape=(self.units,),
initializer='zeros')
def call(self, inputs):
outputs = K.dot(inputs, self.kernel)
outputs = K.bias_add(outputs, self.bias)
outputs = Activation(self.activation).call(outputs)
return outputs
def compute_output_shape(self, input_shape):
return input_shape[:-1] + (self.units,)
# 用法
embedding_layer = Embedding(10000, 128)
x = embedding_layer(x) # 调用Embedding层
x = EmbeddingDense(embedding_layer)(x) # 调用EmbeddingDense层
提取中间层 #
有时候我们需要从搭建好的模型中提取中间层的特征,并且构建一个新模型,在Keras中这同样是很简单的操作:
from keras.applications.resnet50 import ResNet50
model = ResNet50(weights='imagenet')
Model(
inputs=model.input,
outputs=[
model.get_layer('res5a_branch1').output,
model.get_layer('activation_47').output,
]
)
从中间拆开 #
最后,来到本文最有难度的地方了,我们要将模型从中间拆开,搞懂之后也可以实现往已有模型插入或替换新层的操作。这个需求看上去比较奇葩,但是还别说,stackoverflow上面还有人提问过,说明这确实是有价值的。
假设我们有一个现成的模型,它可以分解为
$$\text{inputs}\to h_1 \to h_2 \to h_3 \to h_4 \to \text{outputs}$$
那可能我们需要将$h_2$替换成一个新的输入,然后接上后面的层,来构建一个新模型,即新模型的功能是:
$$\text{inputs} \to h_3 \to h_4 \to \text{outputs}$$
如果是Sequential
类模型,那比较简单,直接把model.layers
都遍历一边,就可以构建新模型了:
x_in = Input(shape=(100,))
x = x_in
for layer in model.layers[2:]:
x = layer(x)
model2 = Model(x_in, x)
但是,如果模型是比较复杂的结构,比如残差结构这种不是一条路走到底的,就没有这么简单了。事实上,这个需求本来没什么难度,该写的Keras本身已经写好了,只不过没有提供现成的接口罢了。为什么这么说,因为我们通过model(x)
这样的代码调用已有模型的时候,实际上Keras就相当于把这个已有的这个model
从头到尾重新搭建了一遍,既然可以重建整个模型,那搭建“半个”模型原则上也是没有任技术难度的,只不过没有现成的接口。具体可以参考Keras源码的keras/engine/network.py
的run_internal_graph
函数。
完整重建一个模型的逻辑在run_internal_graph
函数里边,并且可以看到它还不算简单,所以如无必要我们最好不要重写这个代码。但如果不重写这个代码,又想调用这个代码,实现从中间层拆解模型的功能,唯一的办法是“移花接木”了:通过修改已有模型的一些属性,欺骗一下run_internal_graph
函数,使得它以为模型的输入层是中间层,而不是原始的输入层。有了这个思想,再认真读读run_internal_graph
函数的代码,就不难得到下述参考代码:
def get_outputs_of(model, start_tensors, input_layers=None):
"""start_tensors为开始拆开的位置
"""
# 为此操作建立新模型
model = Model(inputs=model.input,
outputs=model.output,
name='outputs_of_' + model.name)
# 适配工作,方便使用
if not isinstance(start_tensors, list):
start_tensors = [start_tensors]
if input_layers is None:
input_layers = [
Input(shape=K.int_shape(x)[1:], dtype=K.dtype(x))
for x in start_tensors
]
elif not isinstance(input_layers, list):
input_layers = [input_layers]
# 核心:覆盖模型的输入
model.inputs = start_tensors
model._input_layers = [x._keras_history[0] for x in input_layers]
# 适配工作,方便使用
if len(input_layers) == 1:
input_layers = input_layers[0]
# 整理层,参考自 Model 的 run_internal_graph 函数
layers, tensor_map = [], set()
for x in model.inputs:
tensor_map.add(str(id(x)))
depth_keys = list(model._nodes_by_depth.keys())
depth_keys.sort(reverse=True)
for depth in depth_keys:
nodes = model._nodes_by_depth[depth]
for node in nodes:
n = 0
for x in node.input_tensors:
if str(id(x)) in tensor_map:
n += 1
if n == len(node.input_tensors):
if node.outbound_layer not in layers:
layers.append(node.outbound_layer)
for x in node.output_tensors:
tensor_map.add(str(id(x)))
model._layers = layers # 只保留用到的层
# 计算输出
outputs = model(input_layers)
return input_layers, outputs
用法:
from keras.applications.resnet50 import ResNet50
model = ResNet50(weights='imagenet')
x, y = get_outputs_of(
model,
model.get_layer('add_15').output
)
model2 = Model(x, y)
代码有点长,但其实逻辑很简单,真正核心的代码只有三行:
model.inputs = start_tensors
model._input_layers = [x._keras_history[0] for x in input_layers]
outputs = model(input_layers)
也就是覆盖模型的model.inputs
和model._input_layers
就可以实现欺骗模型从中间层开始构建的效果了,其余的多数是适配工作,不是技术上的,而model._layers = layers
这一句是只保留了从中间层开始所用到的层,只是为了统计模型参数量的准确性,如果去掉这一部分,模型的参数量依然是原来整个model那么多。
小结 #
Keras是最让人赏心悦目的深度学习框架,至少到目前为止,就模型代码的可读性而言,没有之一。可能读者会提到PyTorch,诚然PyTorch也有不少可取之处,但就可读性而言,我认为是比不上Keras的。
在深究Keras的过程中,我不仅惊叹于Keras作者们的深厚而优雅的编程功底,甚至感觉自己的编程技能也提高了不少。不错,我的很多Python编程技巧,都是从读Keras源码中学习到的。
转载到请包括本文地址:https://kexue.fm/archives/6985
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Sep. 29, 2019). 《“让Keras更酷一些!”:层与模型的重用技巧 》[Blog post]. Retrieved from https://kexue.fm/archives/6985
@online{kexuefm-6985,
title={“让Keras更酷一些!”:层与模型的重用技巧},
author={苏剑林},
year={2019},
month={Sep},
url={\url{https://kexue.fm/archives/6985}},
}
September 29th, 2019
get_outputs_of函数中,第16行的判断是不是多余了?
必要的,但有个笔误,已经修正。
这样一改,就理解了。
September 30th, 2019
苏神你好,请教一下~
在交叉引用那一节中,如果我把层内的操作放到外面去做,直接做个转置然后点乘,
和您实例里面的把操作包装做一个层,除了写的不一样之外还会有什么区别吗?
没什么区别,但最终还是要用Lambda层封装一下。
October 3rd, 2019
非技术的路过。
October 15th, 2019
苏神你好,我想请教一个问题
在keras用fit_generator进行训练的时候,每个epoch的steps_per_epoch可能都是不一样的,它的具体值是在训练集generator生成器中产生或者定义的,这时候fit_generator里的steps_per_epoch该怎么定义呢
steps_per_epoch并不影响训练效果,为什么一定要弄成动态的呢?steps_per_epoch大概就是“每steps_per_epoch步,keras的进度条就换一个新行”的感觉而已。
December 4th, 2019
苏神你好,我在往bert的Embedding-Norm层加扰动的时候遇到了问题想请教您。
我想从bert的Embedding-Norm层取出输出,加入扰动
\begin{lstlisting}
norm_embedding = ernie.get_layer('Embedding-Norm').output
noise = tf.random_normal(shape=tf.shape(norm_embedding))
perturb = _scale_l2(_mask_by_length(noise, l), 5.0)
_token = Add()([norm_embedding, perturb])
__output = model_output(model_transformer(_token))
_loss = K.mean(K.binary_crossentropy(y, __output))
\end{lstlisting}
model_transformer是我从bert中提取的中间层到最后的(None,None,768)输出。
\begin{lstlisting}
_x, _y = get_outputs_of(ernie, ernie.get_layer('Embedding-Norm').output)
model_transformer = Model(input=_x, output=_y)
\end{lstlisting}
但是在运行的时候报以下错误:
\begin{lstlisting}
tensorflow.python.framework.errors_impl.InvalidArgumentError: 2 root error(s) found.
(0) Invalid argument: You must feed a value for placeholder tensor 'Input-Token' with dtype float and shape [?,513]
[[{{node Input-Token}}]]
[[Mean_2/_3965]]
(1) Invalid argument: You must feed a value for placeholder tensor 'Input-Token' with dtype float and shape [?,513]
[[{{node Input-Token}}]]
\end{lstlisting}
你这个需求还是直接改bert.py的源码吧,你这种改法是不生效的。
September 22nd, 2020
苏神您好,我运行了您的get_outputs_of()方法,但是现在
在for x in node.input_tensors行
报错:TypeError: Cannot iterate over a tensor with unknown first dimension.
请教一下苏神到底是为什么?
x, y = get_outputs_of(
model,
model.get_layer('concatenate').output
)
我也很久不用这代码了,根据这文章的编写时间,估计当时的keras版本是2.2.4
的确有可能是版本的问题,我现在的版本是tf2.0.0 和keras2.3.1 在这个环境下,resnet50示例代码也无法跑通
November 13th, 2020
写得太好了,神级文章,必须打赏!!!
January 30th, 2021
您好。请问用了get_outputs_of之后,得到的新的模型拥有原模型的权重吗?
是的
谢谢!如果是这样的话,是不是可以在get_outputs_of函数的最后直接把model返回出来,像这样:
return input_layers, outputs, model
这样的话,返回的这个model就是改造过的,是不是就和model2 = Model(x, y)这样等价
你自己创建不也一样么...
February 1st, 2021
了解了,再次感谢!
之前大概看看别人写的代码,并没有真正研究过。
May 11th, 2021
非常感谢,解决大问题了!
想问一下model.get_layer()得到的是那个层对象吗,如果我在这里直接调用得到的layer进行计算,是不是会影响原本那个model的计算图?
默认情况下所有模型都是同一个图