9.3 机器翻译-Seq2Seq

前言

上一节通过影评数据分类任务介绍了seq2cls的场景,本节介绍seq2seq的任务——机器翻译。

机器翻译是典型的seq2seq任务,本节将采用传统基于RNN的seq2seq结构模型进行,seq2seq任务有一些特点,并且需要重点理解,包括

  1. 特殊token:, , , 。分别表示起始、结束、未知和填充。尤其是“起始”和"结束",它们是序列生成任务中重要的概念。
  2. 序列最大长度:序列最大长度直接影响模型性能,过长导致模型难以学习,过短导致无法完成任务,本案例选择长度为20。
  3. Teacher forcing 教师强制学习:本概念也是序列生成任务中,在训练阶段所涉及的重要概念,表示decoder的输入采用标签,而非自回归式。
  4. 推理代码逻辑:模型推理输出序列时,采用的是自回归方式,因而代码与训练时要做修改。

任务介绍

机器翻译是经典的seq2seq任务,日常生活中也常用到翻译工具,因此任务比较好理解。例如以下几个句子就是本节要处理的任务。

  1. That mountain is easy to climb. 那座山很容易爬。
  2. It's too soon. 太早了。
  3. Japan is smaller than Canada. 日本比加拿大小。

现在需要构建模型,接收英文句子序列,进行综合理解,然后输出中文句子序列,在神经网络中,通常采用encoder-decoder架构,例如《动手学》中的示意图。

seq2seq-illustration

  • 编码器,对输入进行处理,获得对输入句子的综合理解信息——状态。
  • 解码器,根据状态,以及解码器的输入,逐个token的生成输出,直到输出特殊token——,模型停止输出。
  • 解码器的输入,采用自回归方式(推理时),含义是当前时刻的输入,来自上一时刻的输出,特别地,第0个时刻,是没有上一个时刻,所以采用特殊token——作为输入。

数据模块

数据下载

本案例数据集Tatoeba下载自https://www.manythings.org/anki/,该项目是帮助不同语言的人学习英语,因此是英语与其它几十种语言的翻译文本。

其中就包括本案例使用的英中文本,共计29668条(Mandarin Chinese - English cmn-eng.zip (29668))

数据以txt形式存储,一行是一对翻译文本,例如长这样:

1.That mountain is easy to climb. 那座山很容易爬。

2.It's too soon. 太早了。

3.Japan is smaller than Canada. 日本比加拿大小。

数据集划分

对于29668条数据进行8:2划分为训练、验证,这里采用配套代码a_data_split.py进行划分,即可在统计目录下获得train.txt和text.txt。

词表构建

文本任务首要任务是为文本构建词表,这里采用与上节一样的方法,首先对文本进行分词,然后统计语料库中所有的词,最后根据最大上限、最小词频等约束,构建词表。本部分配套代码是b_gen_vocabulary.py

词表的构建过程中,涉及两个知识点:中文分词和特殊token。

1. 中文分词

对于英文,分词可以直接采用空格。而对于中文,就需要用特定的分词方法,这里采用的是jieba分词工具,以下是英文和中文的分词代码。

source.append(parts[0].split(' '))
target.append(list(jieba.cut(parts[1])))  # 分词

2. 特殊token

由于seq2seq任务的特殊性,在解码器部分,通常需要一个token告诉模型,现在是开始,同时还需要有个token让模型输出,以此告诉人类,模型输出完毕,不要再继续生成了。

因此相较于文本分类,还多了,, 两个特殊token,有的时候,开始token也会用表示。

PAD_TAG = "<pad>"  # 用PAD补全句子长度
BOS_TAG = "<bos>"  # 用BOS表示开始
EOS_TAG = "<eos>"  # 用EOS表示结束
UNK_TAG = "<unk>"  # 用EOS表示结束
PAD = 0  # PAD字符对应的数字
BOS = 1  # BOS字符对应的数字
EOS = 2  # EOS字符对应的数字
UNK = 3  # UNK字符对应的数字

运行代码后,词表字典保存到了result目录下,并得到如下输出,表明英文中有2518个词,中文有3365,但经过最大长度3000的截断后,只剩下2996,另外4个是特殊token。

100%|██████████| 23635/23635 [00:00<00:00, 732978.24it/s]
原始词表长度:2518,截断后长度:2518
2522
保存词频统计图:vocab_en.npy_word_freq.jpg
100%|██████████| 23635/23635 [00:00<00:00, 587040.62it/s]
保存统计图:vocab_en.npy_length_freq.jpg
原始词表长度:3365,截断后长度:2996
3000

Dataset编写

NMTDataset的编写逻辑与上一小节的Dataset类似,首先在类初始化的时候加载原始数据,并进行分词;在getitem迭代时,再进行token转index操作,这里会涉及增加结束符、填充符、未知符。

核心代码如下:

def __init__(self, path_txt, vocab_path_en, vocab_path_fra, max_len=32):
    self.path_txt = path_txt
    self.vocab_path_en = vocab_path_en
    self.vocab_path_fra = vocab_path_fra
    self.max_len = max_len

    self.word2index = WordToIndex()
    self._init_vocab()
    self._get_file_info()

def __getitem__(self, item):

    # 获取切分好的句子list,一个元素是一个词
    sentence_src, sentence_trg = self.source_list[item], self.target_list[item]
    # 进行填充, 增加结束符,索引转换
    token_idx_src = self.word2index.encode(sentence_src, self.vocab_en, self.max_len)
    token_idx_trg = self.word2index.encode(sentence_trg, self.vocab_fra, self.max_len)
    str_len, trg_len = len(sentence_src) + 1, len(sentence_trg) + 1  # 有效长度, +1是填充的结束符 <eos>.

    return np.array(token_idx_src, dtype=np.int64), str_len,  np.array(token_idx_trg, dtype=np.int64), trg_len

def _get_file_info(self):

    text_raw = read_data_nmt(self.path_txt)
    text_clean = text_preprocess(text_raw)
    self.source_list, self.target_list = text_split(text_clean)

模型模块

seq2seq模型,由编码器和解码器两部分构成。

对于编码器,需要的是其对输入句子的全文理解,因此可采用RNN中输出的hidden state特征来表示,在这里均采用LSTM作为基础模型。

对于解码器,同样是一个LSTM,它接收3个数据,一个是输入,另外两个是hidden state和cell state。解码器的输入就是自回归式的,上一时刻输出的单词传到当前时刻。

这里借助《动手学》的示意图,理解seq2seq模型的样子。

seq2seq-mdoel

首先构建一个EncoderLSTM,这个比较简单,只看它的forward,对于输入的句子x,输出hidden_state, cell_state。

def forward(self, x):
    # Shape -----------> (26, 32, 300) [Sequence_length , batch_size , embedding dims]
    embedding = self.dropout(self.embedding(x))

    # Shape --> outputs (26, 32, 1024) [Sequence_length , batch_size , hidden_size]
    # Shape --> (hs, cs) (2, 32, 1024) , (2, 32, 1024) [num_layers, batch_size, hidden_size]
    outputs, (hidden_state, cell_state) = self.LSTM(embedding)

    return hidden_state, cell_state

然后构建一个DecoderLSTM,解码器除了需要对数据进行提特征,获得hidden_state, cell_state,还需要进行当前时刻,单词的输出,即token级的分类任务。

所以,它的forward返回有三个信息,包括输出的token预测向量,LSTM的hidden_state, cell_state,这里需要注意,在代码实现时,解码器输出的隐状态默认包括了来自编码器的,因此后续时间步不再需要从编码器拿隐状态特征了。更直观的就是,解码器返回的hidden_state, cell_state,会是下一次forward输入的hidden_state, cell_state。

def forward(self, x, hidden_state, cell_state):
    x = x.unsqueeze(0)  # x.shape == [1, batch_size]
    embedding = self.dropout(self.embedding(x))

    outputs, (hidden_state, cell_state) = self.LSTM(embedding, (hidden_state, cell_state))

    predictions = self.fc(outputs)

    predictions = predictions.squeeze(0)

    return predictions, hidden_state, cell_state

最后,编写一个Seq2Seq类,将编码器和解码器有序的组合起来,在这里,核心任务是解码器中,如何有序的进行每个时刻的数据处理。

该Seq2Seq类用于训练阶段,会设计teacher forcing(强制学习)的概念,它表示在训练阶段,解码器的输入时自回归,还是根据标签进行输入,采用标签进行输入的方法称为teacher forcing。

这里仔细研究一下forward的后半部分——解码器部分,前半部分就是编码器进行一次性的编码,获得隐状态特征。

对于解码器,采用for循环,依次进行token输出,最终存储在outputs中,for循环需要设置最大步数,一般根据任务的数据长度而定,这里设置为32。

接着,解码器工作,解码器会输出:预测的token分类向量,隐状态信息,其中隐状态信息会在下一个for循环时,输入到解码器中。

再往下看,解码器的输入x,则是根据一个条件判断,以一定的概率采用标签,一定的概率采用自回归方式。这里概率通常设置为0.5,如果设置为1,表明采用强制学习,永远采用标签作为输入,也就是强制学习机制(teacher forcing)。

# Shape of x (32 elements)
x = target[0]  # <bos> token

for i in range(1, target_len):
    # output.shape ==  (bs, vocab_length)
    # hidden_state.shape == [num_layers, batch_size size, hidden_size]
    output, hidden_state, cell_state = self.Decoder_LSTM(x, hidden_state, cell_state)
    outputs[i] = output
    best_guess = output.argmax(1)  # 0th dimension is batch size, 1st dimension is word embedding
    x = target[i] if random.random() < tfr else best_guess  # Either pass the next word correctly from the dataset or use the earlier predicted word

# Shape --> outputs (14, 32, 5766)
return outputs

模型训练

数据和模型准备好之后,可以运行train_seq2seq.py进行训练,seq2seq的训练还有些许不同,主要包括:

  1. 采用blue进行指标评价;
  2. 损失函数加入ignore_index

BLEU是IBM在2002提出的,用于机器翻译任务的评价,发表在ACL,引用次数10000+,原文题目是“BLEU: a Method for Automatic Evaluation of Machine Translation”。

它的总体思想就是准确率,假如给定标准译文reference,模型生成的句子是candidate,句子长度为n,candidate中有m个单词出现在reference,m/n就是bleu的1-gram的计算公式。

当统计不再是一个单词,而是连续的N个单词时,就有了n-gram的概念,词组的概念称为n-gram,词组长度通常选择1, 2, 3, 4

举一个例子来看看实际的计算:

candinate: the cat sat on the mat

reference: the cat is on the mat

BLEU-1: 5/6 = 0.83

BLEU-2: 3/5 = 0.6

BLEU-3: 1/4 = 0.25

BLEU-4: 0/3 = 0

分子表示candidate中预测到了的词组的次数,如BLEU-1中,5分别表示, the, cat, on, the, mat预测中了。BLEU-2中,3分别表示, the cat, on the, the mat预测中了。以此类推。

针对BLEU还有些改进计算方法,可参考BLEU详解

由于句子长度不一致,而训练又需要将数据构造成统一长度的句子来组batch,因此会加入很多特殊token——,对应的index是0,所以会在计算损失函数的时候,这部分的loss是不计算的,可以巧妙的通过设置ignore_index来实现。 nn.CrossEntropyLoss(ignore_index=0)

seq2seq-exp-r

由于数据量少,以及模型参数未精心设计,模型存在过拟合,这里仅作为seq2seq任务的学习和理解,不对模型性能做进一步提升,因为后续有更强大的解决方案——基于Transformer。

模型推理

运行配套代码c_inference.py,可以看到模型的表现如下所示,整体上像一个模型翻译的样子,但效果远不如意,这里包含多方面原因。

  1. 数据量过少,训练数据仅2万多条,而中英文的词表就3000多

  2. 模型过于简单,传统RNN构建的seq2seq在上下文理解能力上表现欠佳,后续可加入注意力机制,或是基于Transformer架构来提升。

    输入: he didn't answer the phone , so i sent him an email . <eos>
    标签:他 没有 <unk> , 所以 我 给 他 发 了 <unk> <unk> 。 <eos>
    翻译:我 不 <unk> , 所以 我 他 他 他 <unk> 。 <eos>
    
    输入: just as he was going out , there was a great earthquake . <eos>
    标签:就 在 他 要 出門 的 時候 , 發生 了 <unk> 。 <eos>
    翻译:他 他 的 <unk> <unk> , 但 他 <unk> <unk> 。 <eos>
    
    输入: tom hugged mary . <eos>
    标签:汤姆 拥抱 了 玛丽 。 <eos>
    翻译:<unk> 了 玛丽 。 <eos>
    
    输入: there are many americans who can speak japanese . <eos>
    标签:有 很多 美国 <unk> 说 日语 。 <eos>
    翻译:有 <unk> <unk> 能 非常 <unk> 。 <eos>
    
    输入: i'm not good at <unk> . <eos>
    标签:我 不 擅長 <unk> 。 <eos>
    翻译:我 不 太 擅长 运动 。 <eos>
    

    小结

    本节通过机器翻译了解seq2seq任务,主要涉及一些特殊的、新的知识点,包括:

    1. 特殊token:解码器需要两个特殊的token,起始token和结束token,用于控制生成序列的起始和结束。

    2. 强制学习:训练时,解码器的输入采用标签

    3. 编码器-解码器的自回归推理逻辑:需要for循环的形式,串行的依次生成句子中的每个单词

    4. 中文分词工具jieba

Copyright © TingsongYu 2021 all right reserved,powered by Gitbook文件修订时间: 2024年04月26日21:48:10

results matching ""

    No results matching ""