“让Keras更酷一些!”:层中层与mask
这一篇“让 Keras 更酷一些!”将和读者分享两部分内容:第一部分是“层中层”,顾名思义,是在 Keras 中自定义层的时候,重用已有的层,这将大大减少自定义层的代码量;另外一部分就是应读者所求,介绍一下序列模型中的 mask 原理和方法。
作者丨苏剑林
单位丨追一科技
研究方向丨NLP,神经网络
个人主页丨kexue.fm
层中层
在《“让Keras更酷一些!”:精巧的层与花式的回调》[1] 一文中我们已经介绍过 Keras 自定义层的基本方法,其核心步骤是定义 build 和 call 两个函数,其中 build 负责创建可训练的权重,而 call 则定义具体的运算。
拒绝重复劳动
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)
return outputs
下面是一个简单的例子,定义一个层,运算如下:
这里 f,g 是激活函数,其实就是两个 Dense 层的复合,如果按照标准的写法,我们需要在 build 那里定义好几个权重,定义权重的时候还需要根据输入来定义 shape,还要定义初始化等,步骤很多,但事实上这些在 Dense 层不都写好了吗,直接调用就可以了,参考调用代码如下:
class OurDense(OurLayer):
"""原来是继承Layer类,现在继承OurLayer类
"""
def __init__(self, hidden_dimdim, 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_dimdim,
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 的向量:
经过 padding 变成长度为 8:
当你将这个长度为 8 的向量输入到模型中时,模型并不知道你这个向量究竟是“长度为 8 的向量”还是“长度为 5 的向量,填充了 3 个无意义的 0”。为了表示出哪些是有意义的,哪些是 padding 的,我们还需要一个 mask 向量(矩阵):
这是一个 0/1 向量(矩阵),用 1 表示有意义的部分,用 0 表示无意义的 padding 部分。
所谓 mask,就是 x 和 m 的运算,来排除 padding 带来的效应。比如我们要求 x 的均值,本来期望的结果是:
但是由于向量已经经过 padding,直接算的话就得到:
会带来偏差。更严重的是,对于同一个输入,每次 padding 的零的数目可能是不固定的,因此同一个样本每次可能得到不同的均值,这是很不合理的。有了 mask 向量 m 之后,我们可以重写求均值的运算:
这里的 ⊗ 是逐位对应相乘的意思。这样一来,分子只对非 padding 部分求和,分母则是对非 padding 部分计数,不管你 padding 多少个零,最终算出来的结果都是一样的。
如果要求 x 的最大值呢?我们有 max([1,0,3,4,5])=max([1,0,3,4,5,0,0,0])=5,似乎不用排除 padding 效应了?在这个例子中是这样,但还有可能是:
经过 padding 后变成了:
如果直接对 padding 后的 x 求 max,那么得到的是 0,而 0 不在原来的范围内。这时候解决的方法是:让 padding 部分足够小,以至于 max(几乎)不能取到 padding 部分,比如:
正常来说,神经网络的输入输出的数量级不会很大,所以经过
处理 softmax 的 padding 也是如此。在 Attention 或者指针网络时,我们就有可能遇到对变长的向量做 softmax,如果直接对 padding 后的向量做 softmax,那么 padding 部分也会平摊一部分概率,导致实际有意义的部分概率之和都不等于 1 了。解决办法跟 max 时一样,让 padding 部分足够小足够小,使得
上面几个算子的 mask 处理算是比较特殊的,其余运算的 mask 处理(除了双向 RNN),基本上只需要输出:
就行了,也就是让 padding 部分保持为 0。
Keras实现要点
Keras 自带了 mask 功能,但是不建议用,因为自带的 mask 不够清晰灵活,而且也不支持所有的层,强烈建议读者自己实现 mask。
近来开源的好几个模型都已经给出了足够多的 mask 案例,我相信读者只要认真去阅读源码,一定很容易理解 mask 的实现方式的,这里简单提一下几个要点。
一般来说 NLP 模型的输入是词 ID 矩阵,形状为 [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 矩阵大小是 [batch_size, seq_len, 1],然后词 ID 矩阵经过 Embedding 层后的大小为 [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 = copy.deepcopy(layer)
self.backward_layer = copy.deepcopy(layer)
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 K.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], 2)
return x * mask
def compute_output_shape(self, input_shape):
return (None, input_shape[0][1], self.forward_layer.units * 2)
使用方法跟自带的 Bidirectional 基本一样的,只不过要多传入 mask 矩阵,比如:
x = OurBidirectional(LSTM(128))([x, x_mask])
小结
Keras 是一个极其友好、极其灵活的高层深度学习 API 封装,千万不要听信网上流传的“Keras 对新手很友好,但是欠缺灵活性”的谣言。Keras 对新手很友好,对老手更友好,对需要频繁自定义模块的用户更更友好。
相关链接
点击以下标题查看作者其他文章:
让你的论文被更多人看到
如何才能让更多的优质内容以更短路径到达读者群体,缩短读者寻找优质内容的成本呢?答案就是:你不认识的人。
总有一些你不认识的人,知道你想知道的东西。PaperWeekly 或许可以成为一座桥梁,促使不同背景、不同方向的学者和学术灵感相互碰撞,迸发出更多的可能性。
PaperWeekly 鼓励高校实验室或个人,在我们的平台上分享各类优质内容,可以是最新论文解读,也可以是学习心得或技术干货。我们的目的只有一个,让知识真正流动起来。
📝 来稿标准:
• 稿件确系个人原创作品,来稿需注明作者个人信息(姓名+学校/工作单位+学历/职位+研究方向)
• 如果文章并非首发,请在投稿时提醒并附上所有已发布链接
• PaperWeekly 默认每篇文章都是首发,均会添加“原创”标志
📬 投稿邮箱:
• 投稿邮箱:hr@paperweekly.site
• 所有文章配图,请单独在附件中发送
• 请留下即时联系方式(微信或手机),以便我们在编辑发布时和作者沟通
🔍
现在,在「知乎」也能找到我们了
进入知乎首页搜索「PaperWeekly」
点击「关注」订阅我们的专栏吧
关于PaperWeekly
PaperWeekly 是一个推荐、解读、讨论、报道人工智能前沿论文成果的学术平台。如果你研究或从事 AI 领域,欢迎在公众号后台点击「交流群」,小助手将把你带入 PaperWeekly 的交流群里。
▽ 点击 | 阅读原文 | 查看作者博客