这个月断断续续自己写了一个seq2seq代码,我也不确定这是否可以算是框架,就姑且定义为框架了。为什么要自己写呢,因为我觉得现有代码组织都写得太复杂,明明是很简单的原理,这样不利于实验室同门入门。我一共写了3700行代码,包括额外的numpy版本的sequencing_np (1000行)和tests (800行)。吸收各个大牛的实现(blocks,seq2seq)的优点,个人认为我的实现已经非常清晰简单,贯彻整个项目的准则是“keep simple, stay na(t)ive.”
我以英中机器翻译为实例来介绍什么是sequence to sequence learning以及如何使用sequencing来训练自己的模型。可以参考Github的代码。为方便大家上手,我也准备了预处理过的数据,见百度云。这份数据包含14590228条平行句对,来源于UN数据集。有兴趣的同学可以根据这些数据训练自己的英文到中文的机器翻译模型。
数据准备
数据是必不可少的,我们可以自己爬,比如有道啊,句酷啊各种有双语例句的网站。大约500万就可以开始训练了。然后需要准备单词表,也就是需要把各个单词映射到一个整数id,否则接下去没法搞。对于英文,我们可以使用空格分隔的单词或者流行的BPE。为了简单起见,中文端我们就不分词了,直接以字作为单位,如“我、们、是、中、国、人”。这样词表小,效率高,也不怎么影响翻译效果。这些都准备完毕之后,只要产生平行句对来训练就行了。当然得考虑补零、效率等问题,这些我都写好了,详见build_inputs.py。只需调用如下,
1 2 3 4 5 6 7 8 9 10 11 | # load vocab src_vocab = build_vocab(src_vocab_file, src_embedding_dim, ' ') trg_vocab = build_vocab(trg_vocab_file, trg_embedding_dim, '') # load parallel data parallel_data_generator = \ build_parallel_inputs(src_vocab, trg_vocab, src_data_file, trg_data_file, batch_size=batch_size, buffer_size=96, mode=MODE.TRAIN) |
编码器
目前只实现了双向RNN编码器,将来会实现CNN和self-attention编码器。编码器是很简单的,见下图。

首先,我们需要通过代表单词的整数index将单词表示为向量,这都是通过一个Lookup Table(Embedding Table)实现的。说白了,就是把一个M×N的矩阵的第index行取出来,M为总的单词个数(可以试试32K),N为向量的大小,512足够了。
1 2 3 4 | source_embedding_table = sq.LookUpOp(src_vocab.vocab_size, src_vocab.embedding_dim, name='source') source_embedded = source_embedding_table(source_ids) |
接着,就是通过双向RNN编码这些向量,来形成表示单词在句子中的意思。多层的编码器性能当然更好,训练也更久。
1 2 3 | encoder = sq.StackBidirectionalRNNEncoder(encoder_params, name='stack_rnn', mode=mode) encoded_representation = encoder.encode(source_embedded, source_seq_length) |
注意力机制
注意力机制是机器翻译必不可少的,可以说就是注意力机制的引入导致了机器翻译的变革。更难得的是注意力机制非常简单,具体来说就是给定查询query(通常是decoder RNN的输出),来匹配编码器的输出。写成代码如下,
1 2 3 4 | energies = v * tanh(keys + query) # 把query与各个编码器的输出向量相加,然后tanh后都乘v。这是加性attention,还有乘性attention。 scores = softmax(energies) # 归一化,注意数值稳定 # 分数代表编码器输出的各个key的权重,乘了之后加起来就行了 context = sum(scores * values) |
当然,这个代码是不精确的,得考虑维度和batch。我们可以根据context翻译下一个词。比如翻译“I love summer”时,“我喜欢”已经翻译出来了,query就是当前解码器的状态,与编码器的输出比对,context就是表示“summer”的向量,据此很容易就能翻译出“夏天”。在sequencing里,可以很方便地调用,如下。
1 2 3 4 | attention_keys = encoded_representation.attention_keys attention_values = encoded_representation.attention_values attention_length = encoded_representation.attention_length attention = sq.Attention(query_size, attention_keys, attention_values, attention_length) |
解码器
解码器可以算是最复杂的。因为要考虑反馈,还要使用注意力机制。不过,隐藏细节后很简单,如下,
1 2 3 4 5 | feedback = sq.TrainingFeedBack(target_ids, target_seq_length, trg_vocab, teacher_rate) # decoder decoder = sq.AttentionRNNDecoder(decoder_params, attention, feedback, mode=mode) |
具体实现不展开,可以见源代码。我也简单地画了一个图。feedback就是要把之前翻译的词反馈给解码器(feedback也包含一个Lookup Table,需要把词表示为向量,这里没有画出来),这里的解码器是一个RNN,起到语言模型的作用。

训练
光有模型不行,得有loss才行。如上图所示,每反馈一个单词,就会输出一个新的单词,直到输出结尾符。因此,第一个反馈的是开始符(BOS),然后可以产生logits(可以理解为概率),将logits与目标输出(“我”)对比得出loss。cross_entroy是最常用的loss。接着反馈“我”,输出logits与“喜”对比,得出下一个loss,以此类推,直至结束。这个过程,我们以dynamic_decode表示,这类似于seq2seq,但被我简化了,去掉了复杂的操作。
1 2 | decoder_output, decoder_final_state = sq.dynamic_decode(decoder, scope='decoder') |
就像上面介绍的,我们定义loss的方式如下
1 2 3 4 5 | predict_ids = target_ids losses = cross_entropy_sequence_loss( logits=decoder_output.logits, targets=predict_ids, sequence_length=target_seq_length) |
有了loss就简单了,选个优化方法进行优化就行了。最常用的是Adam,也可以使用SGD。
推断
Greedy推断和Beam推断都行。不得不提batched beam飞快,60s解码2000句。详见代码,因为不重要,所以这里不展开。
性能
按默认设定(1层1024的编码器,1层1024的解码器), 经测试,每天能训练3千万平行句对。飞快!结果也不错。batched beam更快,60s解码2000句,同样的训练集和参数,效率和效果貌似比seq2seq好一点,有条件的同学可以自行验证。
只依赖numpy的版本,Sequencing_np
这个厉害了,很有意思。我写了一个相互对应的版本,实现了LSTM等基本单元,也实现了整个sequencing的模型,但只依赖numpy。既可以作为测试,也非常有趣。但不能训练是肯定的,我们需要把训练好的模型传给sequencing_np。原理都是一样的,优点是我们可以print!debug方便很多,更装b的是,可以直接在Android进行翻译!我真是机智。

写得比较仓促,不清楚的地方还请留言指出。
近期评论