“让Keras更酷一些!”:层中层与mask
By 苏剑林 | 2019-07-16 | 149743位读者 |这一篇“让Keras更酷一些!”将和读者分享两部分内容:第一部分是“层中层”,顾名思义,是在Keras中自定义层的时候,重用已有的层,这将大大减少自定义层的代码量;另外一部分就是应读者所求,介绍一下序列模型中的mask原理和方法。
层中层 #
在《“让Keras更酷一些!”:精巧的层与花式的回调》一文中我们已经介绍过Keras自定义层的基本方法,其核心步骤是定义build
和call
两个函数,其中build
负责创建可训练的权重,而call
则定义具体的运算。
拒绝重复劳动 #
经常用到自定义层的读者可能会感觉到,在自定义层的时候我们经常在重复劳动,比如我们想要增加一个线性变换,那就要在build
中增加一个kernel
和bias
变量(还要自定义变量的初始化、正则化等),然后在call
里边用K.dot
来执行,有时候还需要考虑维度对齐的问题,步骤比较繁琐。但事实上,一个线性变换其实就是一个不加激活函数的Dense
层罢了,如果在自定义层时能重用已有的层,那显然就可以大大节省代码量了。
事实上,只要你对Python面向对象编程比较熟悉,然后仔细研究Keras的Layer
的源代码,就不难发现重用已有层的方法了。下面将它整理成比较规范的流程,供读者参考调用。
(注意:Keras 2.3.0开始就已经内置了层中层功能,不需要下面的自定义OurLayer
了,直接就用Layer
即可。)
OurLayer #
首先,我们定义一个新的OurLayer
类:
class OurLayer(Layer):
"""定义新的Layer,增加reuse方法,允许在定义Layer时调用现成的层
"""
def reuse(self, layer, *args, **kwargs):
if not layer.built:
if len(args) > 0:
inputs = args[0]
else:
inputs = kwargs['inputs']
if isinstance(inputs, list):
input_shape = [K.int_shape(x) for x in inputs]
else:
input_shape = K.int_shape(inputs)
layer.build(input_shape)
outputs = layer.call(*args, **kwargs)
for w in layer.trainable_weights:
if w not in self._trainable_weights:
self._trainable_weights.append(w)
for w in layer.non_trainable_weights:
if w not in self._non_trainable_weights:
self._non_trainable_weights.append(w)
for u in layer.updates:
if not hasattr(self, '_updates'):
self._updates = []
if u not in self._updates:
self._updates.append(u)
return outputs
这个OurLayer
类继承了原来的Layer
类,为它增加了reuse
方法,就是通过它我们可以重用已有的层。
下面是一个简单的例子,定义一个层,运算如下:
$$y = g(f(xW_1 + b_1)W_2 + b_2)$$
这里$f,g$是激活函数,其实就是两个Dense
层的复合,如果按照标准的写法,我们需要在build
那里定义好几个权重,定义权重的时候还需要根据输入来定义shape,还要定义初始化等,步骤很多,但事实上这些在Dense
层不都写好了吗,直接调用就可以了,参考调用代码如下:
class OurDense(OurLayer):
"""原来是继承Layer类,现在继承OurLayer类
"""
def __init__(self, hidden_dim, output_dim,
hidden_activation='linear',
output_activation='linear', **kwargs):
super(OurDense, self).__init__(**kwargs)
self.hidden_dim = hidden_dim
self.output_dim = output_dim
self.hidden_activation = hidden_activation
self.output_activation = output_activation
def build(self, input_shape):
"""在build方法里边添加需要重用的层,
当然也可以像标准写法一样条件可训练的权重。
"""
super(OurDense, self).build(input_shape)
self.h_dense = Dense(self.hidden_dim,
activation=self.hidden_activation)
self.o_dense = Dense(self.output_dim,
activation=self.output_activation)
def call(self, inputs):
"""直接reuse一下层,等价于o_dense(h_dense(inputs))
"""
h = self.reuse(self.h_dense, inputs)
o = self.reuse(self.o_dense, h)
return o
def compute_output_shape(self, input_shape):
return input_shape[:-1] + (self.output_dim,)
是不是特别清爽?
Mask #
这一节我们来讨论一下处理变长序列时的padding和mask问题。
证明你思考过 #
近来笔者开源的几个模型中大量地用到了mask,不少读者似乎以前从未遇到过这个东西,各种疑问纷至沓来。本来,对一样新东西有所疑问是无可厚非的事情,但问题是不经思考的提问就显得很不负责任了。我一直认为,在向别人提问的时候,需要同时去“证明”自己是思考过的,比如如果你要去解释关于mask的问题,我会先请你回答:
mask之前的序列大概是怎样的?mask之后序列的哪些位置发生了变化?变成了怎么样?
这三个问题跟mask的原理没有关系,只是要你看懂mask做了什么运算,在此基础上,我们才能去讨论为什么要这样运算。如果你连运算本身都看不懂,那只有两条路可选了,一是放弃这个问题的理解,二是好好学几个月Keras咱们再来讨论。
下面假设读者已经看懂了mask的运算,然后我们来简单讨论一下mask的基本原理。
排除padding #
mask是伴随这padding出现的,因为神经网络的输入需要一个规整的张量,而文本通常都是不定长的,这样一来就需要裁剪或者填充的方式来使得它们变成定长,按照常规习惯,我们会使用0作为padding符号。
这里用简单的向量来描述padding的原理。假设有一个长度为5的向量:
$$x = [1, 0, 3, 4, 5]$$
经过padding变成长度为8:
$$x = [1, 0, 3, 4, 5, 0, 0, 0]$$
当你将这个长度为8的向量输入到模型中时,模型并不知道你这个向量究竟是“长度为8的向量”还是“长度为5的向量,填充了3个无意义的0”。为了表示出哪些是有意义的,哪些是padding的,我们还需要一个mask向量(矩阵):
$$m = [1, 1, 1, 1, 1, 0, 0, 0]$$
这是一个0/1向量(矩阵),用1表示有意义的部分,用0表示无意义的padding部分。
所谓mask,就是$x$和$m$的运算,来排除padding带来的效应。比如我们要求$x$的均值,本来期望的结果是:
$$\text{avg}(x) = \frac{1 + 0 + 3 + 4 + 5}{5} = 2.6$$
但是由于向量已经经过padding,直接算的话就得到:
$$\frac{1 + 0 + 3 + 4 + 5 + 0 + 0 + 0}{8} = 1.625$$
会带来偏差。更严重的是,对于同一个输入,每次padding的零的数目可能是不固定的,因此同一个样本每次可能得到不同的均值,这是很不合理的。有了mask向量$m$之后,我们可以重写求均值的运算:
$$\text{avg}(x) = \frac{\text{sum}(x\otimes m)}{\text{sum}(m)}$$
这里的$\otimes$是逐位对应相乘的意思。这样一来,分子只对非padding部分求和,分母则是对非padding部分计数,不管你padding多少个零,最终算出来的结果都是一样的。
如果要求$x$的最大值呢?我们有$\max([1, 0, 3, 4, 5]) = \max([1, 0, 3, 4, 5, 0, 0, 0]) = 5$,似乎不用排除padding效应了?在这个例子中是这样,但还有可能是:
$$x = [-1, -2, -3, -4, -5]$$
经过padding后变成了
$$x = [-1, -2, -3, -4, -5, 0, 0, 0]$$
如果直接对padding后的$x$求$\max$,那么得到的是0,而0不在原来的范围内。这时候解决的方法是:让padding部分足够小,以至于$\max$(几乎)不能取到padding部分,比如
$$\max(x) = \max\left(x - (1 - m) \times 10^{10}\right)$$
正常来说,神经网络的输入输出的数量级不会很大,所以经过$x - (1 - m) \times 10^{10}$后,padding部分在$-10^{10}$这个数量级中上,可以保证取$\max$的话不会取到padding部分了。
处理softmax的padding也是如此。在Attention或者指针网络时,我们就有可能遇到对变长的向量做softmax,如果直接对padding后的向量做softmax,那么padding部分也会平摊一部分概率,导致实际有意义的部分概率之和都不等于1了。解决办法跟$\max$时一样,让padding部分足够小足够小,使得$e^x$足够接近于0,以至于可以忽略:
$$\text{sofmax}(x) = \text{softmax}\left(x - (1 - m) \times 10^{10}\right)$$
上面几个算子的mask处理算是比较特殊的,其余运算的mask处理(除了双向RNN),基本上只需要输出
$$x\otimes m$$
就行了,也就是让padding部分保持为0。
Keras实现要点 #
Keras自带了mask功能,但是不建议用,因为自带的mask不够清晰灵活,而且也不支持所有的层,强烈建议读者自己实现mask。
近来开源的好几个模型都已经给出了足够多的mask案例,我相信读者只要认真去阅读源码,一定很容易理解mask的实现方式的,这里简单提一下几个要点。一般来说NLP模型的输入是词ID矩阵,形状为$\text{[batch_size, seq_len]}$,其中我会用0作为padding的ID,而1作为UNK的ID,剩下的就随意了,然后我就用一个Lambda
层生成mask矩阵:
# x是词ID矩阵
mask = Lambda(lambda x: K.cast(K.greater(K.expand_dims(x, 2), 0), 'float32'))(x)
这样生成的mask矩阵大小是$\text{[batch_size, seq_len, 1]}$,然后词ID矩阵经过Embedding
层后的大小为$\text{[batch_size, seq_len, word_size]}$,这样一来就可以用mask矩阵对输出结果就行处理了。这种写法只是我的习惯,并非就是唯一的标准。
结合:双向RNN #
刚才我们的讨论排除了双向RNN,这是因为RNN是递归模型,没办法简单地mask(主要是逆向RNN这部分)。所谓双向RNN,就是正反各做一次RNN然后拼接或者相加之类的。假如我们要对$[1, 0, 3, 4, 5, 0, 0, 0]$做逆向RNN运算时,最后输出的结果都会包含padding部分的0(因为padding部分在一开始就参与了运算)。因此事后是没法排除的,只有在事前排除。
排除的方案是:要做逆向RNN,先将$[1, 0, 3, 4, 5, 0, 0, 0]$反转为$[5, 4, 3, 0, 1, 0, 0, 0]$,然后做一个正向RNN,然后再把结果反转回去,要注意反转的时候只反转非padding部分(这样才能保证递归运算时padding部分始终不参与,并且保证跟正向RNN的结果对齐),这个tensorflow提供了现成的函数tf.reverse_sequence()
。
遗憾的是,Keras自带的Bidirectional
并没有这个功能,所以我重写了它,供读者参考:
class OurBidirectional(OurLayer):
"""自己封装双向RNN,允许传入mask,保证对齐
"""
def __init__(self, layer, **args):
super(OurBidirectional, self).__init__(**args)
self.forward_layer = layer.__class__.from_config(layer.get_config())
self.backward_layer = layer.__class__.from_config(layer.get_config())
self.forward_layer.name = 'forward_' + self.forward_layer.name
self.backward_layer.name = 'backward_' + self.backward_layer.name
def reverse_sequence(self, x, mask):
"""这里的mask.shape是[batch_size, seq_len, 1]
"""
seq_len = K.round(K.sum(mask, 1)[:, 0])
seq_len = K.cast(seq_len, 'int32')
return tf.reverse_sequence(x, seq_len, seq_dim=1)
def call(self, inputs):
x, mask = inputs
x_forward = self.reuse(self.forward_layer, x)
x_backward = self.reverse_sequence(x, mask)
x_backward = self.reuse(self.backward_layer, x_backward)
x_backward = self.reverse_sequence(x_backward, mask)
x = K.concatenate([x_forward, x_backward], -1)
if K.ndim(x) == 3:
return x * mask
else:
return x
def compute_output_shape(self, input_shape):
return input_shape[0][:-1] + (self.forward_layer.units * 2,)
使用方法跟自带的Bidirectional
基本一样的,只不过要多传入mask矩阵,比如:
x = OurBidirectional(LSTM(128))([x, x_mask])
小结 #
Keras是一个极其友好、极其灵活的高层深度学习API封装,千万不要听信网上流传的“Keras对新手很友好,但是欠缺灵活性”的谣言~Keras对新手很友好,对老手更友好,对需要频繁自定义模块的用户更更友好。
转载到请包括本文地址:https://spaces.ac.cn/archives/6810
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Jul. 16, 2019). 《“让Keras更酷一些!”:层中层与mask 》[Blog post]. Retrieved from https://spaces.ac.cn/archives/6810
@online{kexuefm-6810,
title={“让Keras更酷一些!”:层中层与mask},
author={苏剑林},
year={2019},
month={Jul},
url={\url{https://spaces.ac.cn/archives/6810}},
}
July 16th, 2019
苏神,处理softmax的padding公式是不是有点问题呀,应该是sotfmax(x)=softmax(...)吗?
嗯嗯,谢谢,已经修正~
July 20th, 2019
苏神你好,想请假下计算loss的时候需要考虑mask的影响吗?比如在做序列标注的任务中,padding的部分相应的也会有一个标签(x和y要长度一致),那我在计算的时候是不是应该只考虑实际长度的序列?如果不考虑mask的话,对模型参数的更新有影响吗?谢谢
当然要考虑mask。总的loss是每个样本、每一个时间步(对于序列标注类任务)的loss的平均值,既然是平均值,就要用本文所述的处理平均值的方法来处理mask。
谢谢!另外苏神怎么找回密码啊。。。我昨天刚注册的号(就是上面提问的),登录时不知道为啥密码一直不对,再注册就提示我用户名和邮箱已经被使用,我都崩溃了。。
目前自动发邮件功能有些问题。你私聊我帐号,我给你手动改吧。
July 25th, 2019
苏神 假如一个网络很多层,多层RNN和CNN组合。 需要在每一层前都mask吗
我理解的是不如果mask的话,padding部分会由于一些加法运算反向传播时影响实际结果吧(不知道对不对)
顺便还有就是苏神回复完之后好像我这边没有邮箱通知..
补充一下问题...
有人这么说 (有bias存在,即使bias为0, 经过反向传播更新,bias也会不等于0,下一轮训练的 时候,padding的0,就会对后续的输入训练产生影响了,为了不让padding对后续产生影响,也是需要mask的)
所以想问一下那假如有三层RNN,每一层RNN之前都需要mask吗。。
是的,每一层的输入输出都要mask。
July 26th, 2019
现在才知道Layer里套直接套其他Layer是训练不了的,之前还觉得自定义Layer的写法和pytorch十分相像!
被坑了一整天,看到苏神的OurLayers里把持有的Layer的权重显式的添加到当前Layer的隐藏变量里,才恍然大悟,终于把自定义的嵌套层调试对了。
感觉Keras要做到高级定制完全可以,但是要自己去看底层实现,奈何Layer类的代码太多,做实验逼得紧,根本没时间详细读完啊。
PyTorch还没用过,如果PyTorch的官方API就支持这么嵌套写的话,那还是比Keras友好很多啊。
July 31st, 2019
x_in = Input((256,), name='word')
x = Embedding(len(vocab) + 1, w, weights=[embedding_matrix], trainable=True, name='emb')(x_in)
x = SpatialDropout1D(0.2)(x)
mask = Lambda(lambda x: K.cast(K.greater(K.expand_dims(x, 2), 0), 'float32'))(x_in)
mask_crf = Lambda(lambda x: K.cast(K.greater(x, 0), 'float32'))(x_in)
x = OurBidirectional(LSTM(128, return_sequences=True))([x, mask])
x = SpatialDropout1D(0.3)(x)
x = OurBidirectional(LSTM(128, return_sequences=True))([x, mask])
x = SpatialDropout1D(0.3)(x)
x = Conv1D(len(chunk_tags), kernel_size=1, padding='same')(x)
crf = CRF(len(chunk_tags), sparse_target=True, name='crf')
output = crf(x, mask=mask_crf)
model = Model(inputs=x_in, outputs=[output]) 苏神,我利用你写的带mask的bidirectional层+crf进行命名实体识别的训练,发现收敛速度变的很慢。是不是crf层的mask有问题?
你用谁写的crf...
crf的mask方式更特殊。
我用的是keras 自带的crf
from keras_contrib.layers import CRF
那就不清楚了,我只对自己写的crf负责~
如果把crf换成苏神写的crf,上面的代码就没有问题吗?
我的crf有我自己设计的mask机制,能够做到变长序列的mask,但是官方的crf我不知道,没兴趣看它的源码。
好的哈
August 3rd, 2019
AttributeError: Can't set the attribute "name", likely because it conflicts with an existing read-only @property of the object. Please choose a different name.
这个封装一直报这个错!怎么回事? 大神。我是新手。
已经解决了,谢谢,。用keras 自带的就不行,用tensorflow下的keras就可以了。
August 13th, 2019
苏神,想请教个问题,在bert的各个transformer间为啥不加mask 呀,因为尝试过不同的max length ,每次的padding的0都是进去计算的,那么qkv计算的时候都有影响的,为啥大家都不在各个transformer间加mask,而是在最后输出用一个maskmaxpooling 输出?
keras-bert自己mask了。
keras-bert 我看默认并没有做mask, tensorflow 原版是需要输入input_mask的
keras-bert是用keras自带的mask方式,通过定义compute_mask方法来实现mask的,跟bert4keras的原理不大一样。
August 20th, 2019
OurDense 第四行 hidden_dim 写成了hidden_dimdim ?
对的,已修正,谢谢指出。
September 10th, 2019
苏神,如果我的输入序列是三维的numpy数组,其中有的全是0值,想mask掉这些全0的数组,在你的代码里应该怎么修改可以实现效果呢,谢谢~
October 29th, 2019
9.18之后Keras 2.3.0 更新了API,可以自动跟踪层中层的权重了,tf2.0中的keras也一样,release notes见下:
https://github.com/keras-team/keras/releases/tag/2.3.0
原本在2.2.4版本上折腾自定义类,一直有问题。最后发现了苏神的OursLayer,效仿之后手动在类里面跟踪了下trainable_weights和non_trainable_weights, 然后就work了。
最后突发奇想更新了下keras版本,发现keras2.3.0官方把这个事刚刚给干了,顿时觉得自己一顿操作0-5。
为了找bug茶饭不思,先遇到苏神博客,最后发现keras官方已经填了坑,(说不定Motivated by 了苏神的博客)。
一番折腾感慨不已,特此留言给过路的踩坑人。。。。
确实如此,keras 2.3.0开始已经包含了这个“层中层”功能了,所以OurLayer已经过时了,我的bert4keras最新版干脆去掉了keras 2.3.0之前的版本的支持了。不过就算“过时”,过去的探索也不会没有意义,至少能让我们更熟悉keras的代码结构了。
哈哈,是啊。不过提高生产效率是一个没有边界的事情,继续期待苏神不时分享的“奇技赢巧”,共同进步!
除了keras,最近也看了苏神不少解读类文章,忍不住想要夸一夸:苏神写的论文解读类文章的行文思路真的非常棒,描述精准、节奏稳健,读起来非常自然舒服。非常期待再次看到苏神写的文章~