tensorflow中seq2seq模組的應用
轉載自:Tensorflow中的Seq2Seq全家桶 - 王嶽王院長的文章 - 知乎 https://zhuanlan.zhihu.com/p/47929039
今天看了上面的這位作者的文章,感覺寫的非常清楚
引言
聽說以後公司那邊用 Tensorflow,最近就轉回 Tensorflow學習一下,發現很久以前 Tensorflow 把 seq2seq 的介面又重新升級了一下,也加了一些功能,變成了一個物美價廉的全家桶(tf.contrib.seq2seq)。所以來感受一下,順便做個記錄
除了最基本的 Seq2Seq 模型搭建之外,主要是對全家桶接口裡的 Teacher Forcing,Attention,Beam Search,Sequence Loss 這樣一些比較實用的配件(其實也不算配件,已經是現在 seq2seq 模型的基本要求了)做了一下研究,順手實踐了一下
另外,又在不使用 Tensorflow 提供的的 Seq2Seq 介面的情況下用手實現了一下這些功能,體會一下區別
原始碼
還是先上結論
tensorflow 所提供的這個 seq2seq 全家桶功能還是很強大,很多比如 Beam Search 這些實現起來需要彎彎繞繞寫一大段,很麻煩的事情,直接調個介面,一句話就能用,省時省力,很nice
優點就是封裝的很猛,簡單看一眼文件,沒有教程也能拿過來用。缺點就是封裝的太猛了,太傻瓜式了,特別是像 Attention 這類比較重要的東西,一封起來就看不到資料具體是怎麼流動的,會讓使用者失去很多對模型的理解力,可控性也減少了很多,比如我現在還沒發現怎麼輸出 attention score(。。[尷尬捂臉],如果有知道的請教我一下,感激不盡)
有得必有失,想要簡便快捷拿過來就用使用,不想花時間去學習原理再去一行行碼字,就要失去一些對模型的控制力和理解,正常。總的來說這個全家桶還是很好用,很強大,給了不熟練 Tensorflow 或不熟悉 seq2seq 的玩家一個 3 分鐘上手 30 分鐘上天的機會。但是使用的同時最好了解一下原理,畢竟如果真的把深度學習變成了簡單的調包遊戲,那這遊戲以後很難上分啊
上一句話寫給能看到的人,也寫給我自己
正文
0. 先說 Seq2Seq
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收斂速度以及最終結果都要好一些,放個對比圖
從這裡也可以看到訓練時使用 Teacher Forcing 可以提升訓練速度與質量,但是也會產生一些過擬合的副作用等等,這裡不多說了
2. Attention
相關的全家桶成員:AttentionWrapper,BahdanauAttention/LuongAttention
seq2seq 裡 attention 的作用就不詳細說了,直接放一個我看到過的最直觀的一個圖,圖片來源寫在圖注裡,侵刪。
簡單解釋一下。跟之前基礎 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
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 分鐘上天的機會。但是使用的同時最好了解一下原理,畢竟如果真的把深度學習變成了簡單的調包遊戲,那這遊戲以後很難上分啊
上一句話寫給能看到的人,也寫給我自己