Keras 使用技巧

一个极其友好、极其灵活的高层深度学习 API 封装

Posted by 苏剑林 on September 10, 2019

本文汇总了苏剑林的《让Keras更酷一些》系列的部分文章,部分内容有删改。

Keras 伴我走来

回想起进入机器学习领域的这两三年来,Keras 是一直陪伴在笔者的身边。要不是当初刚掉进这个坑时碰到了 Keras 这个这么易用的框架,能快速实现我的想法,我也不确定我是否能有毅力坚持下来,毕竟当初是 theano、pylearn、caffe、torch 等的天下,哪怕在今天它们对我来说仍然像天书一般。

后来为了拓展视野,我也去学习了一段时间的 tensorflow,用纯 tensorflow 写过若干程序,但不管怎样,仍然无法割舍 Keras。随着对 Keras 的了解的深入,尤其是花了一点时间研究过 Keras 的源码后,我发现 Keras 并没有大家诟病的那样“欠缺灵活性”。事实上,Keras 那精巧的封装,可以让我们轻松实现很多复杂的功能。我越来越感觉,Keras 像是一件非常精美的艺术品,充分体现了 Keras 的开发者们深厚的创作功力。

层的自定义

这里介绍 Keras 中自定义层及其一些运用技巧,在这之中我们可以看到 Keras 层的精巧之处。

基本定义方法

在 Keras 中,自定义层的最简单方法是通过 Lambda 层的方式:

from keras.layers import *
from keras import backend as K

x_in = Input(shape=(10,))
x = Lambda(lambda x: x+2)(x_in) # 对输入加上2

有时候,我们希望区分训练阶段和测试阶段,比如训练阶段给输入加入一些噪声,而测试阶段则去掉噪声,这需要用 K.in_train_phase 实现,比如

def add_noise_in_train(x):
    x_ = x + K.random_normal(shape=K.shape(x)) # 加上标准高斯噪声
    return K.in_train_phase(x_, x)

x_in = Input(shape=(10,))
x = Lambda(add_noise_in_train)(x_in) # 训练阶段加入高斯噪声,测试阶段去掉

当然,Lambda 层仅仅适用于不需要增加训练参数的情形,如果想要实现的功能需要往模型新增参数,那么就必须要用到自定义 Layer 了。其实这也不复杂,相比于 Lambda 层只不过代码多了几行,官方文章已经写得很清楚了:https://keras-cn.readthedocs.io/en/latest/layers/writting_layer/

这里把它页面上的例子搬过来:

class MyLayer(Layer):

    def __init__(self, output_dim, **kwargs):
        self.output_dim = output_dim # 可以自定义一些属性,方便调用
        super(MyLayer, self).__init__(**kwargs) # 必须

    def build(self, input_shape):
        # 添加可训练参数
        self.kernel = self.add_weight(name='kernel', 
                                      shape=(input_shape[1], self.output_dim),
                                      initializer='uniform',
                                      trainable=True)

    def call(self, x):
        # 定义功能,相当于Lambda层的功能函数
        return K.dot(x, self.kernel)

    def compute_output_shape(self, input_shape):
        # 计算输出形状,如果输入和输出形状一致,那么可以省略,否则最好加上
        return (input_shape[0], self.output_dim)

双输出的层

平时我们碰到的所有层,几乎都是单输出的,包括 Keras 中自带的所有层,都是一个或者多个输入,然后返回一个结果输出的。那么 Keras 可不可以定义双输出的层呢?答案是可以,但要明确定义好 output_shape,比如下面这个层,简单地将输入切开分两半,并且同时返回。

class SplitVector(Layer):

    def __init__(self, **kwargs):
        super(SplitVector, self).__init__(**kwargs)

    def call(self, inputs):
        # 按第二个维度对tensor进行切片,返回一个list
        in_dim = K.int_shape(inputs)[-1]
        return [inputs[:, :in_dim//2], inputs[:, in_dim//2:]]

    def compute_output_shape(self, input_shape):
        # output_shape也要是对应的list
        in_dim = input_shape[-1]
        return [(None, in_dim//2), (None, in_dim-in_dim//2)]

x1, x2 = SplitVector()(x_in) # 使用方法

花式回调器

除了修改模型,我们还可能在训练过程中做很多事情,比如每个 epoch 结束后,算一下验证集的指标,保存最优模型,还有可能在多少个 epoch 后就降低学习率,或者修改正则项参数,等等,这些都可以通过回调器来实现。

回调器官方页:https://keras.io/callbacks/

保存最优模型

在 Keras 中,根据验证集的指标来保留最优模型,最简便的方法是通过自带的 ModelCheckpoint,比如

checkpoint = ModelCheckpoint(filepath='./best_model.weights',
                             monitor='val_acc',
                             verbose=1,
                             save_best_only=True)

model.fit(x_train,
          y_train,
          epochs=10,
          validation_data=(x_test, y_test),
          callbacks=[checkpoint])

然而,这种方法虽然简单,但是有一个明显的缺点,就是里边的指标是由 compile 的 metrics 来确定的,而 Keres 中自定义一个 metric,需要写成张量运算才行(后面会具体介绍),也就是说如果你期望的指标并不能写成张量运算(比如 bleu 等指标),那么就没法写成一个 metric 函数了,也就不能用这个方案了。

于是,一个万能的方案就出来了:自己写回调器,爱算什么就算什么。比如:

from keras.callbacks import Callback

def evaluate(): # 评测函数
    pred = model.predict(x_test)
    return np.mean(pred.argmax(axis=1) == y_test) # 爱算啥就算啥


# 定义Callback器,计算验证集的acc,并保存最优模型
class Evaluate(Callback):

    def __init__(self):
        self.accs = []
        self.highest = 0.

    def on_epoch_end(self, epoch, logs=None):
        acc = evaluate()
        self.accs.append(acc)
        if acc >= self.highest: # 保存最优模型权重
            self.highest = acc
            model.save_weights('best_model.weights')

        # 爱运行什么就运行什么
        print 'acc: %s, highest: %s' % (acc, self.highest)


evaluator = Evaluate()
model.fit(x_train,
          y_train,
          epochs=10,
          callbacks=[evaluator])

修改超参数

训练过程中还有可能对超参数进行微调,比如最常见的一个需求是根据 epoch 来调整学习率,这可以简单地通过 LearningRateScheduler 来实现,它也属于回调器之一。

from keras.callbacks import LearningRateScheduler

def lr_schedule(epoch):
    # 根据epoch返回不同的学习率
    if epoch < 50:
        lr = 1e-2
    elif epoch < 80:
        lr = 1e-3
    else:
        lr = 1e-4
    return lr


lr_scheduler = LearningRateScheduler(lr_schedule)

model.fit(x_train,
          y_train,
          epochs=10,
          callbacks=[evaluator, lr_scheduler])

如果是其他超参数呢?比如前面 center loss 的 lamb,或者是类似的正则项。这种情况下,我们需要将 lamb 设为一个 Variable,然后自定义一个回调器来动态赋值。比如当初我定义的一个 loss:

def mycrossentropy(y_true, y_pred, e=0.1):
    loss1 = K.categorical_crossentropy(y_true, y_pred)
    loss2 = K.categorical_crossentropy(K.ones_like(y_pred)/nb_classes, y_pred)
    return (1-e)*loss1 + e*loss2

如果要动态改变参数 e,那么可以改为

e = K.variable(0.1)

def mycrossentropy(y_true, y_pred):
    loss1 = K.categorical_crossentropy(y_true, y_pred)
    loss2 = K.categorical_crossentropy(K.ones_like(y_pred)/nb_classes, y_pred)
    return (1-e)*loss1 + e*loss2


model.compile(loss=mycrossentropy,
              optimizer='adam')


class callback4e(Callback):
    def __init__(self, e):
        self.e = e
    def on_epoch_end(self, epoch, logs={}):
        if epoch > 100: # 100个epoch之后设为0.01 
            K.set_value(self.e, 0.01)

model.fit(x_train,
          y_train,
          epochs=10,
          callbacks=[callback4e(e)])

注意 Callback 类共支持六种在不同阶段的执行函数:on_epoch_beginon_epoch_endon_batch_beginon_batch_endon_train_beginon_train_end,每个函数所执行的阶段不一样(根据名字很容易判断),可以组合起来实现很复杂的功能。比如 warmup,就是指设定了默认学习率后,并不是一开始就用这个学习率训练,而是在前几个 epoch 中,从零慢慢增加到默认的学习率,这个过程可以理解为在为模型调整更好的初始化。参考代码:

class Evaluate(Callback):
    def __init__(self):
        self.num_passed_batchs = 0
        self.warmup_epochs = 10
    def on_batch_begin(self, batch, logs=None):
        # params是模型自动传递给Callback的一些参数
        if self.params['steps'] == None:
            self.steps_per_epoch = np.ceil(1. * self.params['samples'] / self.params['batch_size'])
        else:
            self.steps_per_epoch = self.params['steps']
        if self.num_passed_batchs < self.steps_per_epoch * self.warmup_epochs:
            # 前10个epoch中,学习率线性地从零增加到0.001
            K.set_value(self.model.optimizer.lr,
                        0.001 * (self.num_passed_batchs + 1) / self.steps_per_epoch / self.warmup_epochs)
            self.num_passed_batchs += 1

可以不要输出

一般我们用 Keras 定义一个模型,是这样子的:

x_in = Input(shape=(784,))
x = x_in
x = Dense(100, activation='relu')(x)
x = Dense(10, activation='softmax')(x)

model = Model(x_in, x)
model.compile(loss='categorical_crossentropy ',
              optimizer='adam',
              metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5)

这种模型就是普通的输入输出结构,然后 loss 是输出的运算。然而,对于比较复杂的模型,如自编码器、GAN、Seq2Seq,这种写法有时候不够方便,loss 并不总只是输出的运算。幸好,比较新的 Keras 版本已经支持更加灵活的 loss 定义,比如我们可以这样写一个自编码器:

x_in = Input(shape=(784,))
x = x_in
x = Dense(100, activation='relu')(x)
x = Dense(784, activation='sigmoid')(x)

model = Model(x_in, x)
loss = K.mean((x - x_in)**2)
model.add_loss(loss)
model.compile(optimizer='adam')
model.fit(x_train, None, epochs=5)

上述写法的几个特点是:

  • compile 的时候并没有传入 loss,而是在 compile 之前通过另外的方式定义 loss,然后通过 add_loss 加入到模型中,这样可以随意写足够灵活的 loss,比如这个 loss 可以跟中间层的某个输出有关、跟输入有关,等等。
  • fit 的时候,原来的目标数据,现在是 None,因为这种方式已经把所有的输入输出都通过 Input 传递进来了。读者还可以看之前写的《Seq2Seq 模型入门》,在那个例子中,读者能更充分地感觉到这种写法的便捷性。

更随意的 metric

另一种输出是训练过程中用来观察的 metric。这里的 metric,就是指衡量模型性能的一些指标,比如正确率、F1等,Keras 内置了一些常见的 metric。像开头例子的 accuracy 一样,将这些 metric 的名字加入到 model.compile 中,就可以在训练过程中动态地显示这些 metric。

当然,你也可以参考 Keras 中内置的 metric 来自己定义新 metric,但问题是在标准的 metric 定义方法中,metric 是“输出层”与“目标值”之间的运算结果,而我们经常要在训练过程中观察一些特殊的量的变化过程,比如我想观察中间某一层的输出变化情况,这时候标准的 metric 定义就无法奏效了。

那可以怎么办呢?我们可以去看 Keras 的源码,去追溯它的 metric 相关的方法,最终我发现 metric 实际上定义在两个 list 之中,通过修改这两个 list,我们可以非常灵活地显示需要观察的 metric,比如

x_in = Input(shape=(784,))
x = x_in
x = Dense(100, activation='relu')(x)
x_h = x
x = Dense(10, activation='softmax')(x)

model = Model(x_in, x)
model.compile(loss='categorical_crossentropy ',
              optimizer='adam',
              metrics=['accuracy'])

# 重点来了
model.metrics_names.append('x_h_norm')
model.metrics_tensors.append(K.mean(K.sum(x_h**2, 1)))

model.fit(x_train, y_train, epochs=5)

上述代码展示了在训练过程中观察中间层的平均模长的变化情况。可以看到,主要涉及到两个 list:model.metrics_names 是 metric 的名称,是字符串列表;model.metrics_tensors 是 metric 的张量。只要在这里把你需要展示的量添加进去,就可以在训练过程中显示了。当然,要注意的是,一次性只能添加一个标量。

输出中间变量

在自定义层时,我们可能希望查看中间变量,这些需求有些是比较容易实现的,比如查看中间某个层的输出,只需要将截止到这个层的部分模型保存为一个新模型即可,但有些需求是比较困难的,比如在使用 Attention 层时我们可能希望查看那个 Attention 矩阵的值,如果用构建新模型的方法则会非常麻烦。下面我们给出一种简单的方法,彻底满足这个需求。

下面以基本模型

x_in = Input(shape=(784,))
x = x_in

x = Dense(512, activation='relu')(x)
x = Dropout(0.2)(x)
x = Dense(256, activation='relu')(x)
x = Dropout(0.2)(x)
x = Dense(num_classes, activation='softmax')(x)

model = Model(x_in, x)

为例,逐步深入地介绍如何获取 Keras 的中间变量。

作为一个新模型

假如模型训练完成后,我想要获取 x = Dense(256, activation='relu')(x) 对应的输出,那可以在定义模型的时候,先把对应的变量存起来,然后重新定义一个模型:

x_in = Input(shape=(784,))
x = x_in

x = Dense(512, activation='relu')(x)
x = Dropout(0.2)(x)
x = Dense(256, activation='relu')(x)
y = x
x = Dropout(0.2)(x)
x = Dense(num_classes, activation='softmax')(x)

model = Model(x_in, x)
model2 = Model(x_in, y)

model 训练完成后,直接用 model2.predict 就可以查看对应的256维的输出了。这样做的前提是 y 必须是某个层的输出,不能是随意一个张量。

K.function!

有时候我们自定义了一个比较复杂的层,比较典型的就是 Attention 层,我们希望查看层的一些中间变量,比如对应的 Attention 矩阵,这时候就比较麻烦了,如果想要用前面的方式,那么就要把原来的 Attention 层分开为两个层定义才行,因为前面已经说了,新定义一个 Keras 模型时输入输出都必须是 Keras 层的输入输出,不能是随意一个张量。这样一来,如果想要分别查看层的多个中间变量,那就要将层不断地拆开为多个层来定义,显然是不够友好的。

其实 Keras 提供了一个终极的解决方案:K.function

介绍 K.function 之前,我们先写一个简单示例:

class Normal(Layer):
    def __init__(self, **kwargs):
        super(Normal, self).__init__(**kwargs)
    def build(self, input_shape):
        self.kernel = self.add_weight(name='kernel', 
                                      shape=(1,),
                                      initializer='zeros',
                                      trainable=True)
        self.built = True
    def call(self, x):
        self.x_normalized = K.l2_normalize(x, -1)
        return self.x_normalized * self.kernel


x_in = Input(shape=(784,))
x = x_in

x = Dense(512, activation='relu')(x)
x = Dropout(0.2)(x)
x = Dense(256, activation='relu')(x)
x = Dropout(0.2)(x)
normal = Normal()
x = normal(x)
x = Dense(num_classes, activation='softmax')(x)

model = Model(x_in, x)

在上面的例子中,Normal 定义了一个层,层的输出是 self.x_normalized * self.kernel,不过我想在训练完成后获取 self.x_normalized 的值,而它是跟输入有关,并且不是一个层的输出。这样一来前面的方法就没法用了,但用 K.function 就只是一行代码:

fn = K.function([x_in], [normal.x_normalized])

K.function 的用法跟定义一个新模型类似,要把所有跟 normal.x_normalized 相关的输入张量都传进去,但是不要求输出是一个层的输出,允许是任意张量!返回的 fn 是一个具有函数功能的对象,所以只需要

fn([x_test])

就可以获取到 x_test 对应的 x_normalized 了!比定义一个新模型简单通用多了~

事实上 K.function 就是 Keras 底层的基础函数之一,它直接封装好了后端的输入输出操作,换句话说,你用 Tensorflow 为后端时,fn([x_test]) 就相当于

sess.run(normal.x_normalized, feed_dict={x_in: x_test})

了,所以 K.function 的输出允许是任意张量,因为它本来就在直接操作后端了~

层中层

“层中层”,顾名思义,是在 Keras 中自定义层的时候,重用已有的层,这将大大减少自定义层的代码量。前面已经介绍过 Keras 自定义层的基本方法,其核心步骤是定义 buildcall 两个函数,其中 build 负责创建可训练的权重,而 call 则定义具体的运算。

拒绝重复劳动

经常用到自定义层的读者可能会感觉到,在自定义层的时候我们经常在重复劳动,比如我们想要增加一个线性变换,那就要在 build 中增加一个 kernelbias 变量(还要自定义变量的初始化、正则化等),然后在 call 里边用 K.dot 来执行,有时候还需要考虑维度对齐的问题,步骤比较繁琐。但事实上,一个线性变换其实就是一个不加激活函数的 Dense 层罢了,如果在自定义层时能重用已有的层,那显然就可以大大节省代码量了。

事实上,只要你对 Python 面向对象编程比较熟悉,然后仔细研究 Keras 的 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 方法,就是通过它我们可以重用已有的层。

下面是一个简单的例子,定义一个层,运算如下:

这里 $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_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 问题。

排除 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$ 之后,我们可以重写求均值的运算:

这里的 $\otimes$ 是逐位对应相乘的意思。这样一来,分子只对非 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 部分,比如

正常来说,神经网络的输入输出的数量级不会很大,所以经过 $x - (1 - m) \times 10^{10}$ 后,padding 部分在 $-10^{10}$ 这个数量级中上,可以保证取 $\max$ 的话不会取到 padding 部分了。

处理 softmax 的 padding 也是如此。在 Attention 或者指针网络时,我们就有可能遇到对变长的向量做 softmax,如果直接对 padding 后的向量做 softmax,那么 padding 部分也会平摊一部分概率,导致实际有意义的部分概率之和都不等于 1 了。解决办法跟 $\max$ 时一样,让 padding 部分足够小足够小,使得 $e^x$ 足够接近于 $0$,以至于可以忽略:

上面几个算子的 mask 处理算是比较特殊的,其余运算的 mask 处理(除了双向 RNN),基本上只需要输出

就行了,也就是让 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 = 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 无限可能

通常我们认为 Keras 这样的高度封装的库,灵活性是比较欠缺的,但事实上不然。要知道,Keras 并不是简单地调用 tensorflow 或者 theano 中现成的上层函数,而仅仅是通过 backend 来封装了一些基本的函数,然后把所有的东西(各种层、优化器等)用自己的 backend 重写了一遍!也正是如此,它才能支持切换不同的后段。

能做到这个程度,Keras 的灵活性是不容置喙的,但是这种灵活性在帮助文档和普通的案例中比较难体现,很多时候要阅读源码,才能感觉到 Keras 那样的写法已经无可挑剔了。我感觉,用 Keras 实现复杂的模型,既是一种挑战,又像是一种艺术创作,当你成功时,你就会陶醉于你创造出来的艺术品了。

本文汇总文章如下,更新中…

《“让Keras更酷一些!”:精巧的层与花式的回调》
《“让Keras更酷一些!”:随意的输出和灵活的归一化》
《“让Keras更酷一些!”:中间变量、权重滑动和安全生成器》
《“让Keras更酷一些!”:层中层与mask》