第九章:自然语言处理
* 9.1
语言模型的背景知识
* 9.1.1
语言模型简介
* 9.1.2
语言模型的评价方法
* 9.2
神经语言模型
* 9.2.1
PTB数据集的预处理
* 9.2.2
PTB数据的batching方法
* 9.2.3
基于循环神经网络的神经语言模型
* 9.3
神经网络机器翻译
* 9.3.1
机器翻译背景与Seq2Seq模型介绍
* 9.3.2
机器翻译文本数据的预处理
* 9.3.3
Seq2Seq模型的代码实现
* 9.3.4
注意力机制
上一章讲解了循环神经网络的基本知识,这一章将继续利用循环神经网络来搭建自然语言处理方面的一些经典应用,如语言模型
、机器翻译
等。
*
在自然语言处理研究的早期,人们试图整理出关于自然语言的语法,并根据这些语法
去理解和生成句子。然而,现实中使用的自然语言总是过于复杂,人们为了沟通方便,使用句子时常常不拘泥于固定的语法,每个单词的含义在不同语境下也有多种变化。
*
从20世纪80年代起,随着硬件计算能力的增强和大型语料库的出现,使用统计
方法对语言进行概率建模的方式开始变成主流。
*
从2010年起,基于循环神经网络
的方法在许多自然语言处理的问题上超越了传统的统计模型,在学术界和工业界都得到广泛应用。
这一章将具体介绍如何使用深度学习方法解决自然语言处理问题。
-9.1- 语言模型的背景知识
本节将介绍
语言模型
的基本概念。
语言模型是自然语言处理问题中一类最基本的问题,它有着非常广泛的应用,也是理解后文中更加复杂的自然语言处理问题的基础。
-9.1.1- 语言模型简介
本小节将首先介绍
语言模型
的基本概念。
假设一门语言中所有可能的句子服从某一个概率分布,每个句子出现的概率加起来为1,那么“语言模型”的任务就是预测每个句子在语言中出现的概率
。
对于语言中常见的句子,一个好的语言模型应得出相对较高的概率;而对于不合语法的句子,计算出的概率则应接近于零。把句子看成单词的序列,语言模型可以表示为一个计算的模型。语言模型仅仅对句子出现的概率进行建模,并不尝试去“理解”句子的内容含义。比如说,语言模型能告诉我们什么样的句子是常用句子,但无法告诉我们两句话的意思是否相似或者相反。
语言模型有很多应用。
*
很多生成自然语言文本
的应用都依赖语言模型来优化输出文本的流畅性。生成的句子在语言模型中的概率越高,说明其越有可能是一个流畅、自然的句子。例如在输入法中,假设输入的拼音串为“xianzaiquna”,输出可能是“西安在去哪”也可能是“现在去哪”,这时输入法就利用语言模型比较两个输出的概率,得出“现在去哪”更有可能是用户所需要的输出。
*
在统计机器翻译
的噪声信道模型(Noisy Channel Model)
中,每个候选翻译的概率由一个翻译模型
和一个语言模型
共同决定,其中的语言模型就起到了在目标语言中挑选较为合理的句子的作用。在9.3小节中将看到,神经网络机器翻译的Seq2Seq模型
可以看作是一个条件语言模型(Conditional Language Model)
,它相当于是在给定输入的情况下对目标语言的所有句子估算概率,并选择其中概率最大的句子作为输出。
那么如何计算一个句子的概率呢?首先一个句子可以被看成是一个单词序列:
其中为句子的长度。那么,它的概率可以表示为:
表示,已知前个单词时,第个单词为的条件概率
。如果能对这一项建模,那么只要把每个位置的条件概率相乘,就能计算一个句子出现的概率
。然而一般来说,任何一门语言的词汇量都很大,词汇的组合更是不计其数。
假设一门语言的词汇量为,如果要将的所有参数保存在一个模型里,将需要个参数,一般的句子长度远远超出了实际可行的范围。为了估计这些参数的取值,常见的方法有:
*
n-gram模型
、
*
决策树
、
*
最大熵模型
、
*
条件随机场
、
*
神经网络语言模型
等。
这里先以其中最简单的n-gram模型
来介绍语言模型问题。为了控制参数数量,n-gram模型做了一个有限历史假设:当前单词的出现概率仅仅与前面的个单词相关,因此以上公式可以近似
为:
n-gram
模型里的指的是当前单词依赖它前面的单词的个数。通常可以取1、2、3、4,其中取1、2、3时分别称为unigram
、bigram
和trigram
。n-gram模型中需要估计的参数为条件概率
。假设某种语言的单词表大小为,那么n-gram模型需要估计的不同参数数量为量级。当越大时,n-gram模型在理论上越准确,但也越复杂,需要的计算量和训练语料数据量也就越大,因此取的情况非常少。
n-gram模型的参数一般采用最大似然估计(Maximum Likelihood Estimation, MLE)
方法计算:
其中表示单词序列X在训练语料中出现的次数。训练语料的规模越大,参数估计的结果越可靠。但即使训练数据的规模非常大时,还是有很多单词序列在训练语料中不会出现,这就会导致很多参数为。举例来说,IBM使用了366M英语语料训练trigram,发现14.7%的trigram和2.2%的bigram在训练中没有出现。为了避免因为乘以0而导致整个句概率为0,使用最大似然估计方法时需要加入平滑
避免参数取值为0。使用n-gram建立语言模型的细节不再详细介绍,感兴趣的读者推荐阅读Michael Collins的讲义。
-9.1.2- 语言模型的评价方法
本小节将介绍
评价
语言模型好坏的标准
,并给出了使用TensorFlow实现该损失函数
的具体代码。
语言模型效果好坏的常用评价指标是复杂度(perplexity)
。
在一个测试集上得到的perplexity越低,说明建模的效果越好。计算perplexity值的公式如下:
简单来说,perplexity值
刻画的是语言模型预测一个语言样本的能力。比如已经知道这句话会出现在语料库之中,那么通过语言模型计算得到的这句话的概率越高,说明语言模型对这个语料库拟合得越好。
从上面的定义中可以看出,perplexity实际是计算每一个单词得到的概率倒数的几何平均,因此perplexity可以理解为平均分支系数(average branching factor)
,即模型预测下一个词时的平均可选择数量。例如,考虑一个由这个数字随机组成的长度为的序列,由于这个数字出现的概率是随机的,所以每个数字出现的概率是。因此,在任意时刻,模型都有个等概率的候选答案可以选择,于是perplexity就是(有10个合理的答案)。
perplexity的计算过程如下:
目前在PTB(Penn Tree Bank)数据集
上最好的语言模型perplexity,也就是说,平均情况下,该模型预测下一个词时,有47.7个词等可能地可以作为下一个词的合理选择。
在语言模型中,通常采用perplexity的对数表达形式:
相比乘积求平方根的方式,使用加法的形式可以加速计算,同时避免概率乘积数值过小而导致浮点数向下溢出的问题。
在数学上,可以看成真实分布与预测分布之间的交叉熵(Cross Entropy)
。交叉熵描述了两个概率分布之间的一种距离。假设是一个离散变量,和是两个与相关的概率分布,那么和之间交叉熵的定义是在分布下的期望值:
把看作单词,为每个位置上单词的真实分布,为模型的预测分布,就可以看出和交叉熵是等价的。唯一的区别在于,由于语言的真实分布是未知的,因此在的定义中,真实分布用测试语料中的取样代替,即认为在给定上文的条件下,语料中出现单词的概率为,出现其他单词的概率均为。
在神经网络模型中,分布通常是由一个softmax层产生的,这时TensorFlow中提供了两个方便计算交叉熵的函数:tf.nn.softmax_cross_entropy_with_logits()
和tf.nn.sparse_softmax_cross_entropy_with_logits()
。两个函数之间的区别可以看下面的例子:
# 假设词汇表的大小为3,语料包含两个单词"2 0"
word_labels = tf.constant([2, 0])
# 假设模型对两个单词预测时,产生的logit分别是[2.0, -1.0, 3.0]和[1.0, 0.0, -0.5]
# 注意这里的logit不是概率,因此它们不是0.0~1.0范围之间的数字。
# 如果需要计算概率,则需要调用pro=tf.nnn.softmax(logits)。
# 但是这里计算交叉熵的函数直接输入logits即可。
predict_logits = tf.constant([[2.0, -1.0, 3.0], [1.0, 0.0, -0.5]])
# 使用sparse_softmax_cross_entropy_with_logits计算交叉熵。
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=word_labels,
logits=predict_logits
)
# 运行程序,计算loss的结果是[0.32656264, 0.46436879],这对应两个预测的perplexity损失。
sess = tf.Session()
sess.run(loss)
# softmax_cross_enntropy_with_logits与上面的函数相似,但需要将预测目标以概率分布的形式给出。
word_prob_distribution = tf.constant([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]])
loss = tf.nn.softmax_cross_entropy_with_logits(
labels=word_prob_distribution,
logits=predict_logits
)
# 运行结果与上面相同:[0.3265264, 0.46436879]
sess.run(loss)
# 由于softmax_cross_entropy_with_logits允许提供一个概率分布,因此在使用时有更大的自由度。
# 举个例子,一种叫做label smoothing的技巧是将正确数据的概率设为一个比1.0略小的值,将错误数据的概率设为比0.0略大的值。
# 这样可以避免模型与数据过拟合,在某些时候可以提高训练效果。
word_prob_smooth = tf.connstant([[0.01, 0.01, 0.98], [0.98, 0.01, 0.01]])
loss = tf.nn.softmax_cross_entropy_with_logits(
labels=word_prob_smooth,
logits=predict_logits
)
# 运行结果为[0.37656265, 0.48936883]
sess.run(loss)
-9.2- 神经语言模型
本节将
*
介绍基于循环神经网络的语言模型,以及语言模型中单词向量 (embedding)
的概念。
*
以PTB 数据集
为例介绍对文本数据进行预处理
的步骤。
*
给出在PTB数据上使用循环神经网络
建立语言模型
的具体样例。
上一节中曾提到,n-gram模型
为了控制参数数量,需要将上下文信息控制在几个单词以内。也就是说,在预测下个单词时,n-gram模型只能考虑前n个单词的信息(通常),这就对语言模型的能力造成了很大的限制。与之相比,循环神经网络
可以将任意长度的上文信息存储在隐藏状态中,因此使用循环神经网络作为语言模型有着天然的优势。
考虑如图9-1所示的循环神经网络,每个时刻的输入为一个句子中的单词,而每个时刻的输出为一个概率分布,表示句子中下一个位置为不同单词的概率。比如在图9-1中,第一个时刻输入的单词为“大海”,而输出为,即知道第一个词为“大海”后,其他不同单词出现在下一个位置的概率。若,则“大海”之后的单词为“的”出现的概率为0.8。类似地,通过循环神经网络可以求得概率、、。每个位置上的概率取对数再平均起来,就可以得到在这个句子上计算的。
-9.2.1- PTB数据集的预处理
和之前章节中介绍的图像数据不同,自然语言文本数据无法直接被当成数值提供给神经网络,所以本小节将介绍如何对本文数据进行
预处理
,从而使得它能作为神经网络的输入。
PTB (Penn Treebank Dataset)文本数据集
是目前语言模型学习中使用最为广泛的数据集。本小节将以PTB 为例,介绍对自然语言数据进行预处理的方法。
虽然这一小节的内容与深度学习和TensorFlow并不直接相关,但正确理解数据的预处理对于理解后面的内容和工程实践都是非常必要的。
首先,需要下载来源于Tomas Mikolov网站上的PTB数据。数据的下载地址为:
http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz
将下载下来的文件解压之后可以得到如下文件夹列表:
1-train/
2-nbest-rescore/
3-combination/
4-data-generation/
5-one-iter/
6-recovery-during-training/
7-dynamic-evaluation/
8-direct/
9-char-based-lm/
data/
models/
rnnlm-0.2b/
本书中只需要关心data/
文件夹下的三个文件:
ptb.test.txt #测试集数据文件
ptb.train.txt #训练集数据文件
ptb.valid.txt #验证集数据文件
这三个数据文件中的数据己经过预处理,相邻单词之间用空格隔开。数据集中共包含了9998个不同的单词词汇,加上稀有词语的特殊符号<unk>
和语句结束标记符(在文本中就是换行符)
在内,一共是10000个词汇。在使用perplexity
比较不同的语言模型时,文本的预处理和词汇表必须保持一致。举个例子,如果一个语言模型将“don’t”视为一个单词,而另一个语言模型在预处理时将其拆为“don”和“'t”两个单词,那么这两个语言模型得到的perplexity值就是不可比较的。近年来关于语言模型方面的论文大多采用了Mikolov提供的这一预处理后的数据版本,由此保证了论文之间具有可比性。
下面展示了训练数据中的一行:
mr. <unk> is chairman of <unk> n.v. the dutch publishing group
为了将文本转化为模型可以读入的单词序列,需要将这 10000 个不同的词汇分别映射 到 0~9999 之间的整数编号。下面的辅助程序首先按照词频顺序为每个词汇分配一个编号,然后将词汇表保存到一个独立的vocab文件
中。
import codecs
import collections
from operator import itemgetter
RAW_DATA = "/path/to/data/ptb.train.txt" # 训练集数据文件
VOCAB_OUTPUT = "ptb.vocab" # 输出的词汇表文件
counter = collections.Counter() # 统计单词出现频率
with codecs.open(RAW_DATA, "r", "utf-8") as f:
for line in f:
for word in line.strip().split():
counter[word] += 1
# 按词频顺序对单词进行排序。
sorted_word_to_cnt = sorted(
counter.items(),
key=itemgetter(1),
reverse=True
)
sorted_words = [x[0] for x in sorted_word_to_cnt]
# 稍后我们需要在文本换行处加入句子结束符“<eos>”,这里先将其加入词汇表。
sorted_words = ["<eos>"] + sorted_words
# 在9.3.2小节处理机器翻译数据时,除了"<eos>",还需要将"<unk>"和句子起始符"<sos>"加入词汇表,并从词汇表中删除低频词汇。
# 在PTB数据中,因为输入数据已经将低频词汇替换成了"<unk>",因此不需要这一步。
# sorted_words = ["<unk>", "<sos>", "<eos>"] + sorted_words
# if len(sorted_words) > 10000:
# sorted_words = sorted_words[:10000]
with codecs.open(VOCAB_OUTPUT, 'w', 'utf-8') as file_output:
for word in sorted_words:
file_output.write(word + "\n")
下面展示了以上程序统计输出的词汇文件的前几行。从结果可以看出,这些单词都是使用频率非常高的。
<eos>
the
<unk>
N
of
to
a
在确定了词汇表
之后,再将训练文件、测试文件等都根据词汇文件转化为单词编号
。每个单词的编号就是它在词汇文件中的行号。
import codecs
import sys
RAN_DATA = "/path/to/data/ptb.train.txt" # 原始的训练数据集文件
VOCAB = "/ptb.vocab" # 上面生成的词汇表文件
OUTPUT_DATA = "ptb.train" # 将单词替换为单词编号后的输出文件
# 读取词汇表,并建立词汇到单词编号的映射。
with codecs.open(input_vocab_file, 'r', 'utf-8') as f_vocab:
vocal = [w.strip() for w in f_vocab.readlines()]
word_to_id = {k: v for (k, v) in zip(vocab, range(len(vocab)))}
# 如果出现了被删除的低频词,则替换为“<unk>”。
def get_id(word):
return word_to_id[word] if word in word_to_id else word_to_id["<unk>"]
fin = codecs.open(RAW_DATA, "r", "utf-8")
fout = codecs.open(OUTPUT_DATA, "w", "utf-8")
for line in fin:
words = line.strip().split() +["<eos>"] # 读取单词并添加<eos>结束符
# 将每个单词替换为词汇表中的编号
out_line = ' '.join([str(get_id(w)) for w in words]) + '\n'
fout.write(out_line)
fin.close()
fout.close()
经过上面的处理,“mr. <unk> is chairman of <unk> n.v. the dutch publishing group”被替换成了如下内容:
23 2 13 142 4 1 5459 1 3106 1583 96 0
这个例子中简单地使用了文本文件来保存经过处理的数据。在实际工程中,通常使用TFRecords格式
来提高读写效率。
虽然预处理原则上可以放在TensorFlow的
Dataset框架
中与读取文本同时进行,但在工程实践上,保存处理好的数据
有几个重要的优点:
*
第一,在调试模型的过程中,可以保证不同模型采取的预处理步骤相同;
*
第二,减小文件体积,节省磁盘读取的时间:
*
第三,方便对预处理步骤本身进行debug,例如在模型训练效果不理想时,只需检查最终的数据文件就可以知道是不是预处理过程出了问题。
-9.2.2- PTB数据的batching方法
本小节将介绍如何对处理后的文本数据进行更加有效的
batching
来提升计算效率。
在文本数据中,由于每个句子的长度不同,又无法像图像一样调整到固定维度,因此在对文本数据进行batching时需要采取一些特殊操作。最常见的办法是使用填充(padding)
将同一batch内的句子长度补齐。这个方法会在9.3.2小节中做详细介绍。在PTB数据集中,每个句子并非随机抽取的文本,而是在上下文之间有关联的内容。语言模型为了利用上下文信息
,必须将前面句子的信息传递到后面的句子。为了实现这个目标,在PTB上下文有关联的数据集上,通常采用另一种batching方法。
如果模型大小没有限制,那最理想的设计是将整个文档前后连接起来,当作一个句子来训练,如图9-2所示。但现实中这是无法实现的。例如PTB数据总共约有19万词,若将整个文档放入一个计算图,循环神经网络将展开成一个19万层的前馈网络。这样会导致计算图过大,另外序列过长可能造成训练中梯度爆炸的问题。
对此问题的解决方法是,将长序列切割为固定长度的子序列。循环神经网络在处理完一个子序列后,它最终的隐藏状态将复制到下一个序列中作为初始值,这样在前向计算时,效果等同于一次性顺序地读取了整个文档;而在反向传播时,梯度则只在每个子序列内部传播。
图9-3展示了在没有batching时切分文档的方法。为了利用计算时的并行能力,我们希望每一次计算可以对多个句子进行井行处理,同时又要尽量保证batch之间的上下文连续。解决方案是,先将整个文档切分成若干连续段落,再让batch中的每一个位置负责其中一段。例如,如果batch大小是4,则先将整个文档平均分成4个子序列,让batch中的每一个位置负责其中一个子序列,这样每个子文档内部的所有数据仍可以被顺序处理。
下面的代码从文本文件中读取数据,并按上面介绍的方案将数据整理成batch。由于PTB数据集比较小,因此可以直接将整个数据集一次性读入内存。
import numpy as np
import tensorflow as tf
TRAIN_DATA = "/path/to/ptb.train" # 使用单词编号表示的训练数据
TRAIN_BATCH_SIZZE = 20
TRAIN_NUM_STEP = 35
# 从文本中读取数据,并返回包含单词编号的数组。
def read_data(file_path):
with open(file_path, "r") as fin:
# 将整个文档读进一个长字符串。
id_string = ' '.join([line.strip() for line in fin.readlines()])
id_list = [int(w) for w in id_string.split()] # 将读取的单词编号转为整数
return id_list
def make_batches(id_list, batch_size, num_step):
# 计算总的batch数量。
# 每个batch包含的单词数量是batch_size*num_step。
num_batches = (len(id_list) - 1) // (batch_size * num_step)
# 如9-4图所示,将数据整理成一个纬度为[batch_size, num_batches * num_step]的二维数组。
data = np.array(id_list[: num_batches*batch_size*num_step])
data = np.reshape(data, [batch_size, num_batches*num_step])
# 沿着第二个维度将数据切分成num_batches个batch,存入一个数组。
data_batches = np.split(label, num_batches, axis=1)
# 重复上述操作,但是每个位置向右移动一位。
# 这里得到的是RNN每一步输出所需要预测的下一个单词。
label = np.array(id_list[1: num_batches*batch_size*num_step + 1])
label = np.reshape(label, [batch_size, num_batches*num_step])
label_batches = np.split(label, num_batches, axis=1)
# 返回一个长度为num_batches的数组,其中每一项包括一个data矩阵和一个label矩阵。
return list(zip(data_batches, label_batches))
def main():
train_batches = make_batches(
read_data(TRAIN_DATA),
TRAIN_BATCH_SIZE,
TRAIN_NUM_STEP
)
# 在这里插入模型训练的代码。训练代码将在下一小节详细介绍。
...
if __name__ == "__mian__":
main()
-9.2.3- 基于循环神经网络的神经语言模型
本小节将给出完整的代码来实现通过
循环神经网络
对自然语言进行建模。
在介绍了语言模型的理论和使用到的数据集之后,下面介绍神经语言模型
的结构。在图9-1中可以看到,与第8章介绍的循环神经网络
相比,NLP应用中主要多了两个层:词向量层(embedding)
和softmax层
。下面对这两个层分别进行介绍。
*
词向量层
在输入层,每一个单词
用一个实数向量表示,这个向量被称为“词向量”(word embedding,也可翻译为“词嵌入”)
。词向量可以形象地理解为将词汇表嵌入到一个固定维度的实数空间里。
将单词编号转化为词向量主要有两大作用:
*
降低输入的维度
。如果不使用词向量层,而直接将单词以one-hot vector的形式输入循环神经网络,那么输入的维度大小将与词汇表大小相同,通常在10000以上。而词向量的维度通常在200~1000之间,这将大大减少循环神经网络的参数数量与计算量。
*
增加语义信息
。简单的单词编号是不包含任何语义信息的。两个单词之间编号相近,并不意味着它们的含义有任何关联。而词向量层将稀疏的编号
转化为稠密的向量表示
,这使得词向量有可能包含更为丰富的信息。在自然语言应用中学习得到的词向量通常会将含义相似的词赋予取值相近的词向量值,使得上层的网络可以更容易地抓住相似单词之间的共性。举例来说,因为猫和狗都需要吃东西,因此在预测下文中出现单词“吃”的概率时,上文中出现“猫”或者“狗”带来的影响可能是相似的。在这样的任务训练出来的词向量中,代表“猫”和“狗”的词向量取值很可能是相近的。
假设词向量的维度是EMB_SIZE,词汇表的大小为VOCAB_SIZE,那么所有单词的词向量可以放入一个大小为VOCAB_SIZE * EMB_SIZE的矩阵内。在读取词向量时,可以调用tf.nn.embedding_lookup()
方法。
embedding = tf.get_variable("embedding", [VOCAB_SIZE, EMB_SIZE])
# 输出的矩阵比输入的矩阵多一个维度,新增维度的大小是EMB_SIZE。
# 在语言模型中,一般input_data的维度是batch_size*num_step,而输出的input_embedding的维度是batch_size*num_step*EMB_SIZE。
input_embedding = tf.nn.embedding_lookup(embedding, input_data)
*
Softmax层
Softmax层的作用是将循环神经网络的输出转化为一个单词表中每个单词的输出概率。为此需要有两个步骤:
*
第一,使用一个线性映射
将循环神经网络的输出映射为一个维度
与词汇表大小
相同的向量。这一步的输出叫作logits
。
# 定义线性映射用到的参数。
# HIDDEN_SIZE是循环神经网络的隐藏状态维度,VOCAB_SIZE是词汇表的大小。
weight = tf.get_variable("weight", [HIDDEN_SIZE, VOCAB_SIZE])
bias = tf.get_variable("bias", [VOCAB_SIZE])
# 计算线性映射。
# output是RNN输出,其维度为[batch_size*num_steps, HIDDEN_SIZE]
logits = tf.nn.bias_add(tf.matmal(output, weigth), bias)
*
第二,调用softmax方法将logits
转化为加和为1的概率
。softmax在4.2.1小节中已经在分类问题中介绍过。事实上,语言模型的每一步输出都可以看作一个分类问题:在VOCAB_SIZE个可能的类别中决定这一步最可能输出的单词。
# probs的维度与logits的维度相同。
probs = tf.nn.softmax(logits)
模型训练通常并不关心概率的具体取值,而更关心最终的,因此可以调用tf.nn.sparse_softmax_cross_entropy_with_logits()
方法直接从logits计算作为损失函数
:
# labels是一个大小为[batch_size*num_steps]的唯一数组,它包含每个位置正确的单词编号。
# logits的维度是[batch_size*num_steps, HIDDEN_SIZE]
# loss的维度与labels相同,代表每个位置上的log(perplexity)。
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=tf.reshape(self.targets, [-1]),
logits=logits
)
通过共享参数减少参数数量
Softmax层
和词向量层
的参数数量都与词汇表大小VOCAB_SIZE成正比。由于VOCAB_SIZE的数值通常较大,而循环体中的HIDDEN_SIZE
相对较小,导致Softmax和Embdding在整个网络的参数数量中占有很大比例。举个例子,假如VOCAB_SIZE为10000,HIDDEN_SIZE和EMB_SIZE都是512,循环神经网络采用双层LSTM,那么词向量层和Softmax层的参数数量均为,而循环网络本身的参数数量仅仅为,少于词向量层和Softmax层的参数数量,仅占总参数数量的29%。
注意,在上面的例子中,词向量层和Softmax层的参数数量是相等的,它们都为每一个单词分配了一个长度为512的向量。有研究指出,如果共享``````词向量层
和Softmax层
的参数,不仅能大幅减少参数数量,还能提高最终模型效果。下面的完整代码样例中实现了这一方法。
完整的训练程序
上面已经介绍了一个神经语言模型所需要的数据预处理、词向量层以及Softmax层。将这些组件与第8章介绍过的循环神经网络结合起来,就可以搭建一个完整的神经语言模型。下面的代码给出了一个完整的训练程序,它使用一个双层LSTM作为循环神经网络的主体,并共享Softmax层和词向量层的参数。
# coding: utf-8
import numpy as np
import tensorflow as tf
TRAIN_DATA = "ptb.train" # 训练数据路径
EVAL_DATA = "ptb.valid" # 验证数据路径
TEST_DATA = "ptb.test" # 测试数据路径
HIDDEN_SIZE = 300 # 隐藏层规模
NUM_LAYERS = 2 # 深层循环神经网络中LSTM结构的层数
VOCAB_SIZE = 10000 # 词典规模
TRAIN_BATCH_SIZE = 20 # 训练数据batch的大小
TRAIN_NUM_STEP = 35 # 训练数据截断长度
EVAL_BATCH_SIZE = 1 # 测试数据batch的大小
EVAL_NUM_STEP = 1 # 测试数据截断长度
NUM_EPOCH = 5 # 使用训练数据的轮数
LSTM_KEEP_PROB = 0.9 # LSTM节点不被dropout的概率
EMBEDDING_KEEP_PROB = 0.9 # 词向量不被dropout的概率
MAX_GRAD_NORM = 5 # 用于控制梯度膨胀的梯度上限大小
SHARE_EMB_AND_SOFTMAX = True # 在Softmax层和词向量层共享参数
# 通过一个PTBModel类来描述模型,这样方便维护循环神经网络的状态。
class PTBModel(object):
def __init__(self, is_training, batch_size, num_steps):
# 记录使用的batch大小和截断长度。
self.batch_size = batch_size
self.num_steps = num_steps
# 定义每一步的输入和预期输出。
# 两者的维度都是[batch_size, num_steps]。
self.input_data = tf.placeholder(tf.int32, [batch_size, num_steps])
self.targets = tf.placeholder(tf.int32, [batch_size, num_steps])
# 定义使用LSTM结构为循环体结构,且使用dropout的深层循环神经网络。
dropout_keep_prob = LSTM_KEEP_PROB if is_training else 1.0
lstm_cells = [
tf.nnn.rnn_cell.DropoutWrapper(
tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE),
output_keep_prob=dropout_keep_prob
)
for _ in range(NUM_LAYERS)
]
cell = tf.nn.rnn_cell.MultiRNNCell(lstm_cells)
# 初始化最初的状态,即全零的向量。
# 这个量只在每个epoch初始化第一个batch时使用。
self.innitial_state = cell.zero_state(batch_size, tf.float32)
# 定义单词的词向量矩阵。
embedding = tf.get_variable(
"embedding",
[VOCAB_SIZE, HIDDEN_SIZE]
)
# 将输入单词转化为词向量。
inputs = tf.nn.embedding_lookup(embedding, self.input_data)
# 只在训练时使用dropout。
if is_training:
inputs = tf.nn.dropout(inputs, EMBEDDING_KEEP_PROB)
# 定义输出列表。
# 在这里先将不同时刻LSTM结构的输出收集起来,再一起提供给softmax层。
output = []
state = self.initial_state
with tf.variable_scope("RNN"):
for time_step in range(num_steps):
if time_step > 0:
tf.get_variable_scope().reuse_variables()
cell_output, state = cell(inputs[: time_step, :], state)
outputs.append(cell_output)
# 把输出队列展开成[batch, hidden_size*num_steps]的形状,然后再reshape成[batch*num_steps, hidden_size]的形状。
output = tf.reshape(tf.concat(outputs, 1), [-1, HIDDEN_SZIE])
# Softmax层:将RNN在每个位置上的输出转化为各个单词的logits。
if SHARE_EMB_AND_SOFTMAX:
weight = tf.transpose(embedding)
else:
weight = tf.get_variable("weight", [HIDDEN_SIZE, VOCAB_SIZE])
bias = tf.get_variable("bias", [VOCAB_SIZZE])
logits = tf.matmul(output, weigth) + bias
# 定义交叉熵损失函数和平均损失。
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=tf.reshape(self.targets, [-1]),
logits=logits
)
self.cost = tf.reduce_sum(loss) / batch_size
self.final_state = state
# 只在训练模型时定义反向传播操作。
if not is_training:
return
trainable_variables = tf.trainable_variables()
# 控制梯度大小,定义优化方法和训练步骤。
grads, _ = tf.clip_by_global_norm(
tf.gradients(self.cost, trainable_variables),
MAX_GRAD_NORM
)
optimizer = tf.train.GradientDescentOpimizer(learning_rate=1.0)
self.train_op = optimizer.apply_gradients(zip(grads, trainable_variables))
# 使用给定的模型model在数据data上运行train_op,并返回在全部数据上的perplexity值。
def run_epoch(session, model, batches, train_op, output_log, step):
# 计算平均perplexity的辅助变量。
total_cost = 0.0
iters = 0
state = session.run(model.initial_state)
# 训练一个epoch。
for x, y in batches:
# 在当前batch上运行train_op并计算损失值。
# 交叉熵损失函数计算的就是下一个单词为给定单词的概率。
cost, state, _ = session.run(
[model.cost, model.final_state, train_op],
{
model.input_data: x,
model.targets: y,
model.initial_state: state
}
)
total_cost += cost
iters += model.num_step
# 只有在训练时输出日志。
if output_log and step%100 == 0:
print("After %d steps, perplexity is %.3f" % (step, np.exp(total_costs/iters)))
step += 1
# 返回给定模型在给定数据上的perplexity值。
return step, np.exp(total_costs / iters)
def read_data(file_path):
... # 与前面9.2.2小节中数据batching部分讲解的代码相同。
def make_batches(id_list, batch_size, num_step):
... # 与前面9.2.2小节中数据batching部分讲解的代码相同。
def main():
# 定义初始化函数。
initializer = tf.random_uniform_initializer(-0.05, 0.05)
# 定义训练用的循环神经网络模型。
# 它与train_model共用参数,但是没有dropout。
with tf.variable_scope("language_model", reuse=True, initializer=initializer):
eval_model = PTBModel(False, EVAL_BATCH_SIZE, EVAL_NUM_STEP)
# 训练模型。
with tf.Session() as session:
tf.global_variables_initializer().run()
train_batches = make_batches(
read_data(TRAIN_DATA),
TRAIN_BATCH_SIZE,
TRAIN_NUM_STEP
)
eval_batches = make_batches(
read_data(EVAL_DATA),
EVAL_BATCH_SIZE,
EVAL_NUM_STEP
)
step = 0
for i in range(NUM_EPOCH):
print("In iteraton: %d" % (i+1))
step, train_pplx = run_epoch(
session,
train_model,
train_batches,
train_model.train_op,
True,
step
)
print("Epoch: %d Train Perplexity: %.3f" % (i+1), train_pplx)
_, eval_pplx = run_epoch(
session,
eval_model,
eval_batches,
tf.no_op(),
False,
0
)
print("Epoch: %d Eval Perplexity: %.3f" % (i+1, eval_pplx))
_, test_pplx = run_epoch(
session,
eval_model,
test_batches,
tf.no_op(),
False,
0
)
print("Test Perplexity: %.3f" % test_pplx)
if __name__ == "__main__":
main()
运行以上程序可以得到类似如下的输出结果:
In iteration: 1
After 0 steps, perplexity is 10011.672
After 100 steps, perplexity is 1835.183
After 200 steps, perplexity is 1217 . 401
After 300 steps, perplexity is 948.607
After 400 steps, perplexity is 776.578
...
After 6400 steps, perplexity is 75.838
After 6500 steps, perplexity is 75.161
After 6600 steps, perplexity is 74.335
Epoch: 5 Train Perplexity: 74.497
Epoch: 5 Eval Perplexity: 107.432
Test Perplexity: 104.704
从输出结果可以看出,迭代开始时,perplexity值为10011.672,这基本相当于从1万个单词中随机选择下一个单词。而在训练结束后,训练数据上的perplexity值降低到了104.704。这表明通过训练过程,将选择下一个单词的范围从1万个减小到了大约105个。通过调整LSTM隐藏层的节点个数和大小以及训练法代的轮数还可以将perplexity值降到更低。
-9.3- 神经网络机器翻译
本节将介绍
机器翻译
的seq2seq模型
和注意力(Attention)机制
,并使用公开数据集搭建一个英中翻译模型。
上一节介绍的语言模型是很多自然语言处理应用的基石,非常多自然语言处理应用的技术都是基于语言模型,这一节将介绍的机器翻译就是一个例子。
-9.3.1- 机器翻译背景与Seq2Seq模型介绍
本小节将:
*
更加详细地介绍机器翻译
的背景;
*
介绍最基础的机器翻译算法——Seq2Seq模型
。
*
机器翻译的研究始于20世纪50年代,是人工智能最早的研究领域之一。当时的主要做法是依靠人工编写翻译规则
,将源语言的句式和词汇按照固定规则转换为目标语言。然而人们很快认识到语言的复杂程度是难以用规则涵盖的。到20世纪80年代为止,机器翻译只在天气预报等语法简单、词汇固定的个别领域实现了应用。
*
20世纪90年代起,IBM提出了统计机器翻译
,他们提出的IBM模型实现了在平行语料上自动学习单词
之间的对应,再与语言模型结合,实现了基于单词的机器翻译系统。
*
在此基础上,2003年提出的“基于短语
的机器翻译”将单词之间的对应扩展到词组之间的对应,实现了当时最优的翻译效果,随即被工业界广泛采纳。
*
与传统的统计翻译模型相比,2014年提出的循环神经网络Seq2Seq
在概念上要简单得多。在统计翻译模型中,模型的训练步骤可以分为预处理
、词对齐
、短语对齐
、抽取短语特征(feature)
、训练语言模型
、学习特征权重
等诸多步骤。而Seq2Seq模型的基本思想非常简单——使用一个循环神经网络读取输入句子,将整个句子的信息压缩到一个固定维度的编码中;再使用另一个循环神经网络读取这个编码,将其“解压”为目标语言的一个句子。这两个循环神经网络分别称为编码器(Encoder)
和解码器(Decoder)
,这个模型也称为Encoder-Decoder模型
。
解码器
部分的结构与语言模型
几乎完全相同:输入为单词的词向量,输出为Softmax层产生的单词概率,损失函数为。事实上,解码器可以理解为一个以输入编码为前提条件的语言模型(Conditional Language Model)
。语言模型中使用的一些技巧,如共享Softmax层和词向量层的参数
,都可以直接应用到Seq2Seq模型的解码器中。
编码器
部分则更为简单。它与解码器一样拥有词向量层和循环神经网络,但是由于在编码阶段并未输出,因此不需要Softmax层。
TRAIN
在训练过程中,编码器顺序读入每个单词的词向量,然后将最终的隐藏状态复制到解码器作为初始状态。解码器的第一个输入是一个特殊的<sos>(Start-0f-Sentence)字符
,每一步预测的单词是训练数据的目标句子,预测序列的最后一个单词是与语言模型相同的<eos>(End-Of-Sentence)字符
。
TEST
在机器翻译应用中,在真实应用场景下的测试步骤与语言模型的测试步骤有所不同。上一节介绍过,语言模型中测试的标准是给定目标句子上的perplexity。而机器翻译的测试方法是,让解码器在没有“正确答案”的情况下自主生成一个翻译句子,然后采用人工或自动的方法对翻译句子的质量进行评测。
让解码器生成句子的过程也称为“解码”(decoding)。这样的测试方法更贴近于用户实际使用机器翻译产品的体验。在解码过程中,每一步预测的单词中概率最大的单词被选为这一步的输出,并复制到下一步的输入中(在图9-5中用虚线表示)。
下面先介绍机器翻译领域的数据集和数据处理方法,在一个较小的数据集上实现一个基础的Seq2Seq翻译模型。
-9.3.2- 机器翻译文本数据的预处理
本小节将:
*
介绍机器翻译的数据集
;
*
介绍数据预处理
方法。
机器翻译领域最重要的公开数据集是WMT数据集
。WMT的全称是Workshop on Statistical Machine Translation
,每年,这个会议都会组织一次机器翻译领域的竞赛,其提供的训练和测试数据也成为了机器翻译领域论文的标准数据集。然而由于WMT数据集较大(取决于不同语言,通常在百万句到千万句不等),训练时间很长,因此本书中将采用一个较小的IWLST TED演讲数据集
作为示例。该数据集的下载地址是https://wit3.fbk.eu/mt.php?release=2015-01。下面以英文-中文数据为例,它的英文-中文训练数据包含约21万个句子对,内容是TED演讲的中英字幕。
对于平行语料的预处理
,其步骤和9.2.1小节中关于PTB数据的预处理基本是一样的。首先,需要统计语料中出现的单词
,为每个单词分配一个ID,将词汇表存入一个vocab文件,然后将文本转化为用单词编号的形式来表示。
与前面不同的地方主要在于,下载的文本没有经过预处理,尤其是没有经过切词
。举个例子来说,由于每个英文单词和标点符号之间是紧密相连的,导致不能像处理PTB数据那样直接用空格对单词进行分割。为此需要用一些独立的工具来进行切词操作。最常用的开源切词工具是moses
。可以在GitHub上下载moses切词工具的代码:
https://github.com/moses-smt/mosesdecoder/blob/master/scripts/tokenizer/tokenizer.perl
它的使用方法如下:
# train.raw.en 是原始输入数据,格式为每行一句话;
# train.txt.en 是输出的文件名。
# -no-escape 参数表示不把标点符号替换为HTML编码(如把引号替换为“"”)。
# -l en 参数表示输入文件的语言是英文。
perl ./moses_tokenizer.perl -no-escape -l en < ./train.raw.en > train.text.en
切词前文本如下:
And we knew it was volcanic back in the '60s, '70s.
切词后的文本如下,注意单引号和数字、逗号和句号之前都增加了空格:
And we knew it was volcanic back in the ' 605 , ' 70s .
对于中文文本而言,为了方便起见,本书的例子中直接以字
为单位进行切割。
# train.raw.zh 是原始输入数据,格式为每行一句话;
# train.txt.zh 是输出文件名
# sed 's/ //g' 表示去除文本中已有的空格。
# sed 's/\B/ /g' 表示将每个字之间的边界替换为空格。
sed 's/ //g; s/\B/ /g' ./train.raw.zh > train.txt.zh
切词前的文本如下:
六七十年代时找们只知道这是一座火山。
切词后的文本如下。每个字和符号之间都增加了空格:
六 七 十 年 代 时 我 们 只 知 道 这 是 一 座 火 山 。
完成切词后,再使用和9.2.1小节处理PTB数据相同的方法,分别生成英文文本和中文文本词汇文件,并将文本转化为单词编号。生成词汇文件时,需要注意将<sos>
、<eos>
、<unk>
这3个词手动加入到词汇表中,并且要限制词汇表大小,将词频过低的词替换为<unk>
。相应代码在前面处理PTB相关数据的代码中都以注释的形式给出了。下面的例子中,假定英文词汇表大小为10000,中文词汇表大小为4000。
下面来看一下关于机器翻译语料的填充(padding)
和batching
的内容。
在PTB数据中,由于句子之间有上下文关联,因此可以直接将连续的句子
连接起来称为一个大的段落
。而在机器翻译的训练样本中,每个句子对通常是作为独立的数据来训练的。由于每个句子的长短不一致,因此在将这些句子放入同一个batch时,需要将较短的句子补齐到与同batch内最长句子相同的长度。用于填充长度而填入的位置叫作填充(padding)
。在TensorFlow中,tf.data.Dataset的padded_batch()函数
提供了这一功能。
图9-6给出了一个填充示例。假设一个数据集中有4句话,分别是、、和,将它们加入必要的填充并组成大小为2的batch后,得到的batch如图9-6所示。
循环神经网络在读取数据时会将填充位置的内容与其他内容一样纳入计算,因此为了不让填充影响训练,有两方面需要注意:
*
第一,循环神经网络在读取填充时,应当跳过这一位置的计算。以编码器为例,如果编码器在读取填充时,像正常输入一样处理填充输入,那么在读取之后产生的最后一位隐藏状态就和读取之后的隐藏状态不同,会产生错误的结果。TensorFlow提供了tf.nn.dynamic_rnn()
方法来实现这一功能。dynamic_rnn()对每一个batch的数据读取两个输入:输入数据的内容(维度为)和输入数据的长度(维度为)。对于输入batch里的每一条数据,在读取了相应长度的内容后,dynamic_rnn()就跳过后面的输入,直接把前一步的计算结果复制到后面的时刻。这样可以保证padding是否存在
不影响模型效果。
另外值得注意的是,使用dyanmic_rnn()时每个batch的最大序列长度不需要相同。例如在上面的例子中,第一个batch的维度是,而第二个batch的维度是。在训练中dynamic_rnn()会根据每个batch的最大长度动态展开到需要的层数,这就是它被称为“dynamic”的原因。
*
第二,在设计损失函数时需要特别将填充位置的损失
的权重
设置为0,这样在填充位置产生的预测不会影响梯度的计算。
下面的代码使用tf.data.Dataset.padded_batch()
来进行填充和batching,并记录每个句子的序列长度以用作dynamic_rnn()的输入。与前面PTB的例子不同,这里没有将所有数据读入内存,而是使用Dataset从磁盘动态读取数据。
MAX_LEN = 50 # 限定句子的最大单词数量。
SOS_ID = 1 # 目标语言词汇表中<sos>的ID。
# 使用Dataset从一个文件中读取一个语言的数据。
# 数据的格式为每行一句话,单词已经转化为单词编号。
def MakeDataset(file_path):
dataset = tf.data.TextLineDataset(file_path)
# 根据空格将单词编号切分开来并放入一个一维向量。
dataset = dataset.ma(lambda string: tf.string_split([string]).values)
# 将字符串形式的单词编号转化为整数。
dataset = dataset.map(
lambda string: tf.string_to_number(string, tf.int32)
)
# 统计每个句子的单词数量,并与句子内容一起放入Dataset中。
dataset = dataset.map(lambda x: (x, tf.size(x)))
return dataset
# 从源语言文件src_path和目标语言文件trg_path中分别读取数据,并进行填充和batching操作。
def MakeSrcTrgDataset(src_path, trg_path, batch_size):
# 首先分别读取源语言数据和目标语言数据。
src_data = MakeDataset(src_path)
arg_data = MakeDataset(trg_path)
# 通过zip操作将两个Dataset合并为一个Dataset。
# 现在每个Dataset中每一项ds由4个张量组成:
# ds[0][0] 是源句子
# ds[0][1] 是源句子长度
# ds[1][0] 是目标句子
# ds[1][1] 是目标句子长度
dataset = tf.data.Dataset.zip([src_data, trg_data])
# 删除内容为空(只包含<eos>)的句子和长度过长度句子。
def FilterLength(src_tuple, trg_tuple):
((src_input, src_len), (trg_label, trg_len)) = (src_tuple, trg_tuple)
src_len_ok = tf.logical_and(
tf.greater(src_len, 1),
tf.less_equal(src_len, MAX_LEN)
)
trg_len_ok = tf.logical_and(
tf.greater(trg_len, 1),
tf.less_equal(trg_len, MAX_LEN)
)
return tf.logical_and(src_len_ok, trg_len_ok)
dataset = dataset.filter(FilterLength)
# 从图9-5可知,解码器需要两种格式的目标句子:
# 1. 解码器的输入(trg_input),形式如同“<sos> X Y Z”
# 2. 解码器的目标输出(trg_label),形式如同“X Y Z <eos>”
# 上面从文件中读到的目标句子是“X Y Z <eos>”的形式,我们需要从中生成“<sos> X Y Z”形式并加入到Dataset中。
def MakeTrgInput(src_tuple, trg_tuple):
((src_input, src_len), (trg_label, trg_len)) = (src_tuple, trg_tuple)
trg_input = tf.concat(
[[SOS_ID], trg_label[:-1]],
axis=0
)
return ((src_input, srcs_len), (trg_input, trg_label, trg_len))
dataset = dataset.map(MakeInput)
# 随机打乱训练数据。
dataset = dataset.shuffle(10000)
# 确定填充后输出的数据维度。
padded_shape = (
(tf.TensorShape([None]), # “源句子” 是长度未知的向量
tf.TensorShape([]) # “源句子长度” 是单个数字
),
(tf.TensorShape([None]), # “目标句子(解码器输入)” 是长度未知的向量
tf.TensorShape([None]), # “目标句子(解码器目标输出)” 是长度未知的向量
tf.TensorShape([]) # “目标句子长度” 是单个数字
),
)
# 调用padding_batch()方法进行batching操作。
batched_dataset = dataset.padded_batch(batch_size, padded_shapes)
return batched_data
-9.3.3- Seq2Seq模型的代码实现
本小节将:
*
给出完整的Seq2Seq模型样例代码。 这小节中将在TensorFlow中完整实现一个Seq2Seq模型。在本小节中,模型的训练和测试将分为两个程序来实现。
首先讲解模型训练的实现。该实现与9.2节中语言模型的实现相似,也使用一个双层LSTM作为循环神经网络的主体,并在Softmax层和词向量层之间共享参数。与9.2节中的语言模型相比,下面代码的主要变化有以下几点:
*
增加了一个循环神经网络作为编码器
。
*
使用Dataset动态读取
数据,而不是直接将所有数据读入内存。
*
每个batch完全独立,不需要在batch之间传递状态。
*
每训练200步便将模型参数保存到一个checkpoint中。之后会讲解怎样从checkpoint中读取模型井对新的句子进行翻译。
# coding: utf-8
import tensorflow as tf
# 假设输入数据已经用了9.2.1小节中的方法转换成了单词编号的格式。
SRC_TRAIN_DATA = "/path/to/data/traain.en" # 源语言输入文件。
TRG_TRAIN_DATA = "/path/to/data/train.zh" # 目标语言输入文件。
CHECKPOINT_PATH = "/path/to/seq2seq_ckpt" # checkpoint保存路径。
HIDDEN_SIZE = 1024 # LSTM的隐藏层规模。
NUM_LAYERS = 2 # 深层循环神经网络LSTM结构的层数。
SRC_VOCAB_SIZE = 10000 # 源语言词汇表大小。
TRG_VOCAB_SIZE = 4000 # 目标语言词汇表大小。
BATCH_SIZE = 100 # 训练数据batch的大小。
NUM_EPOCH = 5 # 使用训练数据的轮数。
KEEP_PROB = 0.8 # 节点不被dropout的概率。
MAX_GRAD_NORM = 5 # 用于控制梯度膨胀的梯度大小上限。
SHARE_EMB_AND_SOFTMAX = True # 在Softmax层和词向量层之间共享参数。
# 定义NMTModel类来描述模型。
class NMTModel(object):
# 在模型的初始化函数中定义模型要用到的变量。
def __init__(self):
# 定义编码器和解码器所使用的LSTM结构。
self.enc_cell = tf.nn.rnn_cell.MultiRNNCell(
[tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE) for _ in range(NUM_LAYERS)]
)
self.dec_cell = tf.nn.rnn_cell.MultiRNNCell(
[tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE) for _ in range(NUM_LAYERS)]
)
# 为源语言和目标语言分别定义词向量。
self.src_embedding = tf.get_variable(
"src_emb",
[SRC_VOCAB_DATA, HIDDEN_SIZE]
)
self.trg_embedding = tf.get_variable(
"trg_emb",
[TRG_VOCAB_SIZE, HIDDEN_SIZE]
)
# 定义Softmax层的变量
if SHARE_EMB_AND_SOFTMAX:
self.softmax_weight = tf.transpose(self.trg_embedding)
else:
self.softmax_weight = tf.get_variable(
"weight",
[HIDDEN_SIZE, TRG_VOCAB_SIZE]
)
self.softmax_bias = tf.get_variable(
"softmax_bias",
[TRG_VOCAB_SIZE]
)
# 在forward函数中定义模型的前向计算图。
# src_input、src_size、trg_input、trg_label、trg_size分别是上面MakeSrcTrgDataset函数产生的5中张量。
def forward(self, src_input, src_size, trg_input, trg_label, trg_size):
batch_size = tf.shape(src_input)[0]
# 将输入和输出单词编号转为词向量。
src_emb = tf.nn.embedding_lookup(self.src_embedding, src_input)
trg_emb = tf.nn.embedding_lookup(self.trg_embedding, trg_input)
# 在词向量上进行dropout
src_emb = tf.nn.dropout(src_emb, KEEP_PROB)
trg_emb = tf.nn.dropout(trg_emb, KEEP_PROB)
# 使用dynamic_rnn构造编码器。
# 编码器读取源句子每个位置的词向量,输出最后一步的隐藏状态ent_state。
# 因为编码器是一个双层LSTM,因此enc_state是一个包含两个LSTMStateTuple类的tuple,每个LSTMStateTuple对应编码器中一层的状态。
# enc_output是顶层LSTM在每一步的输出,它的维度是[batch_size, max_time, HIDDEN_SIZE]。
# Seq2Seq模型中不需要用到enc_output,而下一小节介绍的Attention模型会用到它。
with tf.variable_scope("encoder"):
enc_output, enc_state = tf.nn.dynamic_rnn(
self.src_cell,
src_emb,
src_size,
dtype=tf.float32
)
# 使用dynamic_rnn构造解码器。
# 解码器读取目标句子每个位置的词向量,输出的dec_outputs为每一步顶层LSTM的输出。
# dec_outputs的维度是[batch_size, max_time, HIDDEN_SIZE]。
# initial_state=enc_state表示用编码器的输出来初始化第一步的隐藏状态。
with tf.variable_scope("decoder"):
dec_outputs, _ = tf.nn.dynamic_rnn(
self.dec_cell,
trg_emb,
trg_size,
initial_state=enc_state
)
# 计算解码器每一步的log(perplexity)。
# 这一步与语言模型代码相同。
output = tf.reshape(dec_output, [-1, HIDDEN_SIZE])
logits = tf.matmul(output, self.softmax_weight) + self.softmax_bias
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=tf.reshape(trg_label, [-1]),
logits=logits
)
# 在计算平均损失时,需要将填充位置的权重设置为0,以避免无效位置的预测干扰模型的训练。
label_weights = tf.reshape(label_weights, [-1])
cost = tf.reduce_sum(loss * label_weights)
cost_per_token = cost / tf.reduce_sum(label_weights)
# 定义反向传播操作。
# 反向传播的实现与语言模型代码相同。
trainable_variables = tf.trainable_variables()
# 控制梯度大小,定义优化方法和训练步骤。
grads = tf.gradients(cost / tf.to_float(batch_size), trainable_variables)
grads, _ = tf.clip_by_global_norm(grads, MAX_GRAD_NORM)
optimizer = tf.train.GradientDescentOptimizer(learning_rate=1.0)
train_op = optimizer.apply_gradients(
zip(grads, trainable_variables)
)
return cost_per_token, train_op
# 使用给定的模型model上训练一个epoch,并返回全局步数。
# 每训练200步便保存一个checkpoint。
def run_epoch(session, cost_op, train_op, saver, step):
# 训练一个epoch。
# 重复训练步骤直至遍历完Dataset中所有数据。
while True:
try:
# 运行train_op并计算损失值。
# 训练数据在main()函数中以Dataset方式提供。
cost, _ = session.run([cost_op, train_op])
if step%10 == 0:
print("After %d steps, per token cost is %.3f" % (step, cost))
# 每200步保存一个checkpoint。
if step%200 == 0:
saver.save(session, CHECKPOINT_PATH, global_step=step)
step += 1
except tf.errors.OutOfRangeError:
break
return step
def main():
# 定义初始化函数。
initializer = tf.random_uniform_initializer(-0.05, 0.05)
# 定义训练用的循环神经网络模型。
with tf.variable_scope("nmt_model", reuse=None, initializer=initializer):
train_model = NMTModel()
# 定义输入数据。
data = MakeSrcTrgDataset(SRC_TRAIN_DAATA, TRG_TRAIN_DATA, BATCH_SIZE)
iterator = data.make_initializable_iterator()
(src, src_size), (trg_input, trg_label, trg_size) = iterator.get_next()
# 定义前向计算图。
# 输入数据以张量形式提供给forward()函数。
cost_op, train_op = train_model.forward(src, src_size, trg_input, trg_label, trg_size)
# 训练模型。
saver = tf.train.Saver()
step = 0
with tf.Session() as sess:
tf.global_variables_initializer().run()
for i in range(NUM_EPOCH):
print("In iteration: %d" % (i+1))
sess.run(iterator.initializer)
step = run_epoch(sess, cost_op, train_op, saver, step)
if __name__ == "__main__":
main()
运行以上程序,得到如下结果:
In iteration: 1
After 0 steps, per token cost is 8.298
After 10 steps, per token cost is 8.908
After 20 steps, per token cost is 7.428
After 30 steps, per token cost is 7.137
After 40 steps, per token cost is 7.044
...
After 8980 steps, per token cost is 2.488
After 8990 steps, per token cost is 2.402
After 9000 steps, per token cost is 2.524
After 9010 steps, per token cost is 2.516
上面的程序完成了机器翻译模型的训练步骤,井将训练好的模型保存到checkpoint中。下面讲解怎样从checkpoint中读取模型并对一个新的句子进行翻译。对新输入的句子进行翻译的过程也称为解码(decoding)
或推理(inference)
。
在解码的程序中,解码器的实现与训练时有很大不同。这是因为训练时解码器可以从输入中读取完整的目标训练句子,因此可以用dynamic_rnn简单地展开成前馈网络。而在解码过程中,模型只能看到输入句子,却不能看到目标句子。解码器在第一步读取``<sos>符,预测目标句子的第一个单词,然后需要将这个预测的单词复制到第二步作为输入,再预测第二个单词,直到预测的单词为
<eos>为止。这个过程需要使用一个循环结构来实现。在TensorFlow中,循环结构是由
tf.while_loop()```来实现的。tf.while_loop()的使用方法如下:
# cond是一个函数,负责判断继续执行循环的条件。
# loop_body是每个循环体内执行的操作,负责对循环状态进行更新。
# init_state为循环的起始状态,它可以包含多个Tensor或者TensorArray。
# 返回的结果是循环结束时的循环状态。
final_state = tf.while_loop(cold, loop_body, init_state)
如果用伪代码来表示运行逻辑的话,那tf.while_loop的功能与下面的代码相当:
def while_loop(cold, loop_body, init_state):
state = init_state
while(cond(state)): # 使用cond函数判断循环结束条件。
state = loop_body(state) # 使用loop_body函数对state进行更新。
return state
但是,与上面的伪代码不同的是,tf.while_loop建立计算图的过程中并没有真正的进行循环,而是建立了一个包含循环逻辑的计算节点。在建立计算图的过程中,loop_body函数内的代码之执行一次。
下面的代码展示了如何用tf.while_loop()
来实现解码过程。
import tensorflow as tf
# 读取checkpoint的路径。
# 9000表示是训练程序在第9000步保存的checkpoint。
CHECKPOINT_PATH = "/path/to/seq2seq_ckpt-9000"
# 模型参数。
# 必须与训练时的模型参数保持一致。
HIDDEN_SIZE = 1024 # LSTM的隐藏层规模。
NUM_LAYERS = 2 # 深层循环神经网络中LSTM结构的层数。
SRC_VOCAB_SIZE = 10000 # 源语言词汇表大小。
TRG_VOCAB_SIZE = 4000 # 目标语言词汇表大小。
SHARE_EMB_AND_SOFTMAX = True # 在Softmax层和词向量层之间的共享参数。
# 词汇表中<sos>和<eos>的ID。
# 在解码过程中,需要用<sos>作为第一步的输入,并将检查是否是<eos>,因此需要知道这两个符号的ID。
SOS_ID = 1
EOS_ID = 2
# 定义NMTModel类来描述模型。
class NMTModel(object):
# 在模型的初始化函数中定义模型需要用到的变量。
def __init__(self):
pass
# 与训练时的__init__函数相同。
# 通常在训练程序和解码程序中复用NMTModel类及其__init__函数,以确保解码时和训练时定义的变量是相同的。
def inference(self, src, src_input):
# 虽然输入只有一个句子,但因为dynamic_rnn要求输入是batch的形式,因此这里将输入句子整理为大小为1的batch。
src_size = tf.convert_to_tensor([len(src_input)], dtype=tf.int32)
src_input = tf.convert_to_tensor([src_input], dtype=tf.int32)
src_emb = tf.nn.embedding_lookup(self.src_embedding, src_input)
# 使用dynamic_rnn构造编码器。
# 这一步与训练时相同。
with tf.variable_scope("encoder"):
enc_outputs, enc_state = tf.nn.dynamic_rnn(
self.enc_call,
src_emb,
src_size,
dtype=tf.float32
)
# 设置解码的最大步数。
# 这是为了避免在极端情况出现无限循环的问题。
MAX_DEC_LEN = 100
with tf.variable_scope("decoder/rnn/multi_rnn_cell"):
# 使用一个边长的TensorArray来存储生成的句子。
init_array = tf.TensorArray(
dtype=tf.init32,
size=0,
dynamic_size=True,
clear_after_read=False
)
# 填入第一个单词<sos>作为解码器的输入。
init_array = init_array.write(0, SOS_ID)
# 构建初始的循环状态。
# 循环状态包含循环神经网络的隐藏状态,保存生成句子的TensorArray,以及记录解码步数的一个整数step。
init_loop_var = (enc_state, init_array, 0)
# tf.while_loop的循环条件:
# 循环直到解码器输出<eos>,或者达到最大步数为止。
def continue_loop_condition(state, trg_ids, step):
return tf.reduce_all(
tf.logical_and(
tf.not_euqal(trg_ids.read(step), EOS_ID),
tf.less(step, MAX_SEC_LEN - 1)
)
)
def loop_body(state, trg_ids, step):
# 读取最后一步输出的单词,并读取其词向量。
trg_input = [trg_ids.read(step)]
trg_emb = tf.nn.embedding_lookup(self.trg_embedding, trg_input)
# 这里不使用dynamic_rnn,而是直接调用dec_cell向前计算一步。
dec_outputs, next_state = self.dec_cell.call(
state=state,
inputs=trg_emb
)
# 计算每个可能的输出单词对应的logit,并选取logit值最大的单词作为这一步的输出。
output = tf.reshape(dec_outputs, [-1, HIDDEN_SIZE])
logits = (tf.matmul(output, self.softmax_weight) + self.softmax_bias)
next_id = tf.argmax(logits, axis=1, output_type=tf.init32)
# 将这一步输出的单词写入循环状态的trg_ids中。
trg_ids = trg_ids.write(step+1, next_id[0])
return next_state, trg_ids, step+1
# 执行tf.while_loop,返回最终状态。
state, trg_ids, step = tf.while_loop(
continue_loop_condition,
loop_body,
init_loop_var
)
return trg_ids.stack()
def main():
# 定义训练用的循环神经网络模型。
with tf.variable_scope("nmt_model", reuse=None):
model = NMTModel()
# 定义一个测试例子。
# 这里的例子是经过预处理后的"This is a test."
test_sentence = [90, 13, 9, 689, 4, 2]
# 建立解码所需的计算图。
output_op = model.inference(test_sentence)
sess = tf.Session()
saver = tf.train.Saver()
saver.restore(sess, CHECKPOINT_PATH)
# 读取翻译结果
output = sess.run(output_op)
print(output)
sess.close()
if __name__ == "__main__":
main()
运行以上程序, 得到的翻译结果是[1, 10, 7, 12, 411, 271, 6, 2]
,按中文vocab文件转化为文字,就是<sos>这是一个测试。<eos>
,翻译成功!
-9.3.4- 注意力机制
本小节将介绍 Seq2Seq模型的一个重要改进——
注意力Attention
机制。
在Seq2Seq模型中,编码器将完整的输入句子压缩到一个维度固定的向量中,然后解码器根据这个向量生成输出句子。当输入句子较长时,这个中间向量
难以存储足够的信息,就成为这个模型的一个瓶颈。注意力(Attention)
机制就是为了解决这个问题而设计的。注意力机制允许解码器随时查阅
输入句子中的部分单词或片段,因此不再需要在中间向量中存储所有信息。
这个过程可以类比于人的翻译过程:在翻译句子时,人们经常
回头查阅
原文的某个词或者片段,来提高对于这个词或者片段的翻译精确度。
举个例子,假如一个人要把“The sea is blue”翻译成中文,当他翻译出“大海的颜色是_”的时候,如果突然想不起来原文中写的是什么颜色了,就会回头到原文相关部分去查阅。
这时如果不允许他查询原文(类比于Seq2Seq模型),那么他就只能根据常理推断来选择一个最可能的颜色,准确率就会下降。
图9-7和图9-8展示了使用注意力机制的Seq2Seq模型,其中图9-7概括性地展示了注意力机制的主要框架,而图9-8给出了注意力模型中的细节。解码器在解码的每一步将隐藏状态
作为查询的输入来查询
编码器的隐藏状态,在每个输入的位置计算一个反映与查询输入相关程度的权重
,再根据这个权重对各输入位置的隐藏状态求加权平均
。加权平均后得到的向量称为context
,表示它是与翻译当前单词最相关的原文信息。在解码下一个单词时,将context作为额外信息输入到循环神经网络中,这样循环神经网络可以时刻读取原文中最相关的信息,而不必完全依赖于上一时刻的隐藏状态。
下面介绍注意力机制的数学定义。在图9-8中,表示编码器在第个单词上的输出,是编码器在预测第个单词时的状态。计算时刻的的方法如下:
其中是计算原文各单词与当前解码器状态的“相关度”的函数。
最常用的函数定义是一个带有单个隐藏层的前馈神经网络:
其中,U,V,W是模型的参数,构成了一个包含一个隐藏层的全连接神经网络。这个模型是Dzmitry Bahdanau等在第一次提出注意力机制的论文中采用的模型,因此也称为BahdanauAttention
。除此之外,注意力机制还有多种其他设计,如Minh-Thang Luong等提出的,或直接使用两个状态之间的点乘。无论采用哪个模型,通过softmax计算权重α
和通过加权平均计算context
的方法都是一样的。
在计算得到第步的context向量之后,context被加入到时刻作为循环层的输入。假设的维度是,词向量的维度是,那么在计算隐藏状态时,输入的维度是。通过context向量,解码器可以在解码的每一步查询最相关的原文信息,从而避免Seq2Seq模型中信息瓶颈问题。
比较图9-5和图9-7,除增加了注意力机制
以外,还可以看到两点不同。
*
第一,编码器采用了一个双向循环网络
。虽然Seq2Seq模型中也可以使用双向循环网络作为编码器,但在使用注意力机制时,这一选择将变得尤为重要。这是因为在解码器通过注意力查询一个单词时,通常也需要知道该单词周围的部分信息,例如在查询“颜色”时,可能更需要知道查询的是“大海”的颜色,而不是别的物体的颜色。如果使用单向循环网络,那么每个单词的attentation就只包含它左边的文字的信息,而不包含它右边的信息,而双向循环网络使得每个单词的attentation可以同时包含左右两侧的信息。
*
第二,这里取消
了编码器和解码器之间的连接
,解码器完全依赖于注意力机制获取原文信息。取消这一连接使得编码器和解码器可以自由选择
模型。例如它们可以选择不同层数、不同维度、不同结构的循环神经网络,可以在编码器中使用双向LSTM,而在解码器使用单向LSTM,甚至可以用卷积网络作为编码器、用循环神经网络作为解码器等。
注意力机制的实现较为复杂。为了方便开发者,TensorFlow己经提供了几种预置的实现。tf.contrib.seq2seq.AttentionWrapper将解码器的循环神经网络层和注意力层结合,成为一个更高层的循环神经网络。每一步计算的context在相邻解码步骤之间的传递,可以视为一个隐藏状态在相邻时刻之间的传递。将注意力机制封装成循环神经网络后,就可以使用dynamic_rnn调用新的包含注意力的循环神经网络。
下面的代码在9.3.3小节关于Seq2Seq模型的代码基础上稍作修改,展示了使用AttentionWrapper的方法:
# 下面self.enc_cell_fw和self.enc_cell_bw定义了编码器中的前向和后向循环网络。
# 它们取代了Seq2Seq样例代码中__init__()函数里的self.enc_cell。
self.enc_cell_fw = tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE)
self.enc_cell_bw = tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE)
# 下面的代码取代了Seq2Seq样例代码中的forward函数里的相应部分。
with tf.variable_scope("encoder"):
# 构造编码器时,使用bidirectional_dynamic_rnn构造双向循环网络。
# 双向循环网络的顶层输出enc_outputs是一个包含两个张量的tuple,
# 每个张量的维度都是[batch_size, max_time, HIDDEN_SIZE],代表两个LSTM在每一步的输出。
enc_outputs, enc_state = tf.nn.bidirectional_dynamic_rnn(
self.enc_cell_fw,
self.enc_cell_bw,
src_emb,
src_size,
dtype=tf.float32
)
# 将两个LSTM的输出拼接为一个张量。
enc_outputs = tf.concat([enc_outputs[0], enc_outputs[1]], -1)
with tf.variable_scope("decoder"):
# 选择注意力权重的计算模型。
# BahdanauAttention 是使用一个隐藏层的前馈神经网络。
# memory_sequence_length是一个维度为[batch_size]的张量,代表batch中每个句子的长度
# Attention需要根据这个信息把填充位置的注意力权重设置为0。
attention_cell = tf.contrib.seq2seq.AttentionWrapper(
self.dec_cell,
attention_mechanism,
attention_layer_size=HIDDEN_SIZE
)
# 使用attention_cell和dynamic_rnn构造编码器。
# 这里没有指定init_state,也就是没有使用编码器的输出来初始化输入,而完全依赖注意力作为信息来源。
dec_outputs, _ = tf.nn.dynamic_rnn(
attention_cell,
trg_emb,
trg_size,
dtype=tf.float32
)
注意力机制是一种高效获取信息的方式。
*
一方面,它使得解码器可以在每一步主动查询最相关的信息,而暂时忽略不相关的信息;
*
另一方面,它大大缩短了信息流动的距离,比如在传统的Seq2Seq模型中,如果解码器生成最后一个单词时需要用到编码器读入的第一个单词的信息,那么这个信息需要通过个LSTM节点才能从编码器的最前端传递到编码器的最后端,而有了注意力机制后,解码器在任意时刻只需一步就可以查阅输入的任意单词。
鉴于这些优点,注意力机制在很多模型中得到了广泛应用。例如
*
ConvSeq2Seq
模型使用卷积神经网络取代了Seq2Seq模型的循环神经网络,同时仍使用相似的注意力机制在编码器和解码器之间传递信息。
*
Transformer模型
既不使用循环神经网络,也不使用卷积神经网络,而完全使用注意力机制在不同神经层之间传递信息,实现了一个仅依靠注意力机制的机器翻译模型并取得了当前最好的效果。
*
在图像领域中,注意力机制和卷积神经网络结合,也在图像分类、图片描述生成等应用上取得了很好的效果。