NLP教程

TF_IDF
词向量
句向量
Seq2Seq 语言生成模型
CNN的语言模型
语言模型的注意力
Transformer 将注意力发挥到极致
ELMo 一词多义
GPT 单向语言模型
BERT 双向语言模型
NLP模型的多种应用


一想到用深度学习解决语言问题,我们自然而然的就能想到使用循环神经网络RNN这一系列的模型。 而像CNN这种专注于图像处理的模型在语言领域也能胜任吗?答案是可以的。

而这次,我们就尝试使用一种CNN模型,把文字描述转化成向量表达。用一句话来概括这个CNN语言模型,我想可以这样说: 用N个不同长度时间窗口,以CNN的卷积方法在句子中依次滑动,让模型拥有N种阅读的眼界宽度,综合N种宽度的信息总结出这句话的内容

怎么卷积

上次我们提到了Encoder Decoder的概念, 这次的CNN语言模型重视的是怎么样使用CNN当做文字内容提取的Encoder。

CNN最擅长的事就是卷积,但是相比图像中的卷积,在句子中的卷积起到的作用是特殊的,学者想利用CNN去利用不同长度的卷积核去观察句子中不同长度的局部特征。 然后CNN对句子的理解就是不同长度的局部特征拼凑起来的理解。

比如:

  • 卷积核A两个两个字一起看;
  • 卷积核B三个三个字一起看;
  • 卷积核C四个四个字一起看;

卷积核ABC利用自己看句子的独特视角,能够提炼出对句子不同的理解,然后如果再汇集这些不同理解,就有了一个对句子更加全面的理解。

翻译

在这节内容中,还是以翻译为例。有了上次Seq2Seq 的经验,我们知道在翻译的模型中,实际上是要构建一个Encoder,一个Decoder。 这节CNN做文字翻译的内容中,我们更关注的是用CNN的方法来做Encoder,让计算机读懂句子,至于Decoder,我们还是使用Seq2Seq当中的RNN Decoder来实现。

秀代码

我使用一个非常简单,好训练的日期转换的例子来展示一下CNN的语言理解能力。需要实现的功能如下:

# 中文的 "年-月-日" -> "day/month/year"
"98-02-26" -> "26/Feb/1998"

我们将中文的顺序日期,转成英文的逆序日期,数据区间是20世纪后期到21世纪前期。 为了施加一些难度,在中文模式下,我不会告诉机器这是哪一个世纪,需要计算机自己去判断转成英文的时候是 20 世纪还是 21 世纪。

先来看训练过程, 其实也很简单,生成数据,建立模型,训练模型。

def train():
    # 我已经帮大家封装了日期生成器代码
    data = utils.DateData(4000)
    
    # 建立模型
    model = CNNTranslation(...)

    # training
    for t in range(1500):
        bx, by, decoder_len = data.sample(32)
        loss = model.step(bx, by, decoder_len)

最后你能看到它的整个训练过程。最开始预测成渣渣,但是后面预测结果会好很多。不过最后这个CNN的模型可能是应为参数量还不够大的关系, 预测并不是特别准确,不过将就能用~

t:  0 | loss: 3.293 | input:  96-06-17 | target:  17/Jun/1996 | inference:  /////1///99
t:  70 | loss: 1.110 | input:  91-08-19 | target:  19/Aug/1991 | inference:  03/Feb/2013<EOS>
t:  140 | loss: 0.972 | input:  11-04-30 | target:  30/Apr/2011 | inference:  10/Sep/2001<EOS>
t:  210 | loss: 0.828 | input:  76-03-14 | target:  14/Mar/1976 | inference:  16/May/1977<EOS>
...
t:  1400 | loss: 0.183 | input:  86-10-14 | target:  14/Oct/1986 | inference:  14/Oct/1986<EOS>
t:  1470 | loss: 0.151 | input:  18-02-08 | target:  08/Feb/2018 | inference:  05/Feb/2018<EOS>

这节内容最重要的代码内容就在下方,我们动手搭建一下它的Encoder部分。为本节的例子,我们使用3个Conv2D的卷积层,这三个对不同长度的局部信息做卷积, 所以他们的结构都不一样,然后再用MaxPool2D去将他们归一化到同一dimension。这样就可以将最后的所有局部信息汇总,加工成句向量了。

import tensorflow as tf
from tensorflow import keras
import numpy as np
import tensorflow_addons as tfa

class CNNTranslation(keras.Model):
    def __init__(self, ...):
        super().__init__()

        # encoder
        self.enc_embeddings = keras.layers.Embedding(
            input_dim=enc_v_dim, output_dim=emb_dim,  # [enc_n_vocab, emb_dim]
            embeddings_initializer=tf.initializers.RandomNormal(0., 0.1),
        ) 
        self.conv2ds = [
            keras.layers.Conv2D(16, (n, emb_dim), padding="valid", activation=keras.activations.relu)
            for n in range(2, 5)]
        self.max_pools = [keras.layers.MaxPool2D((n, 1)) for n in [7, 6, 5]]
        self.encoder = keras.layers.Dense(units, activation=keras.activations.relu)
        ...

    def encode(self, x):
        embedded = self.enc_embeddings(x)       # [n, step, emb]
        o = tf.expand_dims(embedded, axis=3)    # [n, step=8, emb=16, 1]
        co = [conv2d(o) for conv2d in self.conv2ds]    # [n, 7, 1, 16], [n, 6, 1, 16], [n, 5, 1, 16]
        co = [self.max_pools[i](co[i]) for i in range(len(co))]    # [n, 1, 1, 16] * 3
        co = [tf.squeeze(c, axis=[1, 2]) for c in co]    # [n, 16] * 3
        o = tf.concat(co, axis=1)      # [n, 16*3]
        h = self.encoder(o)            # [n, units]
        return [h, h]

接下来的Decoder部分就和Seq2Seq中一模一样了,decoder在训练时和句子生成时是不同的。为了方便训练,尤其是在刚开始训练时,decoder的输入如果是True label,那么就能大大减轻训练难度。 不管在训练时有没有预测错,下一步在decoder的输入都是正确的。

class CNNTranslation(keras.Model):
    def __init__(self, ...):
        ...
        # decoder
        self.dec_embeddings = keras.layers.Embedding() # [dec_n_vocab, emb_dim]
        self.decoder_cell = keras.layers.LSTMCell(units=units)
        decoder_dense = keras.layers.Dense(dec_v_dim)

        # 训练时的 decoder
        self.decoder_train = tfa.seq2seq.BasicDecoder(
            cell=self.decoder_cell,
            sampler=tfa.seq2seq.sampler.TrainingSampler(),   # sampler for train
            output_layer=decoder_dense
        )
        self.cross_entropy = keras.losses.SparseCategoricalCrossentropy(from_logits=True)
        self.opt = keras.optimizers.Adam(0.01)

    def train_logits(self, x, y, seq_len):
        s = self.encode(x)
        dec_in = y[:, :-1]   # ignore <EOS>
        dec_emb_in = self.dec_embeddings(dec_in)
        o, _, _ = self.decoder_train(dec_emb_in, s, sequence_length=seq_len)
        logits = o.rnn_output
        return logits

    def step(self, x, y, seq_len):
        with tf.GradientTape() as tape:
            logits = self.train_logits(x, y, seq_len)
            dec_out = y[:, 1:]  # ignore <GO>
            loss = self.cross_entropy(dec_out, logits)
            grads = tape.gradient(loss, self.trainable_variables)
        self.opt.apply_gradients(zip(grads, self.trainable_variables))
        return loss.numpy()

而在生产环境中预测时,真的在做翻译时,我们就希望有另一种decode的sample方式。使decoder下一步的预测基于decoder上一步的预测,而不是true label。

class CNNTranslation(keras.Model):
    def __init__(self):
        ...
        # predict decoder
        self.decoder_eval = tfa.seq2seq.BasicDecoder(
            cell=self.decoder_cell,
            sampler=tfa.seq2seq.sampler.GreedyEmbeddingSampler(),       # sampler for predict
            output_layer=decoder_dense
        )
        ...

    def inference(self, x):
        s = self.encode(x)
        done, i, s = self.decoder_eval.initialize(
            self.dec_embeddings.variables[0],
            start_tokens=tf.fill([x.shape[0], ], self.start_token),
            end_token=self.end_token,
            initial_state=s,
        )
        pred_id = np.zeros((x.shape[0], self.max_pred_len), dtype=np.int32)
        for l in range(self.max_pred_len):
            o, s, i, done = self.decoder_eval.step(
                time=l, inputs=i, state=s, training=False)
            pred_id[:, l] = o.sample_id
        return pred_id
局限性

不知道你有没有思考过,CNN做句向量encoding的时候有一个局限性,它要求有个句子最长的限制,句子如果超过这个长度,那么就最好截断它。 因为就像在给图像做卷积,图像也是要定长定宽的,不然卷积和池化会有尺度上的问题。这是一个相比RNN的硬伤。之后我们在介绍Transformer类型的语言模型时, 也会介绍到这个硬伤。

全部代码

utils.py与上一节的代码相同

import tensorflow as tf
from tensorflow import keras
import numpy as np
import utils   
import tensorflow_addons as tfa


class CNNTranslation(keras.Model):
    def __init__(self, enc_v_dim, dec_v_dim, emb_dim, units, max_pred_len, start_token, end_token):
        super().__init__()
        self.units = units

        # encoder
        self.enc_embeddings = keras.layers.Embedding(
            input_dim=enc_v_dim, output_dim=emb_dim,  # [enc_n_vocab, emb_dim]
            embeddings_initializer=tf.initializers.RandomNormal(0., 0.1),
        )
        self.conv2ds = [
            keras.layers.Conv2D(16, (n, emb_dim), padding="valid", activation=keras.activations.relu)
            for n in range(2, 5)]
        self.max_pools = [keras.layers.MaxPool2D((n, 1)) for n in [7, 6, 5]]
        self.encoder = keras.layers.Dense(units, activation=keras.activations.relu)

        # decoder
        self.dec_embeddings = keras.layers.Embedding(
            input_dim=dec_v_dim, output_dim=emb_dim,  # [dec_n_vocab, emb_dim]
            embeddings_initializer=tf.initializers.RandomNormal(0., 0.1),
        )
        self.decoder_cell = keras.layers.LSTMCell(units=units)
        decoder_dense = keras.layers.Dense(dec_v_dim)
        # train decoder
        self.decoder_train = tfa.seq2seq.BasicDecoder(
            cell=self.decoder_cell,
            sampler=tfa.seq2seq.sampler.TrainingSampler(),   # sampler for train
            output_layer=decoder_dense
        )
        # predict decoder
        self.decoder_eval = tfa.seq2seq.BasicDecoder(
            cell=self.decoder_cell,
            sampler=tfa.seq2seq.sampler.GreedyEmbeddingSampler(),       # sampler for predict
            output_layer=decoder_dense
        )

        self.cross_entropy = keras.losses.SparseCategoricalCrossentropy(from_logits=True)
        self.opt = keras.optimizers.Adam(0.01)
        self.max_pred_len = max_pred_len
        self.start_token = start_token
        self.end_token = end_token

    def encode(self, x):
        embedded = self.enc_embeddings(x)               # [n, step, emb]
        o = tf.expand_dims(embedded, axis=3)            # [n, step=8, emb=16, 1]
        co = [conv2d(o) for conv2d in self.conv2ds]     # [n, 7, 1, 16], [n, 6, 1, 16], [n, 5, 1, 16]
        co = [self.max_pools[i](co[i]) for i in range(len(co))]     # [n, 1, 1, 16] * 3
        co = [tf.squeeze(c, axis=[1, 2]) for c in co]               # [n, 16] * 3
        o = tf.concat(co, axis=1)           # [n, 16*3]
        h = self.encoder(o)                 # [n, units]
        return [h, h]

    def inference(self, x):
        s = self.encode(x)
        done, i, s = self.decoder_eval.initialize(
            self.dec_embeddings.variables[0],
            start_tokens=tf.fill([x.shape[0], ], self.start_token),
            end_token=self.end_token,
            initial_state=s,
        )
        pred_id = np.zeros((x.shape[0], self.max_pred_len), dtype=np.int32)
        for l in range(self.max_pred_len):
            o, s, i, done = self.decoder_eval.step(
                time=l, inputs=i, state=s, training=False)
            pred_id[:, l] = o.sample_id
        return pred_id

    def train_logits(self, x, y, seq_len):
        s = self.encode(x)
        dec_in = y[:, :-1]   # ignore <EOS>
        dec_emb_in = self.dec_embeddings(dec_in)
        o, _, _ = self.decoder_train(dec_emb_in, s, sequence_length=seq_len)
        logits = o.rnn_output
        return logits

    def step(self, x, y, seq_len):
        with tf.GradientTape() as tape:
            logits = self.train_logits(x, y, seq_len)
            dec_out = y[:, 1:]  # ignore <GO>
            loss = self.cross_entropy(dec_out, logits)
            grads = tape.gradient(loss, self.trainable_variables)
        self.opt.apply_gradients(zip(grads, self.trainable_variables))
        return loss.numpy()


def train():
    # get and process data
    data = utils.DateData(4000)
    print("Chinese time order: yy/mm/dd ", data.date_cn[:3], "\nEnglish time order: dd/M/yyyy ", data.date_en[:3])
    print("vocabularies: ", data.vocab)
    print("x index sample: \n{}\n{}".format(data.idx2str(data.x[0]), data.x[0]),
          "\ny index sample: \n{}\n{}".format(data.idx2str(data.y[0]), data.y[0]))

    model = CNNTranslation(
        data.num_word, data.num_word, emb_dim=16, units=32,
        max_pred_len=11, start_token=data.start_token, end_token=data.end_token)

    # training
    for t in range(1500):
        bx, by, decoder_len = data.sample(32)
        loss = model.step(bx, by, decoder_len)
        if t % 70 == 0:
            target = data.idx2str(by[0, 1:-1])
            pred = model.inference(bx[0:1])
            res = data.idx2str(pred[0])
            src = data.idx2str(bx[0])
            print(
                "t: ", t,
                "| loss: %.3f" % loss,
                "| input: ", src,
                "| target: ", target,
                "| inference: ", res,
            )


if __name__ == "__main__":
    train()