說起「創作」這檔事,我一直相信是機器走向元學習的必經之路。相較於傳統的分類與回歸,無中生有所要跨越的門檻又更上一層,特別是要創作出繪畫、文章等結構性資料更是難上加難,不過相當有趣的是,這兩項生成式技術在 2014 年均有革命性的突破,對抗式生成網路 (GAN) 能在一輪輪的過招後生成以假亂真的圖片,而 Sequence to Sequence 則扮起了網路詩人,將水光山色拓印在短短幾行的小品之間。
Sequence to Sequence 是近幾年蓬勃發展的生成式模型 ,它精彩地解決了 RNN 無法處理不定長配對的困境,並於機器寫作、人機對話等主題上嶄露頭角,可以說 Sequence to Sequence 是個相當具啟發性的模型。現在,就讓我們從零開始,與 Sequence to Sequence 進行一場深度邂逅吧。
快速回顧 RNN
在深入 Sequence to Sequence 的細節之前,我想先和各位介紹一位老朋友 ── RNN (Recurrent neural network),就是它一手搭建起了最初的 Sequence to Sequence 模型。是故要走入 Sequence to Sequence 的前生今世,我們必須先從 RNN 出發。如果你與這位老友已相當熟識了,不妨直接跳到 Sequence to Sequence ,這對後續的教學並不會構成太大的影響。
言歸正傳,RNN 到底是個什麼玩意兒? 就讓我先從傳統的類神經網路切入吧。
傳統上,我們假設神經網路的每個輸入是相互獨立的,意即對於輸入 $I_i, I_j$ 而言,$I_i$ 取什麼值,並不會影響 $I_j$ 取什麼值,因為 $I_i$ 與 $I_j$ 一點關係也沒有。但這個假設有個很大的缺點,就是在處理序列 (Sequence) 時不太管用,因為序列內的元素長幼有序、先後有別,這種順序性導致了輸入間彼此相依,打個比方吧,股票走勢就是種典型的數值序列:
9:00 | 10:00 | 11:00 | 12:00 |
---|---|---|---|
123 | 128 | 132 | 136 |
如果有人問:「不知道下午一點是會漲還是會跌」,我們多半會回答:「當然會漲,因為九點在漲,十點在漲,十一點也在漲」。先姑且不論這麼武斷會不會讓我們賠錢,當我們想預測十二點的股市指數時,不是選擇隨手丟枚骰子,而是選擇參考以前的股市指數,就說明了十二點當下的指數與十二點前的股票指數其實是有相依性的。
再以文字序列舉個例子,比如說同樣都是用到了「不」、「歡」、「喜」這三個字,但「喜歡不?」是一個男孩切切於心的期盼,而「不喜歡。」則是女孩流水無情的漠然。這兩組序列有相同的構成,卻因順序,而讓彼此的結局殊如雲泥。
為了能將「順序」這個信息融入神經網路,RNN 就這麼誕生了,它的式子並不複雜:
$$
ht = \sigma(W_hx_t + U_hh_{t-1} + b_h)
$$
先理解一下每個符號的意思:
- $h_t$ : RNN 在第 $t$ 個時間點的輸出,如果對時間點這個字眼感到茫然,不妨理解為 RNN 讀到序列中第 $t$ 個元素時的輸出吧
- $x_t$ : RNN 在第 $t$ 個時間點的輸入
- $h_{t-1}$: RNN 在第 $t-1$ 個時間點的輸出
- $W_h, U_h, b_h$: 是 RNN 的參數,我們要微調的目標
- $\sigma$ : 神經元的激活函數
你會發現 RNN 其實是兩個單層網路 $W_hx_t$ 和 $U_hh_{t-1}$ 的串接,只是這兩個網路的用途不太一樣,從物理意義上來看:
- $W_h$ 是在控制當前時間點的輸入 $x_t$ 如何影響當前時間點的輸出 $h_t$
- $U_h$ 則是控制前一個時間點的輸出 $h_{t-1}$ 如何影響對當前時間點的輸出 $h_t$
我們能自這兩者間窺探出 RNN 的核心精神,對於一組序列 $x_1,x_2,…..,x_{n-1},x_{n}$
- 在第 $k$ 個時間點時,我們有 $h_{k-1}$ 保留了 $x_1,x_2,…..,x_{k-1}$ 的資訊,佐以當前時間點的輸入 $x_k$ 得出了 $h_k$。
- 現在我們到了第 $k+1$ 個時間點,有 $h_k$ 保留了 $x_1,x_2,…..,x_k$ 的資訊,佐以當前時間點的輸入 $x_{k+1}$ 得出 $h_{t+1}$。
- 如此反覆迭代,直到走至第 $n$ 個時間點便結束。
以一個比較打嘴砲的說法,我們能將 $h_{k}$ 當成記憶,目前的記憶是由以前的記憶 $h_{t-1}$ 和目前的輸入 $x_t$ 摻雜而成,至於迭代就是在傳遞模型記憶,這個過程能用簡單的虛擬碼形象化:
1 | x_inputs = [...] |
我們也能把 RNN 的結構畫成一張圖,橫向的箭頭便是建構出順序的關鍵。
此外,根據上面這張圖,我們能將 RNN 的輸入與輸出關係更細分幾種情形。
假設我們給 RNN 餵進一個長度 N
的序列 x1, x2, ..., xn
,RNN 會吐出與長度等長的序列 y1, y2, ..., yn
,但對於吐出的序列 y1 ~ yn
,我們真的會用到所有的輸出嗎 ? 答案應該是不一定,對不同的應用,我們採用的輸出個數也會不同。
如果把全部的輸出都拿來用,便是很典型的序列標注問題,比如說我們想標注一句話裡每個詞的詞性或實體,就會希望每一個輸入都能被標上一個 tag:
如果只把部分輸出拿來用,就會比較像是個分類在做的事。我們通常會取用最後一個時間的輸出,用於對整個序列做出總結,比如想去識別對話的意圖或情緒,我們通常都必須要看完整句話再做論斷。
理解上述兩種情形後,我們也看出了 RNN 存在著一個侷限性,即是輸出的長度會受限於輸入的長度,我們甚至能夠斷言:「對於輸入長度為 N
的序列而言,是沒有辦法吐出長度超過 N
的輸出的。」不過,這又會造成什麼困擾呢?
想像一下,今天老闆突發奇想,立志開發出一套中英翻譯系統,企劃書上洋洋灑灑地寫滿了RNN、Deep learning、Neural machine translation 等看起來超有深度的關鍵字,並附上了一組簡單的翻譯範例:
英文 | 中文 |
---|---|
An apple a day keeps the doctor away | 一天一蘋果, 醫生遠離我 |
你眉頭一皺,發覺這範例並不單純:「英文有 8 個字,而中文,不多不少是 10 個字!」,猶記小學的數學老師曾經諄諄教誨過 8 < 10,再配上剛剛得出的結論,你便瀟灑地在企劃書的封面撇下「不可能」三個大字,然後就進入了 Bad End。
Sequence to Sequence
當然,故事不會這麼簡單就結束。為了解決這個困境,我們的主角 Sequence to Sequence 終於姍姍來遲了。
Sequence to Sequence 是由 Encoder 與 Decoder 兩個 RNN 構成,它的運作原理其實與人類的思維很相似,當我們看到一段話時,會先將這句話理解吸收,再根據我們理解的內容說出回覆,Sequence to Sequence 就是在模擬這個過程。
還記得我們剛剛提到 RNN 能用於總結一個序列嗎? Encoder 就是負責將輸入序列消化、吸收成一個向量,我們通常把這個向量稱為 context vector,顧名思義,這個向量會囊括原序列的重要訊息。
而 Decoder 則是根據 context vector 來生成文字,不過回到剛剛的困境,我們只有 1 個輸入,要怎麼生成出超過 1 個輸出呢?其實並不難,只要把目前的輸出當成之後的輸入就成了,就像這樣:
1 | while True: |
由於我們總是將前個輸出當成後個輸入,就算等到花兒謝了,這個 while
也永遠不會結束。所以我們得設置一個終止信號 EOS
(End Of Sentence),告知 Decoder 到此為止就好:
1 | while output != 'EOS': |
現在我們有能力處理不定長的輸入與輸出配對了,讓我們引入一點點數學,談談 Sequence to Sequence 的目標函數吧。
對於輸入序列 $x_1,…,x_T$ 與輸出序列 $y_1,…,y_{T’}$ 而言,透過 Encoder 我們能將 $x_1,…,x_T$ 轉換成 context vector $v$,我們希望能在 Decode 階段最大化條件機率 $p$:
最外圍的連乘符號表示想求的是全局最優解,而內部的機率項則指的是對時間點 $T$ 而言,模型知道已經聽了什麼($v$, context vector),以及之前說了些什麼($y_1,…,y_{T-1}$),並以這兩件事為基準,來評估現在該說什麼 ($y_T$)。
總歸而言, Sequence to Sequence 的精華所在便是串接了兩個 RNN ,第一個 RNN 負責將長度為 M
的序列給壓成 1
個向量,第二個 RNN 則根據這 1
個向量產生出 N
個輸出,這 M -> 1
與 1 -> N
相輔相成下就構建出了 M to N
的模型,能夠處理任何不定長的輸入與輸出序列,好比說:
- 輸入一句英文,輸出一句法文,就寫好了一個翻譯系統
- 輸入一個問題,輸出一句回覆,就架好一個聊天機器人
- 輸入一篇文章,輸出一份總結,就構成一個摘要系統
- 輸入幾個關鍵字,輸出一首短詩,就成就了一名詩人
甚至,我們能把眼光放得更遠些,Encoder 與 Decoder 不一定都只有 RNN,可以讓 CNN 與 RNN 一起當 Encoder,負責將影像編碼成向量,而 Decoder 根據這個向量來生成描述,這樣就能讓機器描述圖片中發生了什麼:
雖然內容已經有點跑題了,但對於 Encoder 與 Decoder 這一搭一唱的行為,我們都將其視為 Encoder Decoder Framework 。隨著輸入與輸出的類型不同,就能組合出不同風貌的應用,我想這便是 Sequence to Sequence 的迷人之處。
Show me the code
就算談了再多的理論,工程師最後還是要靠實作來分出勝負,接下來的教學將以 PyTorch 為主,所有程式碼都已經放在Github上了。
畢竟我們是從零開始,就別弄什麼太天馬行空的東西,先從設計一個序列自編碼器 (Sequence Autoencoder) 入門吧,他的目標很簡單,就是希望輸出序列能和輸入序列愈像愈好:
我們的訓練語料來自 Google-10000-English.txt,顧名思義,這是一萬個英文常用單字 (雖然不知為什麼裡面只有 9914 個就是了)。既然要做自編碼器,我們就是要讓輸入的單字跟輸出的單字愈像愈好:
Input | Output | Prediction |
---|---|---|
hello | hello | hello |
world | world | world |
關於資料的預處理與向量化已經寫在 DataHelper.py ,可以先運行看看,了解一下資料集和每個 batch 的構成:
1 | # Output of DataHelper.py |
這是字典與索引的轉換,我另外加了四種輔助字元:
SOS
: Decoder 的啟動信號EOS
: Decoder 的終止信號PAD
: 因為每個 batch 的單字長度要一致,所以我們要用 PAD 來填充過短的單字UNK
: 如果輸入字元沒在字典裡出現過,就用 UNK 的索引替代它
1 | Sequence before transformed: helloworld |
這是個簡單的小示例,說明能透過字典將單字轉為成一組索引序列,並能正確無誤地還原回來。
1 | B0-0, Inputs |
這則是我們每個 batch 會取出的 Tensor,第一個矩陣是輸入,第二個矩陣是輸出因為我們要訓練的是字編碼器,所以輸入跟輸出是長的一模一樣的。
這裡以 batch_size=3
的輸入為例,為了符合 PyTorch RNN 的預設形式, Tensor 的 shape 會是 (time_steps, batch_size)
,也就是說 24 6 10 6 4 12 22 1
是一個單字,15 9 25 12 10 24 1 2
是一個單字,23 18 15 20 12 22 1 2
是一個單字。
而 [8, 7, 7]
則表示每個單字原本的長度,還記得嗎?我們將 PAD
設定為 2 ,除了第一個單字外,另外兩個單字的結尾都被補上了 2
,代表它們的原始長度其實都是 7
。
現在對資料的預處理有些掌握了,讓我們正式切入模型的細節。我將 Sequence to Sequence 切割為 Encoder.py
、Decoder.py
、Seq2Seq.py
三個子模組。
Encoder
1 | # model/Encoder.py |
Encoder 的工作相當簡潔,便是將一組序列編碼成一個向量,我們將選用 RNN 在最後一個時間點的輸出 hidden
來做為我們的 context vector。
Decoder
1 | # model/Decoder.py |
相較於 Encoder, Decoder 的結構就複雜許多,不過別擔心,讓我們一步步來觀察 Tensor 的流動。
先聚焦在 forward
,它的輸入是先前 Encoder 產生出的 context vector 以及標準答案 targets
。我們先幫 Decoder 準備第一個時間點的輸入,其由一組 SOS (Start of sentence) 構成,告訴 Deocder 是時候上工了:
1 | decoder_input = Variable(torch.LongTensor([[self.sos_id] * batch_size])) |
我們還要傳遞 Encoder 壓縮好的 context vector,這樣 Decoder 才能繼承 Encoder 的意志:
1 | decoder_hidden = context_vector |
緊接著,模型重點的重點來了,我們將 RNN 沿著時間軸展開,透過 forward_step
對每個時間點的 decoder_input
進行解碼,並將解碼的結果儲存在 decoder_outputs[t]
裡:
1 | use_teacher_forcing = True if random.random() > self.teacher_forcing_ratio else False |
forward_step()
的構成其實跟 Encoder 相當類似,只有額外加上一個線性輸出層 out
,用來預測當前時間點的輸出字母:
1 | def forward_step(self, inputs, hidden): |
Teacher forcing
等等,Decoder
裡怎麼有個從沒見過的 use_teacher_forcing
,莫急莫慌莫害怕,這只是個訓練的小技巧。因為 Seq2Seq
會把前一個時間點的輸出當成後一個時間點的輸入,如果我們在前個時間點做了錯誤的結論,那往後所有的時間點都會受到這個錯誤影響,這個連鎖反應會讓訓練容易擺盪不定。
於是我們就想到了,對於用於生成式的 RNN,如果他在第 $t$ 個時間搞錯了,沒關係,我們派個老師把錯誤的答案給偷偷糾正成對的,儘管學生記憶中仍覺得之前說的是對的,但在看到了正確的輸入後,至少還有浪子回頭的可能。
不過,學生最終還是得踏出校園,自己探求人生的答案。同樣的,在測試階段,我們並沒有正確答案,模型必須試著自立自強可惜這往往會遇到些困難,因為在訓練時被老師過度保護了,輸出與輸入間的遞歸性並沒被妥善地訓練到。
既然這兩種都各有優劣,那不妨就取個中間值吧,時而讓老師介入,避免在訓練時震盪頻繁,時而讓學生自己學習,提高模型測試時的泛化性,讓訓練和測試時都能有所改進,Teacher forcing 就是這麼回事。
Seq2Seq
有了 Encoder 跟 Decoder 後,接下來讓我們把這兩個東西給串起來,Seq2Seq
就完成了。
1 | # model/Seq2Seq.py |
訓練模型
1 | # train.py |
基本上就是遵循 PyTorch 的老規則,為避免內容太過複雜,就先把驗證放一邊。訓練中比較值得一提的可能是計算誤差的部份,我們以 NLLLoss(ignore_index=self.PAD_ID)
作為損失函數, 其中 ignore_index=self.PAD_ID
意思是不將 PAD
列為誤差,畢竟 PAD
本來就不屬於文本的一部分。
誤差的計算主要是在 get_loss
,decoder_outputs
shape 為 (batch_size, time_steps, class_number)
,targets
shape 為 (batch_size, time_steps)
,我們分別將 targets
與 decoder_outputs
給攤平,以方面一口氣計算整個 batch 的誤差:
1 | def get_loss(self, decoder_outputs, targets): |
試著跑跑看 python train.py
,會輸出一個訓練好的自編碼器模型 auto_encoder.pt
,我們能運行 python eval.py
評估一下這個模型的效能:
1 | You say: python |
大功告成…?
恭喜你,完成了第一個序列自編碼器,雖然測試上有些誤差,但大抵上的輸出都還是不錯的。
讓我們來面對現實吧,目前的模型其實有一個很大的缺陷,那就是我們把一個不限長的輸入,給編碼成一個固定長度的向量,換句話說,隨著我們的輸入越來越長,context vector 的訊息損失就會越大。這方法顯然不太科學,就連我們在聽課時,都不免聽了後面就忘了前面,又怎麼能要求一個向量記住所有資訊呢?
一個比較合乎邏輯的改進是應該這樣的,當我們在輸出時,能夠回想起當時聽到了什麼,或者說,當模型輸出某件事時,只會專注於與這件事相關的輸入上,這很像在學習一種配對關係,比如想要把序列 How are you
翻譯成 你 好 嗎
,當我們在翻譯 你
時,其實最該關注的是輸入中的 You
,而不是全局的 context vector。這技巧可是有個赫赫有名的稱號 ── Attention(注意力機制),至於細節該怎麼實現,就於下次的連載再和各位分享,敬請期待囉。