今天我们来解析下Tensorflow的Seq2Seq的demo。继上篇博客的PTM模型之后,Tensorflow官方也开放了名为translate的demo,这个demo对比之前的PTM要大了很多(首先,空间上就会需要大约20个G,另外差点把我的硬盘给运行死),但是也实用了很多。模型采用了encoder-decoder的框架结果,佐以attention机制来实现论文中的英语法语翻译功能。同时,模型的基础却来自之前的PTM模型。下面,让我们来一起来了解一下这个神奇的系统吧!
论文介绍及基础描写:
这个英语法语翻译器融合了多篇论文的核心内容,所以在学习的过程中其实我们可以变相的了解这些技巧。首先,Cho在论文Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation一文中指出可以通过encoder-decoder这种类似于编码-解码的框架来做机器翻译。这个框架在后续论文中掀起了不小的波澜,很多作品延续了这种sequence-to-sequence的框架在其他的一些领域,比如image caption。该框架说白了就是两个RNN,一个作为编码器,一个作为解码器。至于RNN cell的结构,虽然本项目支持使用LSTM, default的模型为GRU(Gated Recurrent Uint),一个LSTM的简化版模型。另外,为了使翻译的效果达到最好,2014年Bahdanau在论文Neural Machine Translation By Jointy Learning to Align and Translate中提出的attention机制也被运用了起来。背景介绍到此,下面我们赶快来看看代码吧!
代码:
较之PTM模型,机器翻译的demo代码有足足个file之多,可见其难度。(其实也没那么难,都是些犹如纸老虎般的存在,充其量也就在数量上吓唬吓唬人罢了。)项目源代码可以在Tensorflow官方github中找到,地址点击这里。打开项目后我们发现了3个文件,分别是data_utils.py, seq2seq_model.py以及translate.py。这三个文件里,data_utils.py是类似于helper function般的存在,如其名,是一个集合了对输入raw的数据做处理的一个file。file本身不是用来运行的,只是被translate.py所使用而已。这个文件本身也可以在你自己的Tensorflow框架的本地库内找到,所需要做的只是输入from tensorflow.models.rnn.translate import data_utils,就可以取得库里的函数了。为方便用户,这个data_utils文件里的函数还自带检测数据库是否存在于本地路径的功能,如果你没来得及或不知道如何下载数据库,只需要指定路径后运行库里的函数即可,是不是很方便?
Encoder-decoder模型存在于第二个文件中,即seq2seq_model.py文件。文件作为一个类包含了组建机器翻译所需要的神经网络图框架。与data_utils文件一样,他们都是被我们的“main”文件,translate.py使用的。
了解了三个文件的关系后,我们也了解了系统运行的过程以及解读顺序,那么,我们先来了解一下程序运行的大体顺序。运行该程序的方式为cd到文件所在的目录下后在terminal里面输入“python translate.py –data_dir /tmp/ –train_dir /tmp/”就可以了。 其中tmp为默认的tmp文件夹,如果你希望长期保留运行结果,建议在别的文件夹里进行实验。另外,你可以改变一些模型的参数,如把模型改为两层,每层改为256个神经元等。方法为在刚才的comment后面加上两横杠,更改参数名,等于号及更改后参数量。如改变模型为两层的框架的方法是“–num_layers=2”。那么在输入参数后系统是怎么运行的呢?我们在translate.py里找到了main函数的代码如下:
def main(_): if FLAGS.self_test: self_test() elif FLAGS.decode: decode() else: train()
该代码显示,一般情况下如果你没表明要运行decode模式或者测试模式,训练模式将自动开始。那么很明显,我们的主程序在训练模式,也就train函数里。那么我们就顺藤摸瓜来看看train函数吧。
首先,train函数的开始为运行data_utils库的prepare_wmt_data函数,这里的输入为我们在之前手动输入的data_dir外还有英语和法语的单词数。这里除了data_dir是我们之前手动输入的外,英法语的单词数默认都是4万。通过这个神奇的prepare_wmt_data函数,我们可以得到英语,法语两种语言的训练以及测试资料外,还可以获得英语及法语单词的单词表存放路径。那么这个函数是如何工作的呢?我们这就来一探究竟。
def prepare_wmt_data(data_dir, en_vocabulary_size, fr_vocabulary_size, tokenizer=None): """Get WMT data into data_dir, create vocabularies and tokenize data. Args: data_dir: directory in which the data sets will be stored. en_vocabulary_size: size of the English vocabulary to create and use. fr_vocabulary_size: size of the French vocabulary to create and use. tokenizer: a function to use to tokenize each data sentence; if None, basic_tokenizer will be used. Returns: A tuple of 6 elements: (1) path to the token-ids for English training data-set, (2) path to the token-ids for French training data-set, (3) path to the token-ids for English development data-set, (4) path to the token-ids for French development data-set, (5) path to the English vocabulary file, (6) path to the French vocabulary file. """ # Get wmt data to the specified directory. # 建立训练集并取得他们的路径 train_path = get_wmt_enfr_train_set(data_dir) dev_path = get_wmt_enfr_dev_set(data_dir) # Create vocabularies of the appropriate sizes. # 建立英语及法语的单词表 fr_vocab_path = os.path.join(data_dir, "vocab%d.fr" % fr_vocabulary_size) en_vocab_path = os.path.join(data_dir, "vocab%d.en" % en_vocabulary_size) create_vocabulary(fr_vocab_path, train_path + ".fr", fr_vocabulary_size, tokenizer) create_vocabulary(en_vocab_path, train_path + ".en", en_vocabulary_size, tokenizer) # Create token ids for the training data. # 将输入数据里的单词数字化以方便运算 fr_train_ids_path = train_path + (".ids%d.fr" % fr_vocabulary_size) en_train_ids_path = train_path + (".ids%d.en" % en_vocabulary_size) data_to_token_ids(train_path + ".fr", fr_train_ids_path, fr_vocab_path, tokenizer) data_to_token_ids(train_path + ".en", en_train_ids_path, en_vocab_path, tokenizer) # Create token ids for the development data. # 将测试数据里的单词数字化以方便运算 fr_dev_ids_path = dev_path + (".ids%d.fr" % fr_vocabulary_size) en_dev_ids_path = dev_path + (".ids%d.en" % en_vocabulary_size) data_to_token_ids(dev_path + ".fr", fr_dev_ids_path, fr_vocab_path, tokenizer) data_to_token_ids(dev_path + ".en", en_dev_ids_path, en_vocab_path, tokenizer) return (en_train_ids_path, fr_train_ids_path, en_dev_ids_path, fr_dev_ids_path, en_vocab_path, fr_vocab_path)
在data_utils库里,prepare_wmt_data整合了其他的helper函数,其中,get_wmt_enfr_train_set和get_wmt_enfr_dev_set两个函数测试了训练集的路径是否存在后,视情况下载训练集(如果训练集还未下载),之后便是解压了训练包中所用的具体集并提供其路径。虽然对于测试集有不同的应对方法,其逻辑大同小异,具体实现过程大家可以阅读这两个函数去一探究竟,这里将不做细说。
之后函数建立了两个不同的单词表,即英语单词表及法语单词表。在建立表的过程中,逻辑近似于之前讨论过的Word2Vec的create_dataset函数,即从海量数据里建立一个对应的字典来统计输入词。其中,值得注意的是这里有一个tokenizer输入的选项,默认的tokenizer是在符号处截断句子,其代码如下:
_WORD_SPLIT = re.compile(b"([.,!?\"':;)(])") def basic_tokenizer(sentence): """Very basic tokenizer: split the sentence into a list of tokens.""" words = [] for space_separated_fragment in sentence.strip().split(): words.extend(re.split(_WORD_SPLIT, space_separated_fragment)) return [w for w in words if w]
该项目鼓励读者们去采用更好的tokenizer去取得更好的结果。最后,不同于Word2Vec模型的最大地方在于我们将这个单词集保存后备用,也就是说,在第一次运算耗时可能很长后,之后在运行将会比较方便。
在建立完词典后,通过运用data_to_token_ids函数,我们可以将输入转化为数字序列并将其保存,这样可以方便我们系统的运用。其原理也可以在Word2Vec的demo代码里找到逻辑,即运用单词在词典里的位置来代替单词。具体内容请参考博客Python Tensorflow下的Word2Vec代码解释。所得的结果在保存在各自的文件中已备后用后,我们可以直接运用这些资料来训练我们的系统了。接下来,让我们重新回到translation.py文件的train()函数来继续了解它的机制。在得到了输入及训练资料后,我们看到了熟悉的with tf.Session() as sess,这里大家都明白怎么回事了吧,我们进入session了,可以开始建立模型并运行了。那么很明显,我们现在的任务就是建立模型。这里,我们看到了model = create_model(sess, False), 这个函数的具体内容就在train()函数之上,很好找。那么,它是怎么建立模型的呢?走进该函数后我们发现它其实就是个包装盒子,运用了系统的另一个文件,即seq2seq_model.py库里Seq2SeqModel类来得到模型,之后便是取得模型目前的状态。如果模型已经训练并保存,我们即呼唤之前训练的模型并返回。反之则初始化所有参数并返回模型。
现在,让我们来看看这个模型本身。在seq2seq_model.py文件里有三个函数:init函数,step函数及get_batch函数。目前,我们在制造模型阶段,所以先来看看这个init函数。
def __init__(self, source_vocab_size, target_vocab_size, buckets, size, num_layers, max_gradient_norm, batch_size, learning_rate, learning_rate_decay_factor, use_lstm=False, num_samples=512, forward_only=False):
这个函数的parameter列很长,有源语言和目标语言各自的单词数量,框架的层数,每层的神经元数,用于clipped的梯度的最大数值,训练batch的大小,learning rate,learning rate减少的比例及sampled softmax所接收的sample数量。这些都是常见的训练神经网络的参数。参数use_lstm也比较好理解,默认的false表明我们将会运用到GRU,即简易版本的lstm。设置为True时便是使用传统的LSTM cell了。现在,有两个参数我没讲解到,buckets参数和forward_only参数。这两个参数挺有意思。bucket参数的存在是针对机器翻译的,他的格式为一个充满(I,O)的list,I代表这最大输入长度,O代表着输出的最大长度。当输入或输出超出这个距离后,我们将超出的部分放入下一个batch。至于forward_only参数,其存在是因为两种不同的训练方式。一种方式为在训练中根据两种语言各自训练input及output,这是默认方式,即该参数设为Flase。如果我们设为True后,将会在训练output语言时运用output目标的开头后由输入语言取得剩下的数据。两种方法在官方的document里有详细讲解,这里附上链接供有兴趣的读者加深了解。
之后,在assign了变量后,我们又见到了熟悉的RNN模型框架,即
# If we use sampled softmax, we need an output projection. output_projection = None softmax_loss_function = None # Sampled softmax only makes sense if we sample less than vocabulary size. if num_samples > 0 and num_samples < self.target_vocab_size: w = tf.get_variable("proj_w", [size, self.target_vocab_size]) w_t = tf.transpose(w) b = tf.get_variable("proj_b", [self.target_vocab_size]) output_projection = (w, b) def sampled_loss(inputs, labels): labels = tf.reshape(labels, [-1, 1]) return tf.nn.sampled_softmax_loss(w_t, b, inputs, labels, num_samples, self.target_vocab_size) softmax_loss_function = sampled_loss # Create the internal multi-layer cell for our RNN. single_cell = tf.nn.rnn_cell.GRUCell(size) if use_lstm: single_cell = tf.nn.rnn_cell.BasicLSTMCell(size) cell = single_cell if num_layers > 1: cell = tf.nn.rnn_cell.MultiRNNCell([single_cell] * num_layers) # The seq2seq function: we use embedding for the input and attention. def seq2seq_f(encoder_inputs, decoder_inputs, do_decode): return tf.nn.seq2seq.embedding_attention_seq2seq( encoder_inputs, decoder_inputs, cell, num_encoder_symbols=source_vocab_size, num_decoder_symbols=target_vocab_size, embedding_size=size, output_projection=output_projection, feed_previous=do_decode)
这里,我们先了解到如果我们的参数num_samples大于0但小于目标单词量时,我们再次如同PTB模型那样运用一个projection layer来减少空间的占据。之后,我们默认celll类型是GRU,但是如果用户设定了用lstm,我们即更新single_cell变量到lstm cell框架。当网络层大于1时(默认为3层),我们的cell变成了一个多层RNN框架,由单层cell累积组成。这个逻辑已经在之前关于PTM模型的博客里有过介绍,如果你不熟悉这个设定,请看之前PTB模型的博客。在之后,我们发现设计了一个seq2seq_f的内部函数,这个函数为模型加入attention机制,之后我们会用到。
如同往常,我们为源语言,目标语言的训练输入以及目标权重建立placeholder,因为训练目标是输入目标的下一句,我们设定目标为目标语言输入的现在位置+1。之后便是建立训练输出及计算loss的时刻了。基本方法如下:
self.outputs, self.losses = tf.nn.seq2seq.model_with_buckets( self.encoder_inputs, self.decoder_inputs, targets, self.target_weights, buckets, lambda x, y: seq2seq_f(x, y, False), softmax_loss_function=softmax_loss_function)
我们将会运用Tensorflow库seq2seq里的model_with_buckets函数来完成。这个函数是什么呢?让我们来一探究竟。该函数的目标是建立一个bucket版本的seq2seq模型。模型取得encoder, decoder的输入,目标和权重的Tensor,输入和输出大小配对列表叫做buckets的参数后,要求一个sequence to sequence模型,softmax_loss_function函数(default是None),每例子的loss(default None)及名字(default None)。输出是(output, losses)tuple,output指的是每一个bucket的输出,losses这该bucket的loss数值。了解了这些后,我们发掘这里的sequence to sequence函数输入我们运用了lambda匿名函数,核心是我们的attention模型。这是我们训练的基础,但是当forward_only被设为True时,训练后,我们又多了一步,及重新编写buckets的output为揉合输出及之前projection的output。代码如下:
if output_projection is not None: for b in xrange(len(buckets)): self.outputs[b] = [ tf.matmul(output, output_projection[0]) + output_projection[1] for output in self.outputs[b] ]
之后便是传统的RNN训练方法,即运用GradientDecentOptimizer,并运用clip_by_global_norm及appy_gradient函数。这些知识点在之前的PTB模型里以详细介绍,这里将不再重复。
建立模型之后,在train()函数里,我们读入测试和训练的数据,计算训练的bucket并选择输入的句子属于哪个bucket (= [sum(train_bucket_sizes[:i + 1]) / train_total_size for i in xrange(len(train_bucket_sizes))]),之后便是具体训练的循环步骤。
步骤里,系统在运用seq2seq_model类里的get_batch和step两个函数,在取得了一个batch的数据后训练一个步骤的数据,并在一定的步骤后展出结果。代码的逻辑还是很清晰的,只是值得注意的是第205行的sys.stdout.flush()代码,这代码的存在是为了实时把运算结果展示在terminal的。除此之外,大家可以仔细阅读代码来加深了解,这里将不再细说。
就此,系统训练的步骤讲完了。但当我们准备测试我们的训练结果时,我们该怎么办呢?这里,我们就要讲讲这个decode()函数了。根据官方的说明,我们在训练模型后模型参数等全部资料全部都是有好好的保存的,所以我们不需要再次训练了,我们只需要运行“python translate.py –decode –data_dir /tmp/ –train_dir /tmp/”即可,一个interactive session将会打开供我们运作。
这个decode函数本身是用来测试系统的,所以在初始化英语法语的单词表后,系统读取我们输入的一行句子后,运行一下逻辑:
# 读取一行话 sentence = sys.stdin.readline() while sentence: # Get token-ids for the input sentence. # 把输入转换成token_ids token_ids = data_utils.sentence_to_token_ids(tf.compat.as_bytes(sentence), en_vocab) # Which bucket does it belong to? # 选择输入所对应的bucket大小 bucket_id = min([b for b in xrange(len(_buckets)) if _buckets[b][0] > len(token_ids)]) # Get a 1-element batch to feed the sentence to the model. # 取得一个一个element的batch并通过step函数来取得运行结果 encoder_inputs, decoder_inputs, target_weights = model.get_batch( {bucket_id: [(token_ids, [])]}, bucket_id) # Get output logits for the sentence. _, _, output_logits = model.step(sess, encoder_inputs, decoder_inputs, target_weights, bucket_id, True) # This is a greedy decoder - outputs are just argmaxes of output_logits. outputs = [int(np.argmax(logit, axis=1)) for logit in output_logits] # If there is an EOS symbol in outputs, cut them at that point. if data_utils.EOS_ID in outputs: outputs = outputs[:outputs.index(data_utils.EOS_ID)] # Print out French sentence corresponding to outputs. print(" ".join([tf.compat.as_str(rev_fr_vocab[output]) for output in outputs])) print("> ", end="") sys.stdout.flush() sentence = sys.stdin.readline()
通过这种方式,我们可以验证我们系统的好坏。可惜的是第一,我不懂法语。第二,系统在读取第1770000行句子时系统卡死了,差点废了我的硬盘,我没有得到测试结果,可能是模型过大我的电脑承受不起的缘故吧。对于这个错误我会研究一下,不过如果读者们有类似的情况并知道为何如此,请务必让我知道!谢谢大家!