原文鏈接:https://zhuanlan.zhihu.com/p/47929039
Seq2Seq 模型顧名思義,輸入一個序列,用一個 RNN (Encoder)編碼成一個向量 u,再用另一個 RNN (Decoder)解碼成一個序列輸出,且輸出序列的長度是可變的。用途很廣,機器翻譯,自動摘要,對話系統,還有上一篇文章里我用來做多跳問題的問答,只要是序列對序列的問題都能來搞,功能很強大,效果也不錯。
一個最基本的 seq2seq 代碼寫起來也很簡單,無論是用 Tensorflow 還是 Pytorch,比如:
import tensorflow as tf class Seq2seq(object): def __init__(self, config, w2i_target): self.seq_inputs = tf.placeholder(shape=(config.batch_size, None), dtype=tf.int32, name='seq_inputs') self.seq_inputs_length = tf.placeholder(shape=(config.batch_size,), dtype=tf.int32, name='seq_inputs_length') self.seq_targets = tf.placeholder(shape=(config.batch_size, None), dtype=tf.int32, name='seq_targets') self.seq_targets_length = tf.placeholder(shape=(config.batch_size,), dtype=tf.int32, name='seq_targets_length') with tf.variable_scope("encoder"): encoder_embedding = tf.Variable(tf.random_uniform([config.source_vocab_size, config.embedding_dim]), dtype=tf.float32, name='encoder_embedding') encoder_inputs_embedded = tf.nn.embedding_lookup(encoder_embedding, self.seq_inputs) encoder_cell = tf.nn.rnn_cell.GRUCell(config.hidden_dim) encoder_outputs, encoder_state = tf.nn.dynamic_rnn(cell=encoder_cell, inputs=encoder_inputs_embedded, sequence_length=self.seq_inputs_length, dtype=tf.float32, time_major=False) tokens_go = tf.ones([config.batch_size], dtype=tf.int32) * w2i_target["_GO"] decoder_inputs = tf.concat([tf.reshape(tokens_go,[-1,1]), self.seq_targets[:,:-1]], 1) with tf.variable_scope("decoder"): decoder_embedding = tf.Variable(tf.random_uniform([config.target_vocab_size, config.embedding_dim]), dtype=tf.float32, name='decoder_embedding') decoder_inputs_embedded = tf.nn.embedding_lookup(decoder_embedding, decoder_inputs) decoder_cell = tf.nn.rnn_cell.GRUCell(config.hidden_dim) decoder_outputs, decoder_state = tf.nn.dynamic_rnn(cell=decoder_cell, inputs=decoder_inputs_embedded, initial_state=encoder_state, sequence_length=self.seq_targets_length, dtype=tf.float32, time_major=False) decoder_logits = tf.layers.dense(decoder_outputs.rnn_output, config.target_vocab_size) self.out = tf.argmax(decoder_logits, 2)
這里就是定義兩個 RNN,都是直接准備好輸入序列用 dynamic_rnn 運行就可以,encoder rnn 的輸入就是模型輸入的單詞序列,decoder rnn 的輸入需要簡單制作一下,就是把期望輸出往后挪一下,然后前面加一個“_GO”的標記。就是如下圖的樣子。
模型寫完了,代碼很簡單,跑就完事了。
但是這只是個引子。正文這里才開始。
1. Teacher Forcing
相關的全家桶成員:TrainingHelper,GreedyEmbeddingHelper,BasicDecoder,dynamic_decode
上面的代碼雖然簡單粗暴,實際上已經使用了一種 Teacher Forcing 的策略。就是說在 decoder 階段,正常情況下某個時刻的輸入應該是上一時刻的輸出,但是使用了 Teacher Forcing,不管模型上一個時刻的實際輸出的是什么,哪怕輸出錯了,下一個時間片的輸入總是上一個時間片的期望輸出。把兩個套路的圖放一起就能看到區別
這樣做是好的,因為:
- 防止上一時刻的錯誤傳播到這一時刻,decode 出一個序列,要是第一個單詞錯了,整個序列就跑偏了,這個序列就沒啥意義了,計算 loss 更新參數作用都很小了。用了 Teacher Forcing 可以阻斷錯誤積累,斧正模型訓練,加快參數收斂(我自己試了一下,用和不用 Teacher Forcing,訓練時候的 loss 下降速度和最終結果真的差了不少)
- 這樣就可以提前把 decoder 的整個輸入序列提前准備好,直接放到 dynamic_rnn 函數就能出結果,實現起來簡單方便
但是,有個最大的問題:模型訓練好了,到了測試階段,你是不能用 Teacher Forcing 的,因為測試階段你是看不到期望的輸出序列的,所以必須乖乖等着上一時刻輸出一個單詞,下一時刻才能確定該輸入什么。不能提前把整個 decoder 的輸入序列准備好,也就不能用 dynamic_rnn 函數了
咋整?這時就必須用 raw_rnn 函數,手動補充 loop_fn 循環,手動去寫在 decoder rnn 的每一個時間片上,先把上一個時間片的輸出向量映射到詞表上,再找出概率最大的詞,再用 embedding 矩陣映射成向量成為這一時刻的輸入,還要判斷這個序列是否結束了,結束了還要拿“_PAD”作為輸入……,寫出來差不多是這個樣子:
(算了不放了,20多行太長了。總之很麻煩,感興趣可以去源代碼里的 model_seq2seq.py 看一下)
這時,全家桶可以非常輕松解決這個問題,放代碼:
tokens_go = tf.ones([config.batch_size], dtype=tf.int32) * w2i_target["_GO"] decoder_embedding = tf.Variable(tf.random_uniform([config.target_vocab_size, config.embedding_dim]), dtype=tf.float32, name='decoder_embedding') decoder_cell = tf.nn.rnn_cell.GRUCell(config.hidden_dim) if useTeacherForcing: decoder_inputs = tf.concat([tf.reshape(tokens_go,[-1,1]), self.seq_targets[:,:-1]], 1) helper =tf.contrib.seq2seq.TrainingHelper(tf.nn.embedding_lookup(decoder_embedding, decoder_inputs), self.seq_targets_length) else: helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(decoder_embedding, tokens_go, w2i_target["_EOS"]) decoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, helper, encoder_state, output_layer=tf.layers.Dense(config.target_vocab_size)) decoder_outputs, decoder_state, final_sequence_lengths = tf.contrib.seq2seq.dynamic_decode(decoder, maximum_iterations=tf.reduce_max(self.seq_targets_length))
這里就是用 helper 這個類來幫你自動地給 decoder rnn 的每個時刻提供不同的輸入內容,用或不用 Teacher Forcing 的區別只在於將 helper 定義為 TrainingHelper 或是 GreedyEmbeddingHelper。 且這兩種方式,從模型變量的角度看是沒有區別的,只是數據的流動方式不同,也就是說,在實際應用中,可以在 train 階段新建一個用 TrainingHelper 的模型,訓練完了保存模型參數,在 test 階段再新建另一個用 GreedyEmbeddingHelper 的模型,直接加載訓練好的參數就可以用
dynamic_decode 函數類似於 dynamic_rnn,幫你自動執行 rnn 的循環,返回完整的輸出序列
這樣,本來手打實現需要二三十行的功能,調接口10行左右就寫完了。另外還有一個神奇的地方,不知道是 tf.contrib.seq2seq 全家桶在實現的時候加了什么 trick,試驗了一下總是比我自己寫的seq2seq的loss收斂速度以及最終結果都要好一些,放個對比圖
2. Attention
相關的全家桶成員:AttentionWrapper,BahdanauAttention/LuongAttention
seq2seq 里 attention 的作用就不詳細說了,直接放一個我看到過的最直觀的一個圖,圖片來源寫在圖注里,侵刪。
圖片來源:CSDN上博主thriving_fcl的博客,https://blog.csdn.net/thriving_fcl/article/details/74853556
簡單解釋一下。跟之前基礎 seq2seq 模型的區別,就是給 decoder 多提供了一個輸入“c”。因為 encoder把很長的句子壓縮只成了一個小向量“u”,decoder在解碼的過程中沒准走到哪一步就把“u”中的信息忘了,所以在decoder 解碼序列的每一步中,都再把 encoder 的 outputs 拉過來讓它回憶回憶。但是輸入序列中每個單詞對 decoder 在不同時刻輸出單詞時的幫助作用不一樣,所以就需要提前計算一個 attention score 作為權重分配給每個單詞,再將這些單詞對應的 encoder output 帶權加在一起,就變成了此刻 decoder 的另一個輸入“c”
這個自己實現起來也挺簡單的,但是全家桶提供了更為簡單的使用方式,上代碼:
decoder_cell = tf.nn.rnn_cell.GRUCell(config.hidden_dim) if useAttention: attention_mechanism = tf.contrib.seq2seq.BahdanauAttention(num_units=config.hidden_dim, memory=encoder_outputs, memory_sequence_length=self.seq_inputs_length) # attention_mechanism = tf.contrib.seq2seq.LuongAttention(num_units=config.hidden_dim, memory=encoder_outputs, memory_sequence_length=self.seq_inputs_length) decoder_cell = tf.contrib.seq2seq.AttentionWrapper(decoder_cell, attention_mechanism) decoder_initial_state = decoder_cell.zero_state(batch_size=config.batch_size, dtype=tf.float32) decoder_initial_state = decoder_initial_state.clone(cell_state=encoder_state) decoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, helper, decoder_initial_state, output_layer=tf.layers.Dense(config.target_vocab_size)) decoder_outputs, decoder_state, final_sequence_lengths = tf.contrib.seq2seq.dynamic_decode(decoder, maximum_iterations=tf.reduce_max(self.seq_targets_length))
直觀上看就是把原來定義的最基礎 GRU 單元(decoder_cell)外面套一個 AttentionWrapper,直接替換原來的 decoder_cell 就好,只有兩個字,省事。全家桶提供了兩種可選 attention 策略:BahdanauAttention 和 LuongAttention,具體區別不細說了,主要是 attention score 怎么計算以及“c”怎么結合到輸入中的問題,實踐上效果差異基本不大
但是還是想說,太省事了,太傻瓜式了,個人不太喜歡這種過度封裝的感覺。畢竟 attention 其實是一個“聽上去很屌,不明覺厲”,做起來發現“哦,原來就是這么回個事,naive”,所以推薦自己寫 attention,其實照着上面那個圖梳理下數據流通過程,挺簡單的:
def attn(self, hidden, encoder_outputs): # hidden: B * D # encoder_outputs: B * S * D attn_weights = tf.matmul(encoder_outputs, tf.expand_dims(hidden, 2)) # attn_weights: B * S * 1 attn_weights = tf.nn.softmax(attn_weights, axis=1) context = tf.squeeze(tf.matmul(tf.transpose(encoder_outputs, [0,2,1]), attn_weights)) # context: B * D return context # …… input = tf.cond(finished, lambda: tokens_eos_embedded, get_next_input) if useAttention: input = tf.concat([input, self.attn(previous_state, encoder_outputs)], 1) # ……
3. Beam Search
相關的全家桶成員:tile_batch,BeamSearchDecoder
嗨呀這個可是太厲害了。感覺這個是全家桶里性價比最高的一個功能了
先說 Beam Search。這是個只在 test 階段有用的設定。之前基礎的 seq2seq 版本在輸出序列時,僅在每個時刻選擇概率 top 1 的單詞作為這個時刻的輸出單詞(相當於局部最優解),然后把這些詞串起來得到最終輸出序列。實際上就是貪心策略
但如果使用了 Beam Search,在每個時刻會選擇 top K 的單詞都作為這個時刻的輸出,逐一作為下一時刻的輸入參與下一時刻的預測,然后再從這 K*L(L為詞表大小)個結果中選 top K 作為下個時刻的輸出,以此類推。在最后一個時刻,選 top 1 作為最終輸出。實際上就是剪枝后的深搜策略
這個實現起來其實挺麻煩的,所以我在不用全家桶實現的那個 seq2seq 版本里也沒有實現這個功能
但是全家桶提供了一個非常省事的使用方式,放代碼:
tokens_go = tf.ones([config.batch_size], dtype=tf.int32) * w2i_target["_GO"] decoder_cell = tf.nn.rnn_cell.GRUCell(config.hidden_dim) if useBeamSearch > 1: decoder_initial_state = tf.contrib.seq2seq.tile_batch(encoder_state, multiplier=useBeamSearch) decoder = tf.contrib.seq2seq.BeamSearchDecoder(decoder_cell, decoder_embedding, tokens_go, w2i_target["_EOS"], decoder_initial_state , beam_width=useBeamSearch, output_layer=tf.layers.Dense(config.target_vocab_size)) else: decoder_initial_state = encoder_state decoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, helper, decoder_initial_state, output_layer=tf.layers.Dense(config.target_vocab_size)) decoder_outputs, decoder_state, final_sequence_lengths = tf.contrib.seq2seq.dynamic_decode(decoder, maximum_iterations=tf.reduce_max(self.seq_targets_length))
這回就是把 decoder 從 BasicDecoder 換成 BeamSearchDecoder 就完事了,這封裝的,流弊
因為使用了 Beam Search,所以 decoder 的輸入形狀需要做 K 倍的擴展,tile_batch 就是用來干這個。如果和之前的 AttentionWrapper 搭配使用的話,還需要把encoder_outputs 和 sequence_length 都用 tile_batch 做一下擴展,具體可以看代碼,不細說了
4. Sequence Loss
相關的全家桶成員:sequence_loss
這個其實是一個 seq2seq 訓練中不怎么值得一提但卻比較重要的一個地方。放個圖說。按照通常的 loss 計算方法,如圖假設 batch size=4,max_seq_len=4,需要分別計算這 4*4 個位置上的 loss。但是實際上“_PAD”上的 loss 計算是沒有用的,因為“_PAD”本身沒有意義,也不指望 decoder 去輸出這個字符,只是占位用的,計算 loss 反而帶來副作用,影響參數的優化
所以需要在 loss 上乘一個 mask 矩陣,這個矩陣可以把“_PAD”位置上的 loss 篩掉。其實有了這個 sequence_mask 矩陣之后(tensorflow 提供的函數 tf.sequence_mask 可以直接生成),直接乘在 loss 矩陣上就完事了。所以全家桶里這個 sequence_loss 實際上並沒有什么用處
還是放下代碼:
sequence_mask = tf.sequence_mask(self.seq_targets_length, dtype=tf.float32) self.loss = tf.contrib.seq2seq.sequence_loss(logits=decoder_logits, targets=self.seq_targets, weights=sequence_mask)
如果不用全家桶,寫出來差不多是這樣:
sequence_mask = tf.sequence_mask(self.seq_targets_length, dtype=tf.float32) loss = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=decoder_logits, labels=self.seq_targets) self.loss = tf.reduce_mean(loss * sequence_mask)
二者並沒有什么區別
最后再上一遍結論吧
tensorflow 所提供的這個 seq2seq 全家桶功能還是很強大,很多比如 Beam Search 這些實現起來需要彎彎繞繞寫一大段,很麻煩的事情,直接調個接口,一句話就能用,省時省力,很nice
優點就是封裝的很猛,簡單看一眼文檔,沒有教程也能拿過來用。缺點就是封裝的太猛了,太傻瓜式了,特別是像 Attention 這類比較重要的東西,一封起來就看不到數據具體是怎么流動的,會讓用戶失去很多對模型的理解力,可控性也減少了很多,比如我現在還沒發現怎么輸出 attention score(。。[尷尬捂臉],如果有知道的請教我一下,感激不盡)
有得必有失,想要簡便快捷拿過來就用使用,不想花時間去學習原理再去一行行碼字,就要失去一些對模型的控制力和理解,正常。總的來說這個全家桶還是很好用,很強大,給了不熟練 Tensorflow 或不熟悉 seq2seq 的玩家一個 3 分鍾上手 30 分鍾上天的機會。但是使用的同時最好了解一下原理,畢竟如果真的把深度學習變成了簡單的調包游戲,那這游戲以后很難上分啊
上一句話寫給能看到的人,也寫給我自己