本文討論並實現了用於序列模型的基本深度方法,其中迴圈網路主要介紹了傳統的 LSTM 與 GRU,而卷積網路主要介紹了最近 CMU 研究者提出的時間卷積網路與實證研究。相比於我們熟知的經典迴圈網路方法,用 CNN 實現序列建模可能會更有意思,因此本文的實現部分重點介紹了時間卷積網路的實現。
這是機器之心 GitHub 實現專案的第四期,前面幾期分別介紹了卷積神經網路、生成對抗網路與帶動態路由的 CapsNet。
機器之心專案地址:https://github.com/jiqizhixin/ML-Tutorial-Experiment
文章結構:
序列建模
迴圈網路
表示式
計算圖
LSTM
GRU
卷積與時間卷積網路
全卷積與因果卷積
空洞卷積
高速公路網路與殘差模組
實現
LSTM的語言建模
時間卷積網路的語言建模
序列建模廣泛存在於自然語言處理、語音識別和計算機視覺等領域,這種任務通常需要將輸入序列轉換為輸出序列,例如將輸入的英文語句轉換為輸出的中文語句。從實踐經驗上來說,一般我們都將迴圈神經網路視為序列建模的預設配置。甚至 Ian Goodfellow 在《深度學習》一書中使用「序列建模:迴圈和遞迴網路」作為章節名,這些都表明序列建模與迴圈架構有非常緊密的聯絡。
因此本文在前一部分主要介紹了迴圈網路的概念、表示式和計算圖,並著重描述了 LSTM 與 GRU 兩種流行的變體。在實現部分,我們將基於 TensorFlow 在語言建模任務中實現 LSTM 與 GRU。
但序列建模不僅僅只有 RNN,一個關鍵的想法是在一維時間序列上使用一維卷積運算。此外,最近的研究表明一些卷積架構能在音訊合成、語言建模和機器翻譯任務中達到頂尖的準確度。因此,我們是否能找到像 LSTM 那樣的一般架構處理時序問題就顯得十分重要。
為了瞭解這種用於序列建模的卷積網路,我們將解讀最近 Shaojie Bai 等人完成的架構與實驗,包括構建時間卷積網路的因果卷積、空洞卷積和殘差連線等。後面我們同樣會根據他們提出的 TCN 測試語言建模任務,並儘量保證引數數量和 LSTM 與 GRU 處於同一量級。
序列建模
序列建模即將一個輸入或觀測序列對映到一個輸出或標記序列,李航在《統計學習方法》中也將其稱為標註問題。他表明標註問題是分類問題的推廣,又是更復雜的結構預測問題的簡單形式。標註問題或序列建模的目的在於學習一個模型,從而能對觀測序列給出標記序列作為預測,即最大化概率 P(y_1,y_2,...,y_n | x_1,x_2,...,x_n)。在傳統機器學習方法中,序列建模常用的方法有隱馬爾可夫模型和條件隨機場等,但近來 RNN 等深度方法憑藉強大的表徵能力在序列建模問題上有非常突出的表現。
但根據 Shaojie Bai 等人的定義,序列建模應該是在給定時間步 t 的情況下,只使用 x_0 到 x_t 的序列資訊預測輸出 y_t。當然這只是一般化的定義,具體問題還需要具體分析,例如機器翻譯最好可以使用雙向 RNN 獲取整個句子的資訊再轉化為譯文。因此一般序列建模的形式化表述如下:
序列建模為任意滿足因果約束的對映函式 f: X^T+1 → Y^T +1,它僅依賴於 x_1,x_2,...,x_t 而不使用 x_t,x_t+1,...,x_T 的資訊預測 y_t。
迴圈網路
迴圈神經網路是一類用於處理序列問題的神經網路,迴圈網路可以擴充套件到更長的序列。迴圈網路相比經典的全連線網路有非常大的提升,例如引數共享和構建長期依賴關係等。對於語句的序列建模,全連線網路會給每個輸入特徵分配一個單獨的引數,所以它需要分別學習句子每個位置的所有語言規則。而迴圈神經網路會在多個時間步內共享相同的引數,因此不必學習句子每個位置的所有語言規則。此外,迴圈網路會有一個記憶機制為當前時間步的預測提供前面時間步的資訊。
表示式
其實迴圈神經網路的基本原理可使用非常優美的表示式展示,若考慮動態系統的經典形式:
它其實也可以視為一個迴圈神經網路,因為本質上任何涉及迴圈的函式都可以視為一個迴圈神經網路。以上 s^t 可視為系統在第 t 步的狀態,因此後一步的系統狀態會取決於前一步的系統狀態。我們注意到每一個系統狀態的計算都會使用相同的函式與引數,這樣迴圈地向後計算就能構建一個迴圈系統。如下第三個時間步的系統狀態可以表示為:
這樣的表示式其實就展示了迴圈網路的本質。若我們具體考慮迴圈網路每一個時間步都存在輸入,且使用變數 h 表示迴圈網路的隱藏狀態(代替上述系統的狀態),那麼我們可以將一般的迴圈神經網路抽象為以下表示式:
其中 x^t 表示第 t 個時間步上的輸入,當前時間步的隱藏狀態取決於前一時間步的隱藏狀態、當前時間步和所有時間步都有相同的引數θ。我們同樣可以將該表示式展開,例如 h^3 = f(h^2, x^3; θ) = f(f(h^1, x^2; θ), x^3; θ)。
該 RNN 的抽象表示式也說明了它只會利用過去時間步的資訊來預測當前的狀態。此外,迴圈神經網路連續使用相同的函式 f 與引數θ來計算不同時間步的狀態,這種方式在多個時間步上共享了相同的引數而降低了模型規模。
迴圈神經網路與全連線網路的區別可以很直觀地從抽象表示式中看出來,因為有無權重共享機制是它們最重要的屬性。最基本的全連線網路可以抽象為一個簡單的複合函式,因為每一層全連線網路其實都可以看作一個函式逼近器。
以下展示了三層全連線網路的抽象表示,其中 f^1 表示第一層或輸入層,將第一層的值作為輸入並計算第二層的啟用值 f^2,然後將第二層的啟用值作為輸入計算第三層的啟用值。這種複合函式展示了全連線網路的前饋傳播過程,而將複合函式的鏈式求導法則作為反向傳播演算法也就顯得十分自然。
根據上面的全連線表示式,我們清楚地瞭解到迴圈網路複合的函式都是一樣的,而全連線網路複合的函式是不一樣的,這也是迴圈體權重共享的特點。當然我們描述迴圈網路的表示式只是迴圈體的抽象,典型的迴圈網路會增加額外的架構特性,例如讀取狀態資訊 h 進行預測的輸出層或導師驅動過程等。而很多迴圈網路的修正都集中在改進迴圈體以關注長期依賴關係,例如 LSTM 和 GRU 等。
以上只是從概念上解釋迴圈網路,我們並沒有具體學習迴圈網路的架構與模組,下一部分我們將以計算圖的形式具體展示迴圈網路的結構,包括常見的展開式與不同的變體架構等。
計算圖
上一節的抽象表示式展示了迴圈體的本質,而它們可以直觀地用計算圖表示出來。計算圖是形式化一組計算結構的方式,一般情況下,我們看到的迴圈網路展開結構都是這種計算圖,這一章節展示的計算圖參考自《深度學習》。例如上一節中隱藏狀態 h^t = f(h^t-1, x^t; θ) 的計算圖可以表示為:
該計算圖展示了一個不帶輸出單元的迴圈網路架構,它只使用前一個時間步的隱藏單元資訊和當前時間步的輸入資訊,並利用相同的函式計算下一個隱藏單元的值。此外,上圖從左到右分別為迴圈圖和展開圖,迴圈圖非常簡潔,但展開圖詳細描述了計算過程與資訊流動路徑。
上述的計算圖其實只描述了迴圈體,它缺少了輸出對映與輸出單元。一般迴圈神經網路根據輸出單元和迴圈結構可以分為三種,即 Elman Network 類、Jordan Network 類和 N 到 1 的網路。以下分別是這三種網路的計算圖,它們基本上構成了迴圈神經網路架構(雙向 RNN 可以是它們的反向疊加)。
Elman Network 代表了一類迴圈網路,它的每一個時間步都有一個輸入與輸出,且迴圈連線發生在隱藏單元與隱藏單元之間。我們通過累積每一個預測 y hat 與 y 之間的誤差來確定損失函式 L,並執行沿時間的反向傳播訓練整個網路。
以上展示了這種迴圈連線發生在隱藏層之間的網路,其中 x 和 y 分別代表資料點與對應的標註,h 為隱藏單元或迴圈體,L 是預測值與標註值之間的距離與損失。一般在第 t 個時間步,我們會輸入資料 x^t,並計算隱藏狀態 h^t = tanh(W*h^{t-1}+U*x^t),隨後 h^t 將傳入下一個時間步與當前時間步輸出的對數概率 o^t = c + V*h^t。最後我們就能根據輸出與標註值計算模型損失。
這種網路架構是非常經典的結構,我們可以將隱藏單元視為記憶的累積,即將過去的資訊傳遞到未來時間步。這種架構也非常容易擴充套件到深度架構,例如我們在 h 和 o 之間再加一個迴圈單元 h' 或在迴圈體中額外新增全連線結構等。如下展示了上述迴圈架構的計算式:
其中在第 t 個時間步上的輸入 x^t 可以是一個詞嵌入向量或簡單地使用 One-hot 編碼。一般權重矩陣 U 的維度為 [詞嵌入長度 * 隱藏層的單元數],偏置向量 b 的維度等於隱藏層單元數。隱藏層輸出的向量(每一個元素為隱藏單元的啟用值),我們在將隱藏層向量執行仿射變換後,可將 o 視為未歸一化的對數概率,並計算 softmax 以和標註的詞嵌入向量進行對比。
Jordan Network 代表了一類迴圈網路,它的每一個時間步都有一個輸入與輸出,但迴圈連線只存在當前時間步的輸出和下一個時間步的隱藏單元之間。因為該架構在隱藏單元之間沒有迴圈連線,因此它沒有一個記憶機制來捕捉所有用於預測未來時間步的歷史資訊。
這種架構雖然在能力上並沒有那麼強大,但它的優勢在於訓練過程中的解耦合與並行過程。因為既然我們使用 o^t 作為傳遞到後一步的資訊,那麼為什麼我們就不能使用標註 y^t 替換 o^t 而作為傳遞到後面的資訊呢?通過使用 y^t 替換 o^t,網路不再需要先計算前一時間步的隱藏狀態,再計算後一步的隱藏狀態,因此所有計算都能並行化。
Jordan Network 類的架構在推斷時還是會使用前一時間步的輸出值 o 來計算後一時間步的隱藏狀態。這種網路的一大缺點是,訓練過程中觀察到的資料與測試時看到的資料會有較大的不同。
最後一種架構會先讀取整個序列,然後再產生單個輸出,迴圈連線存在於隱藏單元之間。這種架構常用於閱讀理解等序列模型。
這種架構只在最後一個隱藏單元輸出觀察值並給出預測,它可以概括序列併產生用於進一步運算的向量,例如在編碼器解碼器架構中,它可用於編碼整個序列並抽取上下文向量。
以上是迴圈神經網路抽象概念與基本的架構表示,它們非常有助於我們理解「迴圈」這個概念。但在實際建模中,RNN 經常出現梯度爆炸或梯度消失等問題,因此我們一般使用長短期記憶單元或門控迴圈單元代替基本的 RNN 迴圈體。它們引入了門控機制以遺忘或保留特定的資訊而加強模型對長期依賴關係的捕捉,它們同時也大大緩解了梯度爆炸或梯度消失的問題。
下面我們將簡要介紹這兩種非常流行的 RNN 變體,它們同樣希望生成通過時間的路徑,且導數既不會消失也不會爆炸。
LSTM
如前所示,迴圈網路的每一個隱藏層都有多個迴圈單元,隱藏層 h^t-1 的向量儲存了所有該層神經元在 t-1 步的啟用值。一般標準的迴圈網路會將該向量通過一個仿射變換並新增到下一層的輸入中,即 W*h^{t-1}+U*x^t。而這個簡單的計算過程由於重複使用 W 和 U 而會造成梯度爆炸或梯度消失。因此我們可以使用門控機制控制前一時間步隱藏層保留的資訊和當前時間步輸入的資訊,並選擇性地輸出一些值而作為該單元的啟用值。
一般而言,我們可以使用長短期記憶單元代替原版迴圈網路中的隱藏層單元而構建門控迴圈神經網路。以下兩張圖分別介紹了 LSTM 的基本概念和詳細的計算過程。
以下是 LSTM 單元的簡要結構,其中 Z 為輸入部分,Z_i、Z_o 和 Z_f 分別為控制三個門的值,即它們會通過啟用函式 f 對輸入資訊進行篩選。一般啟用函式可以選擇為 Sigmoid 函式,因為它的輸出值為 0 到 1,即表示這三個門被開啟的程度。
圖片來源於李弘毅機器學習講義。
若我們輸入 Z,那麼該輸入向量通過啟用函式得到的 g(Z) 和輸入門 f(Z_i ) 的乘積 g(Z)f(Z_i ) 就表示輸入資料經篩選後所保留的資訊。Z_f 控制的遺忘門將控制以前記憶的資訊到底需要保留多少,保留的記憶可以用方程 c*f(z_f)表示。以前保留的資訊加上當前輸入有意義的資訊將會保留至下一個 LSTM 單元,即我們可以用 c' = g(Z)f(Z_i) + cf(z_f) 表示更新的記憶,更新的記憶 c' 也表示前面與當前所保留的全部有用資訊。我們再取這一更新記憶的啟用值 h(c') 作為可能的輸出,一般可以選擇 tanh 啟用函式。最後剩下的就是由 Z_o 所控制的輸出門,它決定當前記憶所啟用的輸出到底哪些是有用的。因此最終 LSTM 的輸出就可以表示為 a = h(c')f(Z_o)。
上圖非常形象地展示了 LSTM 單元的工作原理,我們修改了《深度學習》一書中的結構圖,以更詳細地解釋該單元的計算過程。
上圖詳細描述了 LSTM 單元的計算過程,其中 x^t 表示第 t 個時間步的輸入向量,一般可以是詞嵌入向量。h^t-1 為上一個時間步隱藏單元的輸出向量,該向量的元素個數等於該層神經元或 LSTM 單元的數量。U 和 W 分別是輸入資料和前一時間步隱藏單元輸出值的權重矩陣,一個 LSTM 單元因為不同的門控與輸入,需要 8 個不同的權重矩陣。此外,s^t 為第 t 個時間步的內部狀態或記憶,它會記住所有對於預測相關的資訊。最後,b 代表了各個門控和輸入的偏置項。
首先我們會向輸入門、遺忘門和輸出門饋送當前時間步的輸入 x^t 與前一步的隱藏單元 h^t-1,在對它們進行線性變換後,利用 Sigmoid 函式壓縮到區間(0, 1)以作為門控。這三個門控的計算式如上圖所示分別為 g^t、f^t 和 q^t,其中 i 表示該層級中的第 i 個 LSTM 單元。
我們將輸入與輸入門對應元素相乘,這就代表了當前時間步需要新增到記憶 s^t 的資訊。而前一時間步的記憶 s^t-1 與遺忘門 f^t 對應元素相乘就表示了需要保留或遺忘的歷史資訊是多少,最後將這兩部分的資訊相加在一起就更新了記憶 s^t,這一過程見上圖 s^t 的計算式。最後我們將記憶 s^t 的啟用值與輸出門 q^t 對應元素相乘,就能計算出當前時間步的 LSTM 單元輸出值,這一計算過程如上圖 h^t 所示。
Goodfellow 表示記憶 s^t-1 也可以用作門控單元的額外輸入(如上圖所示),但一般 LSTM 的門控單元只使用前一時間步的輸出 h^t-1 作為輸入,因此我們也不太確定怎樣才能使用 s^t-1 作為門控單元的額外輸入。
GRU
GRU 背後的原理與 LSTM 非常相似,即用門控機制控制輸入、記憶等資訊而在當前時間步做出預測。GRU 有兩個有兩個門,即一個重置門(reset gate)和一個更新門(update gate)。這兩個門控機制的特殊之處在於,它們能夠儲存長期序列中的資訊,且不會隨時間而清除或因為與預測不相關而移除。
從直觀上來說,重置門決定了如何將新的輸入資訊與前面的記憶相結合,更新門定義了前面記憶儲存到當前時間步的量。如果我們將重置門設定為 1,更新門設定為 0,那麼我們將再次獲得標準 RNN 模型。使用門控機制學習長期依賴關係的基本思想和 LSTM 一致,但還是有一些關鍵區別:
GRU 有兩個門(重置門與更新門),而 LSTM 有三個門(輸入門、遺忘門和輸出門)。
GRU 並不會控制並保留內部記憶(c_t),且沒有 LSTM 中的輸出門。
LSTM 中的輸入與遺忘門對應於 GRU 的更新門,重置門直接作用於前面的隱藏狀態。
在 Kyunghyun Cho 等人第一次提出 GRU 的論文中,他們用下圖展示了門控迴圈單元的結構:
上圖的更新 z 將選擇隱藏狀態 h 是否更新為新的 h tilde。重置門 r 將決定前面的隱藏狀態是否需要遺忘。下面我們將具體解釋這兩個門控與隱藏狀態。
以下將描述第 j 個隱藏單元啟用值的計算方式。首先重置門 r_j 的計算式可以表示為:
其中 σ 為 Sigmoid 函式,[*]_j 向量中的第 j 個元素,x 和 h_t-1 分別為當前輸入和前面層級的隱藏狀態,W_r 和 U_r 分別為更新門的權重矩陣。這個門控將當前輸入與前面隱藏狀態分別執行一個線性變換,再將結果壓縮至 0 到 1 以決定到底有多少過去的資訊需要遺忘。同樣,更新門的計算式可以表示為:
更新門控制了前面時間步的記憶資訊和當前時間步所記的資訊,並傳遞到當前時間步最終記憶的資訊,這一點在以下兩個計算式中有非常明確的展示。
首先我們需要確定當前時間步需要記憶的資訊,即前面隱藏層的資訊到底需要保留多少以作為這一步的記憶。如下所示重置門 r 通過 Hadamard 乘積確定需要遺忘的歷史資訊,如果門控 r 為 0,那麼該時間步記憶的內容就僅從輸入獲取,如果門控 r 為 1,那麼就將利用所有的歷史資訊作為該時間步的記憶。注意我們將 h tilde 理解為該時間步的記憶,如果我們將它和前面時間步的記憶 h_t-1 組合,那麼就能得出當前時間步的最終記憶。
其中 Φ 為啟用函式,一般我們可以選擇 tanh。在計算 h tilde 後,我們可以根據下式組合它與前面時間步的隱藏狀態,而最終得到當前時間步下該單元的啟用值或隱藏狀態:
上式將使用更新門 z 權衡前面時間步的記憶和這一時間步的記憶,並得出當前時間步的最終記憶或啟用值。
因此,重置門其實強制隱藏狀態遺忘一些歷史資訊,並利用當前輸入的資訊。這可以令隱藏狀態遺忘任何在未來發現與預測不相關的資訊,同時也允許構建更加緊緻的表徵。而更新門將控制前面隱藏狀態的資訊有多少會傳遞到當前隱藏狀態,這與 LSTM 網路中的記憶單元非常相似,它可以幫助 RNN 記住長期資訊。
由於每個單元都有獨立的重置門與更新門,每個隱藏單元將學習不同尺度上的依賴關係。那些學習捕捉短期依賴關係的單元將趨向於啟用重置門,而那些捕獲長期依賴關係的單元將常常啟用更新門。
卷積與時間卷積網路
卷積神經網路,即至少在一層上使用卷積運算來代替一般的矩陣乘法運算的神經網路,一般我們認為卷積網路擅長處理「網格結構的資料」,例如影象就是二維的畫素網格。但其實時序資料同樣可以認為是在時間軸上有規律地取樣而形成的一維網格,根據 Shaojie Bai 等人的實驗結果,一般的時間卷積網路甚至比 LSTM 或 GRU 有更好的效能。
卷積的基本概念其實已經有非常多的入門教程,因此這裡只簡要說明一般的卷積運算與一維卷積。在卷積運算中,卷積核會在輸入影象上滑動以計算出對應的特徵圖。卷積層試圖將神經網路中的每一小塊進行更加深入的分析,從而得出抽象程度更高的特徵。一般來說通過卷積層處理的神經元結點矩陣會變得更深,即神經元的組織在第三個維度上會增加。
一般來說,卷積運算主要通過稀疏權重、引數共享和平移等變性等特性加強了機器學習系統。稀疏權重即卷積核大小會遠小於輸入影象的大小,這允許卷積網路儲存更少的引數和使用更少的計算而實現高效的效能。引數共享也是非常優秀的屬性,因為我們假設資料擁有區域性結構,那麼只需要在小範圍神經元中使用不同的引數,而大範圍內的神經元可共享引數。最後的平移不變性也建立在引數共享的基礎上,它可以直觀理解為若移動輸入中物件,那麼輸出中的表示也會移動同樣的量。
以下展示了簡單的一維卷積,適用於序列建模的卷積網路一般就是採用的這種架構。從一維卷積的連線方式可以清晰地瞭解權重共享的方式,圖中每個卷積層使用了一個大小為 3 的卷積核,即 k1、k2 和 k3 和 f1、f2 和 f3。下層每一個神經元只會和上層神經元部分連線,例如 h_3 只能由下層的區域性神經元 x_2、x_3 和 x_4 計算得出。
在序列建模任務中,最下層的 x 可視為句子等輸入序列,最上層的 g 可視為輸出序列,中間的 h 即隱藏層。當然,這種一維卷積並沒有限制為只能檢視當前時間步以及之前資訊的因果卷積。越上層的神經元擁有越廣感受野,因此高層的卷積單元將有能力構建長期依賴關係。如上所示,g_3 可以觀察到輸入序列的所有資訊。
一維卷積從直觀上確實能實現序列建模,但我們經常使用的還是迴圈網路,尤其是 LSTM 或 GRU。不過在論文 An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling 中,作者表明他們所提出的時間卷積網路可作為一般的序列建模框架,且擁有非常好的效果。本文後面將介紹這種網路,並在 PTB 資料集上分別使用 RNN 與 TCN 構建語言模型。
時間卷積也是從一般的卷積運算中延伸得出,下面簡要介紹了卷積序列預測的一般架構。我們的目標是將卷積網路的最佳實踐經驗精煉為一個簡單的架構,它能便捷地處理時序建模問題。這種時間卷積網路(TCN)的顯著的特點有如下幾點,首先架構中的卷積存在因果關係,這意味著從未來到過去不會存在資訊「洩漏」。其次卷積架構可以將任意長度的序列對映到固定長度的序列。除此之外,TCN 還強調利用殘差模組和空洞卷積來構建長期依賴關係。
TCN 論文圖 1:TCN 架構的組成元素。(a)為空洞係數 d=1, 2, 4、卷積核大小 k=3 的空洞因果卷積,感受野能覆蓋輸入序列中的所有值。(b)為 TCN 殘差塊,當殘差輸入和輸出有不同的維度,我們會新增一個 1x1 的卷積。(c)為 TCN 中的殘差連線示例,其中藍線為殘差函式中的卷積核,綠線為恆等對映。
全卷積與因果卷積
為了使用卷積運算處理時序資料,TCN 結合了一維全卷積與因果卷積兩種結構。通過使用一維全卷積網路,TCN 可以產生和輸入序列等長的輸出序列,且每一個隱藏層通過使用 Padding 可以保持和輸出層等長。而通過使用因果卷積,TCN 可以保證前面時間步的預測不會使用未來的資訊,因為時間步 t 的輸出只會根據 t-1 及之前時間步上的卷積運算得出。因此總的來說時間卷積網路簡單地組合一維全卷積和因果卷積而轉化為適合序列資料的模型。
全卷積網路最開始在論文 Fully Convolutional Networks for Semantic Segmentation(2015)中提出,它將傳統卷積神經網路最後幾個全連線層替換為卷積層。一般卷積網路會使用全連線層將特徵圖對映為固定長度的向量,且每一個元素代表一個類別。這種結構相當於將卷積抽取的高階特徵實現線性組合而最終預測類別,但它的侷限性體現在只能對整張影象或整段序列做分類處理。
因此引入全卷積的意義在於它能實現密集型的預測,即在二維卷積下對影象實現畫素級的分類,在一維卷積下對序列實現元素級的預測。此外,由於低層的卷積運算感受野較小,對於特徵的位置變化不敏感,而高層的卷積網路感受野非常大,對特徵的變化也非常敏感。因此 TCN 用一維卷積替代最後幾個全連線層有助於感受整個輸入序列的資訊,這對於構建長期記憶非常有幫助。以下展示了帶全連線層的卷積網路和全卷積網路的區別:
如上所示,全卷積網路將預測類別概率(上)轉化為畫素級的預測(下)。
因果卷積首次是在 WaveNet(van den Oord et al., 2016)論文中提出,從直觀上來說,它類似於將卷積運算「劈」去一半,令其只能對過去時間步的輸入進行運算。對於 TCN 所使用的一維卷積來說,因果卷積可以簡單將一般卷積的輸出移動幾個時間步而實現。在訓練過程中,所有過去時間步的卷積預測可以並行化,因為它們的輸入和標註真值都是已知的,所以這相對於迴圈網路在訓練上有非常大的優勢。因果卷積的結構將結合空洞卷積一起展示。
空洞卷積(Dilated Convolutions)
因果卷積其實還有一個問題,它需要非常多的層級數或較大的卷積核來擴寬感受野,而較大的感受野正式構建長期記憶所必須的。因此,如果我們不希望通過前面兩種會增加計算量的方法擴充套件感受野,那我們就需要使用空洞卷積(或稱擴張卷積)增加數個量級的感受野。
空洞卷積最大的特性就是擴張感受野,它不是在畫素間插入空白畫素,而是略過一些已有的畫素。當然,我們也可以理解為保持輸入不變,並向卷積核中新增一些值為零的權重,從而在計算量基本不變的情況下增加網路觀察到的影象範圍或序列長度。此外,如果我們將一般卷積運算的步幅增大,那同樣也能起到增加感受野的效果,但卷積步幅大於 1 就會起到降取樣的效果,輸出的序列長度會減小。如下展示了因果卷積結合空洞卷積的效果:
如上所示,一維卷積的卷積核大小為 2,第一層使用的 dilation 為 1,即常規的卷積運算。而後面層級的空洞大小依次加大,常規卷積只能從右到左觀察到 5 個輸入資料,而空洞卷積可以觀察到所有 16 個輸入資料。
形式上,對於 1 維的輸入序列 x ∈ R^n 和卷積核 f : {0, . . . , k − 1} → R,空洞卷積運算 F 可以定義為:
其中 d 為擴張係數、k 為卷積核大小,s − d · i 計算了採用上層哪一個單元。擴張係數控制了每兩個卷積核間會插入多少零值,當 d=1 時,空洞卷積就會退化為一般的卷積運算。使用較大的擴張係數允許輸出端的神經元表徵更大範圍的輸入序列,因此能有效擴張感受野。
一般在使用空洞卷積時,我們將隨著網路深度 i 的增加而指數級地增大 d,即 d=O(2^i)。這確保了卷積核在有效歷史資訊中覆蓋了所有的輸入,同樣也確保了使用深度網路能產生極其長的有效歷史資訊。
高速公路網路與殘差連線
殘差網路在計算機視覺中有非常強大的表達能力,它因為解決了深層網路的訓練問題而可以大大增加網路的層數。但要理解殘差網路與殘差連線,我們需要先理解高速公路網路(Highway Networks)。
高速公路網路受到 LSTM 的啟發,它通過門控令資訊在多個神經網路層級中可以高效流動,從而能使用傳統基於梯度的方法快速訓練深度網路。一般而言,若每一層的卷積運算可以用隱藏函式 H 表示,那麼給定該層的輸入 x 與權重矩陣 W_H,輸出可以表示為 y = H(x, W_H)。在高速公路網路中,傳入後一層的資訊不僅是當前層的計算結果,同時還包含了前面層級的計算結果。高速公路網路會使用門控機制控制每一層向後傳遞的資訊:
其中 H(x, W_H) 表示當前層傳統卷積運算的結果,而非線性函式 T(x, W_T) 表示轉換門,它控制了當前層的卷積運算結果對當前層輸出的貢獻大小。C(x,W_C) 表示攜帶門,它控制了當前層的輸入資訊最終不經過計算直接傳到輸出端的大小。高速公路網路一般採用 1-T(x, W_T) 代替 C(x,W_C) 而減少門控的數量,且門控通過 Sigmoid 函式實現。
由於增加了復原輸入資訊的可能性,模型會更加靈活,且當 T=1 而 C=0 時,高速公路網路就退化為了常規的卷積網路。而殘差網路與殘差連線正是這種架構的特例,如果我們令上式的 T 和 C 都等於 1,那麼它就代表了一個殘差模組,即 y = H(x, W_H) + x。因為我們要學的是卷積核的權重 W_H,因此經過簡單的變形可得 H(x, W_H) = y-x。由此可知,我們實際需要學習的函式 H 是由殘差項 y-x 而得出,這也就是我們稱之為殘差網路的原因。
上圖為原論文中的殘差塊結構,其中 F(x) 和前面 H(x, W_H) 表示相同的過程。殘差塊的輸出結合了輸入資訊與內部卷積運算的輸出資訊,這種殘差連線或恆等對映表示深層模型至少不能低於淺層網路的準確度。
原論文展示了實踐中的兩種殘差塊,下圖左邊是一種採用堆疊兩個 3×3 的卷積運算方法,它在深層網路中表現並不是很好。右邊為一種瓶頸殘差網路,第一個 1×1 的卷積可以視為對輸入進行降維處理,因此中間的 3×3 卷積層將有更少的計算量,而後面的 1×1 卷積可以升維或恢復所有的資訊。瓶頸殘差網路有更高的計算效率,因此在非常深的網路中能大量減小計算量。
由於 TCN 的感受野取決於網路深度 n、卷積核大小 k 和空洞卷積中的擴張係數 d,因此更深的 TCN 有更強的穩定性要求。例如在預測依賴於 2^12 歷史時間步和高維輸入空間下,網路需要達到 12 層。且每一層需要多個卷積核執行特徵抽取,在 TCN 論文作者設計的模型中,它使用了殘差模組來加深卷積網路。
在 TCN 的殘差模組內,有兩層空洞卷積和 ReLU 非線性函式,且卷積核的權重都經過了權重歸一化。此外,TCN 在殘差模組內的每個空洞卷積後都新增了 Dropout 以實現正則化。
然而在標準的 ResNet 中,輸入可以直接加上殘差函式的輸出向量。而在 TCN 中,輸入與輸出有不同的維度,因此我們需要使用額外的 1×1 卷積來確保 F(x) 與 x 間對應畫素相加有相同的維度。
然而,在標準 ResNet 中,輸入直接新增到殘餘函式的輸出中,在 TCN 中(通常是 ConvNets),輸入和輸出可以有不同的寬度。為了解決輸入輸出寬度的差異,我們使用額外的 1x1 卷積來確保元素相加⊕接收相同形狀的張量。
最後,時間卷積網路即結合了一維因果卷積和空洞卷積作為標準卷積層,而每兩個這樣的卷積層與恆等對映可以封裝為一個殘差模組。這樣由殘差模組堆疊起一個深度網路,並在最後幾層使用卷積層代替全連線層而構建完整的全卷積網路。
實現
這一部分簡單地實現了 LSTM 網路與 TCN 模型,我們在 PTB 資料集上使用這兩種結構構建了語言模型。本文在這裡只會簡要地分析這兩個語言模型的核心程式碼,完整的實現可檢視機器之心的 GitHub 專案地址。
基於 LSTM 的語言模型使用 TensorFlow 實現,它使用兩層 LSTM 網路,且每層有 200 個隱藏單元。我們在訓練中截斷的輸入序列長度為 32,且使用 Dropout 和梯度截斷等方法控制模型的過擬合與梯度爆炸等問題。我們在簡單地訓練 3 個 Epoch 後,測試複雜度(Perplexity)降低到了 179。
基於 TCN 的語言模型使用 PyTorch 實現,且模型修改自原論文作者 Shaojie Bai 等人的 GitHub 實現。該模型使用論文中介紹的因果卷積與空洞卷積,並採用殘差連線的結構完成構建。
這兩個模型實現的都是語言模型,即給定一句話的前面詞預測下一個詞,因此也可以視為計算語句的出現概率。衡量一個語言模型好壞的方法一般可以用複雜度(Perplexity),它刻畫了估計下一句話出現的概率。複雜度的概念其實就是平均分支系數,即模型預測下一個詞是的平均可選擇數量。我們實現的兩個模型並不能成為嚴格的效能對比,只能幫助讀者瞭解它們的實現過程。但至少,我們可以發現 TCN 確實有能匹敵 LSTM 的效能。
LSTM 語言建模
使用 LSTM 的語言建模非常簡單,現在也有非常多的教程,因此我們也不重點介紹它的實現。以下是使用 LSTM 構建語言模型的部分程式碼,它定義了整個 LSTM 網路的架構。此外,該模型的資料讀取、超引數、驗證與測試過程請檢視 GitHub,我們也給出了必要的程式碼註釋。
# 通過ptbmodel 的類描述模型
class PTBModel(object):
def __init__(self, is_training, batch_size, num_steps):
# 記錄使用的Batch大小和截斷長度
self.batch_size = batch_size
self.num_steps = num_steps
# 定義輸入層,維度為批量大小×截斷長度
self.input_data = tf.placeholder(tf.int32, [batch_size, num_steps])
# 定義預期輸出
self.targets = tf.placeholder(tf.int32, [batch_size, num_steps])
# 定義使用LSTM結構為迴圈體,帶Dropout的深度RNN
lstm_cell = tf.nn.rnn_cell.BasicLSTMCell(hidden_size)
if is_training:
lstm_cell = tf.nn.rnn_cell.DropoutWrapper(lstm_cell, output_keep_prob=keep_prob)
cell = tf.nn.rnn_cell.MultiRNNCell([lstm_cell] * num_layers)
# 初始化狀態為0
self.initial_state = cell.zero_state(batch_size, tf.float32)
# 將單詞ID轉換為單詞向量,embedding的維度為vocab_size*hidden_size
embedding = tf.get_variable('embedding', [vocab_size, hidden_size])
# 將一個批量內的單詞ID轉化為詞向量,轉化後的輸入維度為批量大小×截斷長度×隱藏單元數
inputs = tf.nn.embedding_lookup(embedding, self.input_data)
# 只在訓練時使用Dropout
if is_training: inputs = tf.nn.dropout(inputs, keep_prob)
# 定義輸出列表,這裡先將不同時刻LSTM的輸出收集起來,再通過全連線層得到最終輸出
outputs = []
# state 儲存不同批量中LSTM的狀態,初始為0
state = self.initial_state
with tf.variable_scope('RNN'):
for time_step in range(num_steps):
if time_step > 0: tf.get_variable_scope().reuse_variables()
# 從輸入資料獲取當前時間步的輸入與前一時間步的狀態,並傳入LSTM結構
cell_output, state = cell(inputs[:, time_step, :], state)
# 將當前輸出加入輸出佇列
outputs.append(cell_output)
# 將輸出佇列展開成[batch,hidden*num_step]的形狀,再reshape為[batch*num_step, hidden]
output = tf.reshape(tf.concat(outputs, 1), [-1, hidden_size])
# 將LSTM的輸出傳入全連線層以生成最後的預測結果。最後結果在每時刻上都是長度為vocab_size的張量
# 且經過softmax層後表示下一個位置不同詞的概率
weight = tf.get_variable('weight', [hidden_size, vocab_size])
bias = tf.get_variable('bias', [vocab_size])
logits = tf.matmul(output, weight) + bias
# 定義交叉熵損失函式,一個序列的交叉熵之和
loss = tf.contrib.legacy_seq2seq.sequence_loss_by_example(
[logits], # 預測的結果
[tf.reshape(self.targets, [-1])], # 期望正確的結果,這裡將[batch_size, num_steps]壓縮為一維張量
[tf.ones([batch_size * num_steps], dtype=tf.float32)]) # 損失的權重,所有為1表明不同批量和時刻的重要程度一樣
# 計算每個批量的平均損失
self.cost = tf.reduce_sum(loss) / batch_size
self.final_state = state
# 只在訓練模型時定義反向傳播操作
if not is_training: return
trainable_variable = tf.trainable_variables()
# 控制梯度爆炸問題
grads, _ = tf.clip_by_global_norm(tf.gradients(self.cost, trainable_variable), max_grad_norm)
optimizer = tf.train.GradientDescentOptimizer(learning_rate)
# 定義訓練步驟
self.train_op = optimizer.apply_gradients(zip(grads, trainable_variable))
如上所示,我們首先需要定義輸入與輸出的維度佔位符,其中 num_steps 表示截斷的輸入序列長度,也就是輸入句子的長度。然後定義單個層級的 LSTM 網路,這裡定義的隱藏單元數是 200。此外,定義的 LSTM 迴圈體在訓練過程中還要加一個 Dropout 層以實現正則化和類似整合方法的效果。將這樣的 LSTM 層級堆疊在一起就構建成了多層迴圈神經網路,這也是非常簡單的。
定義輸入後,按時間步來讀取輸入序列中的中的詞向量,並將前一時間步的隱藏狀態同時傳入 LSTM 單元,以得到當前時間步的預測和隱藏狀態。最後將迴圈體的輸出結果傳入一般的全連線層就能完成最終的詞預測,這裡會常規地使用 Softmax 函式歸一化預測不同詞的概率。當然,後面還需要定義損失函式和梯度截斷等方法,這裡需要將輸入語句所有詞的誤差都累積起來,且計算一個批量內(多條語句)的平均損失作為最終的損失。
TCN 語言建模
這一部分的實現主要採用 TCN 原論文的官方實現,我們修改了一些內容以在 Notebook 上直接執行。本文主要介紹了構建 TCN 整體架構的程式碼和整體模型的結構,更多如評估過和訓練等過程請檢視機器之心的 GitHub 專案。
- 機器之心專案地址:https://github.com/jiqizhixin/ML-Tutorial-Experiment
- 原論文實現地址:https://github.com/locuslab/TCN
原論文 tcn.py 檔案中實現了 TCN 的殘差模組與整體網路架構,以下將依次解釋該網路的各個模組。
import torch
import torch.nn as nn
from torch.nn.utils import weight_norm
#定義實現因果卷積的類(繼承自類nn.Module),其中super(Chomp1d, self).__init__()表示對繼承自父類的屬性進行初始化。
class Chomp1d(nn.Module):
def __init__(self, chomp_size):
super(Chomp1d, self).__init__()
self.chomp_size = chomp_size
# 通過增加Padding的方式並對卷積後的張量做切片而實現因果卷積
# tensor.contiguous()會返回有連續記憶體的相同張量
def forward(self, x):
return x[:, :, :-self.chomp_size].contiguous()
如上所示,首先類 Chomp1d 定義了通過 Padding 實現因果卷積的方法。其中 chomp_size 等於 padding=(kernel_size-1) * dilation_size,x 為一般一維空洞卷積後的結果。張量 x 的第一維是批量大小,第二維是通道數量而第三維就是序列長度。如果我們刪除卷積後的倒數 padding 個啟用值,就相當於將卷積輸出向左移動 padding 個位置而實現因果卷積。
以下實現了 TCN 中的殘差模組,它由兩個空洞卷積和恆等對映(或一個逐元素的卷積)組成,並使用 torch.nn.Sequential 簡單地將這些卷積層和 Dropout 等運算結合在一起。
首先 TemporalBlock 類會定義第一個空洞卷積層,dilation 控制了擴充套件係數,即在卷積核權重值之間需要新增多少零。卷積後的結果呼叫上面定義的 Chomp1d 類實現因果卷積。然後再依次新增 ReLU 非線性啟用函式和訓練中的 dropout 正則化方法,得出啟用值後可作為輸入傳入相同結構的第二個卷積層。
因為殘差模組可以表示為 y = H(x, W_H) + x,所以將這兩個卷積結果再加上恆等對映 f(x)=x 就能完成殘差模組。
# 定義殘差塊,即兩個一維卷積與恆等對映
class TemporalBlock(nn.Module):
def __init__(self, n_inputs, n_outputs, kernel_size, stride, dilation, padding, dropout=0.2):
super(TemporalBlock, self).__init__()
#定義第一個空洞卷積層
self.conv1 = weight_norm(nn.Conv1d(n_inputs, n_outputs, kernel_size,
stride=stride, padding=padding, dilation=dilation))
# 根據第一個卷積層的輸出與padding大小實現因果卷積
self.chomp1 = Chomp1d(padding)
#新增啟用函式與dropout正則化方法完成第一個卷積
self.relu1 = nn.ReLU()
self.dropout1 = nn.Dropout2d(dropout)
#堆疊同樣結構的第二個卷積層
self.conv2 = weight_norm(nn.Conv1d(n_outputs, n_outputs, kernel_size,
stride=stride, padding=padding, dilation=dilation))
self.chomp2 = Chomp1d(padding)
self.relu2 = nn.ReLU()
self.dropout2 = nn.Dropout2d(dropout)
# 將卷積模組的所有組建通過Sequential方法依次堆疊在一起
self.net = nn.Sequential(self.conv1, self.chomp1, self.relu1, self.dropout1,
self.conv2, self.chomp2, self.relu2, self.dropout2)
# padding保證了輸入序列與輸出序列的長度相等,但卷積前的通道數與卷積後的通道數不一定一樣。
# 如果通道數不一樣,那麼需要對輸入x做一個逐元素的一維卷積以使得它的緯度與前面兩個卷積相等。
self.downsample = nn.Conv1d(n_inputs, n_outputs, 1) if n_inputs != n_outputs else None
self.relu = nn.ReLU()
self.init_weights()
# 初始化為從均值為0,標準差為0.01的正態分佈中取樣的隨機值
def init_weights(self):
self.conv1.weight.data.normal_(0, 0.01)
self.conv2.weight.data.normal_(0, 0.01)
if self.downsample is not None:
self.downsample.weight.data.normal_(0, 0.01)
# 結合卷積與輸入的恆等對映(或輸入的逐元素卷積),並投入ReLU 啟用函式完成殘差模組
def forward(self, x):
out = self.net(x)
res = x if self.downsample is None else self.downsample(x)
return self.relu(out + res)
但 TCN 的殘差模組還有一個需要注意的地方,即它有可能會對 x 執行一個逐元素的卷積而不是直接新增 x。這主要是因為卷積結果的通道數與輸入 x 的通道數可能不同,那麼我們就需要使用 n_outputs 個卷積核將輸入取樣至與卷積輸出相同的通道數。最後,定義前向傳播以結合兩部分輸出而完成殘差模組的構建。
下面定義了 TCN 的整體架構,簡單而言即根據層級數將殘差模組疊加起來。其中 num_channels 儲存了所有層級(殘差模組)的通道數,它的長度即表示一共有多少個殘差模組。這裡每一個空洞卷積層的擴張係數隨著層級數成指數增加,這確保了卷積核在有效歷史資訊中覆蓋了所有的輸入,同樣也確保了使用深度網路能產生極其長的有效歷史資訊。
在從 num_channels 列表中抽取當前殘差模組的輸入與輸出通道數後,就能定義這一層的殘差模組。將不同層級的殘差模組使用 Sequential 堆疊起來就能構建整個網路架構。
# 定義時間卷積網路的架構
class TemporalConvNet(nn.Module):
def __init__(self, num_inputs, num_channels, kernel_size=2, dropout=0.2):
super(TemporalConvNet, self).__init__()
layers = []
# num_channels為各層卷積運算的輸出通道數或卷積核數量,它的長度即需要執行的卷積層數量
num_levels = len(num_channels)
# 空洞卷積的擴張係數若隨著網路層級的增加而成指數級增加,則可以增大感受野並不丟棄任何輸入序列的元素
# dilation_size根據層級數成指數增加,並從num_channels中抽取每一個殘差模組的輸入通道數與輸出通道數
for i in range(num_levels):
dilation_size = 2 ** i
in_channels = num_inputs if i == 0 else num_channels[i-1]
out_channels = num_channels[i]
layers += [TemporalBlock(in_channels, out_channels, kernel_size, stride=1, dilation=dilation_size,
padding=(kernel_size-1) * dilation_size, dropout=dropout)]
# 將所有殘差模組堆疊起來組成一個深度卷積網路
self.network = nn.Sequential(*layers)
def forward(self, x):
return self.network(x)
以上的三個類都在定義在 tcn.py 檔案中,它適用於所有的測試任務。在語言建模中,還有另一部分定義模型過程的類比較重要,它會將輸入序列饋送到網路以完成整個推斷過程。
class TCN(nn.Module):
def __init__(self, input_size, output_size, num_channels,
kernel_size=2, dropout=0.3, emb_dropout=0.1, tied_weights=False):
super(TCN, self).__init__()
# 將一個批量的輸入資料(one-hot encoding)送入編碼器中成為一個批量的詞嵌入向量
# 其中output_size為詞彙量,input_size為一個詞向量的長度
self.encoder = nn.Embedding(output_size, input_size)
# 構建網路
self.tcn = TemporalConvNet(input_size, num_channels, kernel_size, dropout=dropout)
# 定義最後線性變換的緯度,即最後一個卷積層的通道數(類似2D卷積中的特徵圖數)到所有詞彙的對映
self.decoder = nn.Linear(num_channels[-1], output_size)
# 是否共享編碼器與解碼器的權重,預設是共享。共享的話需要保持隱藏單元數等於詞嵌入長度,這樣預測的向量才可以視為詞嵌入向量
if tied_weights:
if num_channels[-1] != input_size:
raise ValueError('When using the tied flag, nhid must be equal to emsize')
self.decoder.weight = self.encoder.weight
print("Weight tied")
# 對輸入詞嵌入執行Dropout 表示隨機從句子中捨棄詞,迫使模型不依賴於單個詞完成任務
self.drop = nn.Dropout(emb_dropout)
self.emb_dropout = emb_dropout
self.init_weights()
def init_weights(self):
self.encoder.weight.data.normal_(0, 0.01)
self.decoder.bias.data.fill_(0)
self.decoder.weight.data.normal_(0, 0.01)
#先編碼,訓練中再隨機丟棄詞,輸入到網路實現推斷,最後將推斷結果解碼為詞
def forward(self, input):
"""Input ought to have dimension (N, C_in, L_in), where L_in is the seq_len; here the input is (N, L, C)"""
emb = self.drop(self.encoder(input))
y = self.tcn(emb.transpose(1, 2)).transpose(1, 2)
y = self.decoder(y)
return y.contiguous()
如上所示,模型的主要過程即先將輸入的向量編碼為詞嵌入向量,再作為輸入投入到時間卷積網路中。該網路的輸出為 y,它的第一個緯度表示批量大小,第二個緯度是通道數量,而第三個緯度代表序列長度。全卷積主要體現在解碼的過程,我們不需要再向量化卷積結果而進行仿射變換,而是直接將不同的序列通道對映到全部的詞彙中以確定預測的詞。
如果讀者安裝了 PyTorch,那麼 TCN 的測試就可以使用 Git 複製原論文官方實踐,然後轉到 word_cnn 目錄下就能直接在 PyCharm 等 IDE 中執行 word_cnn_test.py 檔案,當然我們也可以使用命令列執行。此外,為了讓更多的入門讀者可以執行該模型,我們會修正這個實現語言建模的 TCN,並放到谷歌 Colaboratory 中,這樣讀者就能使用免費的 GPU 資源進行訓練。這一部分還在修正中,稍後我們會上傳至機器之心 GitHub 專案。
最後,Shaojie Bai 等研究者還在很多序列建模任務上測試了 TCN 與傳統迴圈網路的效能:
上表展示了 TCN 和迴圈架構在合成壓力測試、復調音樂建模、字元級語言建模和單詞級語言建模任務上的評估結果。一般 TCN 架構在全部任務和資料集上都比經典迴圈網路效能優秀,上標 h 代表數值越高越好,l 代表數值越低越好。
從經典的隱馬爾科夫模型到現在基於迴圈神經網路與卷積神經網路的深度方法,序列建模已經走過了很長一段旅程,它對於自然語言處理與語音識別等都非常重要。本文只是簡單的介紹了基礎的序列建模深度方法,它還有很多地方需要探索與討論,那麼讓我們真真切切地去了解它吧。
參考資料:
《Deep Learning》,Ian Goodfellow,2016
An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling:https://arxiv.org/abs/1803.01271
TCN 實現地址:https://github.com/locuslab/TCN
Deep Residual Learning for Image Recognition:https://arxiv.org/abs/1512.03385
Fully Convolutional Networks for Semantic Segmentation:https://arxiv.org/pdf/1605.06211.pdf
WAVENET: A GENERATIVE MODEL FOR RAW AUDIO:https://arxiv.org/pdf/1609.03499.pdf