往期回顧
在前面的文章中,我們介紹了循環神經網絡,它可以用來處理包含序列結構的信息。然而,除此之外,信息往往還存在着諸如樹結構、圖結構等更復雜的結構。對於這種復雜的結構,循環神經網絡就無能為力了。本文介紹一種更為強大、復雜的神經網絡:遞歸神經網絡 (Recursive Neural Network, RNN),以及它的訓練算法BPTS (Back Propagation Through Structure)。顧名思義,遞歸神經網絡(巧合的是,它的縮寫和循環神經網絡一樣,也是RNN)可以處理諸如樹、圖這樣的遞歸結構。在文章的最后,我們將實現一個遞歸神經網絡,並介紹它的幾個應用場景。
遞歸神經網絡是啥
因為神經網絡的輸入層單元個數是固定的,因此必須用循環或者遞歸的方式來處理長度可變的輸入。循環神經網絡實現了前者,通過將長度不定的輸入分割為等長度的小塊,然后再依次的輸入到網絡中,從而實現了神經網絡對變長輸入的處理。一個典型的例子是,當我們處理一句話的時候,我們可以把一句話看作是詞組成的序列,然后,每次向循環神經網絡輸入一個詞,如此循環直至整句話輸入完畢,循環神經網絡將產生對應的輸出。如此,我們就能處理任意長度的句子了。入下圖所示:

然而,有時候把句子看做是詞的序列是不夠的,比如下面這句話『兩個外語學院的學生』:

上圖顯示了這句話的兩個不同的語法解析樹。可以看出來這句話有歧義,不同的語法解析樹則對應了不同的意思。一個是『兩個外語學院的/學生』,也就是學生可能有許多,但他們來自於兩所外語學校;另一個是『兩個/外語學院的學生』,也就是只有兩個學生,他們是外語學院的。為了能夠讓模型區分出兩個不同的意思,我們的模型必須能夠按照樹結構去處理信息,而不是序列,這就是遞歸神經網絡的作用。當面對按照樹/圖結構處理信息更有效的任務時,遞歸神經網絡通常都會獲得不錯的結果。
遞歸神經網絡可以把一個樹/圖結構信息編碼為一個向量,也就是把信息映射到一個語義向量空間中。這個語義向量空間滿足某類性質,比如語義相似的向量距離更近。也就是說,如果兩句話(盡管內容不同)它的意思是相似的,那么把它們分別編碼后的兩個向量的距離也相近;反之,如果兩句話的意思截然不同,那么編碼后向量的距離則很遠。如下圖所示:

從上圖我們可以看到,遞歸神經網絡將所有的詞、句都映射到一個2維向量空間中。句子『the country of my birth』和句子『the place where I was born』的意思是非常接近的,所以表示它們的兩個向量在向量空間中的距離很近。另外兩個詞『Germany』和『France』因為表示的都是地點,它們的向量與上面兩句話的向量的距離,就比另外兩個表示時間的詞『Monday』和『Tuesday』的向量的距離近得多。這樣,通過向量的距離,就得到了一種語義的表示。
上圖還顯示了自然語言可組合的性質:詞可以組成句、句可以組成段落、段落可以組成篇章,而更高層的語義取決於底層的語義以及它們的組合方式。遞歸神經網絡是一種表示學習,它可以將詞、句、段、篇按照他們的語義映射到同一個向量空間中,也就是把可組合(樹/圖結構)的信息表示為一個個有意義的向量。比如上面這個例子,遞歸神經網絡把句子"the country of my birth"表示為二維向量[1,5]。有了這個『編碼器』之后,我們就可以以這些有意義的向量為基礎去完成更高級的任務(比如情感分析等)。如下圖所示,遞歸神經網絡在做情感分析時,可以比較好的處理否定句,這是勝過其他一些模型的:

在上圖中,藍色表示正面評價,紅色表示負面評價。每個節點是一個向量,這個向量表達了以它為根的子樹的情感評價。比如"intelligent humor"是正面評價,而"care about cleverness wit or any other kind of intelligent humor"是中性評價。我們可以看到,模型能夠正確的處理doesn't的含義,將正面評價轉變為負面評價。
盡管遞歸神經網絡具有更為強大的表示能力,但是在實際應用中並不太流行。其中一個主要原因是,遞歸神經網絡的輸入是樹/圖結構,而這種結構需要花費很多人工去標注。想象一下,如果我們用循環神經網絡處理句子,那么我們可以直接把句子作為輸入。然而,如果我們用遞歸神經網絡處理句子,我們就必須把每個句子標注為語法解析樹的形式,這無疑要花費非常大的精力。很多時候,相對於遞歸神經網絡能夠帶來的性能提升,這個投入是不太划算的。
我們已經基本了解了遞歸神經網絡是做什么用的,接下來,我們將探討它的算法細節。
遞歸神經網絡的前向計算
接下來,我們詳細介紹一下遞歸神經網絡是如何處理樹/圖結構的信息的。在這里,我們以處理樹型信息為例進行介紹。
遞歸神經網絡的輸入是兩個子節點(也可以是多個),輸出就是將這兩個子節點編碼后產生的父節點,父節點的維度和每個子節點是相同的。如下圖所示:

和分別是表示兩個子節點的向量,是表示父節點的向量。子節點和父節點組成一個全連接神經網絡,也就是子節點的每個神經元都和父節點的每個神經元兩兩相連。我們用矩陣表示這些連接上的權重,它的維度將是,其中,表示每個節點的維度。父節點的計算公式可以寫成:
在上式中,tanh是激活函數(當然也可以用其它的激活函數),是偏置項,它也是一個維度為的向量。如果讀過前面的文章,相信大家已經非常熟悉這些計算了,在此不做過多的解釋了。
然后,我們把產生的父節點的向量和其他子節點的向量再次作為網絡的輸入,再次產生它們的父節點。如此遞歸下去,直至整棵樹處理完畢。最終,我們將得到根節點的向量,我們可以認為它是對整棵樹的表示,這樣我們就實現了把樹映射為一個向量。在下圖中,我們使用遞歸神經網絡處理一棵樹,最終得到的向量,就是對整棵樹的表示:

舉個例子,我們使用遞歸神將網絡將『兩個外語學校的學生』映射為一個向量,如下圖所示:

最后得到的向量就是對整個句子『兩個外語學校的學生』的表示。由於整個結構是遞歸的,不僅僅是根節點,事實上每個節點都是以其為根的子樹的表示。比如,在左邊的這棵樹中,向量是短語『外語學院的學生』的表示,而向量是短語『外語學院的』的表示。
式1就是遞歸神經網絡的前向計算算法。它和全連接神經網絡的計算沒有什么區別,只是在輸入的過程中需要根據輸入的樹結構依次輸入每個子節點。
需要特別注意的是,遞歸神經網絡的權重和偏置項在所有的節點都是共享的。
遞歸神經網絡的訓練
遞歸神經網絡的訓練算法和循環神經網絡類似,兩者不同之處在於,前者需要將殘差從根節點反向傳播到各個子節點,而后者是將殘差從當前時刻反向傳播到初始時刻。
下面,我們介紹適用於遞歸神經網絡的訓練算法,也就是BPTS算法。
誤差項的傳遞
首先,我們先推導將誤差從父節點傳遞到子節點的公式,如下圖:

定義為誤差函數E相對於父節點的加權輸入的導數,即:
設是父節點的加權輸入,則
在上述式子里,、、都是向量,而是矩陣。為了看清楚它們的關系,我們將其展開:
在上面的公式中,表示父節點p的第i個分量;表示子節點的第i個分量;表示子節點的第i個分量;表示子節點的第k個分量到父節點p的第i個分量的的權重。根據上面展開后的矩陣乘法形式,我們不難看出,對於子節點來說,它會影響父節點所有的分量。因此,我們求誤差函數E對的導數時,必須用到全導數公式,也就是:
有了上式,我們就可以把它表示為矩陣形式,從而得到一個向量化表達:
其中,矩陣是從矩陣W中提取部分元素組成的矩陣。其單元為:
上式看上去可能會讓人暈菜,從下圖,我們可以直觀的看到到底是啥。首先我們把W矩陣拆分為兩個矩陣和,如下圖所示:

顯然,子矩陣和分別對應子節點和的到父節點權重。則矩陣為:
也就是說,將誤差項反向傳遞到相應子節點的矩陣就是其對應權重矩陣的轉置。
現在,我們設是子節點的加權輸入,是子節點c的激活函數,則:
這樣,我們得到:
如果我們將不同子節點對應的誤差項連接成一個向量。那么,上式可以寫成:
式2就是將誤差項從父節點傳遞到其子節點的公式。注意,上式中的也是將兩個子節點的加權輸入和連在一起的向量。
有了傳遞一層的公式,我們就不難寫出逐層傳遞的公式。

上圖是在樹型結構中反向傳遞誤差項的全景圖,反復應用式2,在已知的情況下,我們不難算出為:
在上面的公式中,,表示取向量屬於節點p的部分。
權重梯度的計算
根據加權輸入的計算公式:
其中,表示第l層的父節點的加權輸入,表示第l層的子節點。是權重矩陣,是偏置項。將其展開可得:
那么,我們可以求得誤差函數在第l層對權重的梯度為:
上式是針對一個權重項的公式,現在需要把它擴展為對所有的權重項的公式。我們可以把上式寫成矩陣的形式(在下面的公式中,m=2n):
式3就是第l層權重項的梯度計算公式。我們知道,由於權重是在所有層共享的,所以和循環神經網絡一樣,遞歸神經網絡的最終的權重梯度是各個層權重梯度之和。即:
因為循環神經網絡的證明過程已經在零基礎入門深度學習(4) - 卷積神經網絡一文中給出,因此,遞歸神經網絡『為什么最終梯度是各層梯度之和』的證明就留給讀者自行完成啦。
接下來,我們求偏置項的梯度計算公式。先計算誤差函數對第l層偏置項的梯度:
把上式擴展為矩陣的形式:
式5是第l層偏置項的梯度,那么最終的偏置項梯度是各個層偏置項梯度之和,即:
權重更新
如果使用梯度下降優化算法,那么權重更新公式為:
其中,是學習速率常數。把式4帶入到上式,即可完成權重的更新。同理,偏置項的更新公式為:
把式6帶入到上式,即可完成偏置項的更新。
這就是遞歸神經網絡的訓練算法BPTS。由於我們有了前面幾篇文章的基礎,相信讀者們理解BPTS算法也會比較容易。
遞歸神經網絡的實現
完整代碼請參考GitHub: https://github.com/hanbt/learn_dl/blob/master/recursive.py (python2.7)
現在,我們實現一個處理樹型結構的遞歸神經網絡。
在文件的開頭,加入如下代碼:
#!/usr/bin/env python# -*- coding: UTF-8 -*-import numpy as npfrom cnn import IdentityActivator
上述四行代碼非常簡單,沒有什么需要解釋的。IdentityActivator激活函數是在我們介紹卷積神經網絡時寫的,現在引用一下它。
我們首先定義一個樹節點結構,這樣,我們就可以用它保存卷積神經網絡生成的整棵樹:
class TreeNode(object):def __init__(self, data, children=[], children_data=[]):self.parent = Noneself.children = childrenself.children_data = children_dataself.data = datafor child in children:child.parent = self
接下來,我們把遞歸神經網絡的實現代碼都放在RecursiveLayer類中,下面是這個類的構造函數:
# 遞歸神經網絡實現class RecursiveLayer(object):def __init__(self, node_width, child_count,activator, learning_rate):'''遞歸神經網絡構造函數node_width: 表示每個節點的向量的維度child_count: 每個父節點有幾個子節點activator: 激活函數對象learning_rate: 梯度下降算法學習率'''self.node_width = node_widthself.child_count = child_countself.activator = activatorself.learning_rate = learning_rate# 權重數組Wself.W = np.random.uniform(-1e-4, 1e-4,(node_width, node_width * child_count))# 偏置項bself.b = np.zeros((node_width, 1))# 遞歸神經網絡生成的樹的根節點self.root = None
下面是前向計算的實現:
def forward(self, *children):'''前向計算'''children_data = self.concatenate(children)parent_data = self.activator.forward(np.dot(self.W, children_data) + self.b)self.root = TreeNode(parent_data, children, children_data)
forward函數接收一系列的樹節點對象作為輸入,然后,遞歸神經網絡將這些樹節點作為子節點,並計算它們的父節點。最后,將計算的父節點保存在self.root變量中。
上面用到的concatenate函數,是將各個子節點中的數據拼接成一個長向量,其代碼如下:
def concatenate(self, tree_nodes):'''將各個樹節點中的數據拼接成一個長向量'''concat = np.zeros((0,1))for node in tree_nodes:concat = np.concatenate((concat, node.data))return concat
下面是反向傳播算法BPTS的實現:
def backward(self, parent_delta):'''BPTS反向傳播算法'''self.calc_delta(parent_delta, self.root)self.W_grad, self.b_grad = self.calc_gradient(self.root)def calc_delta(self, parent_delta, parent):'''計算每個節點的delta'''parent.delta = parent_deltaif parent.children:# 根據式2計算每個子節點的deltachildren_delta = np.dot(self.W.T, parent_delta) * (self.activator.backward(parent.children_data))# slices = [(子節點編號,子節點delta起始位置,子節點delta結束位置)]slices = [(i, i * self.node_width,(i + 1) * self.node_width)for i in range(self.child_count)]# 針對每個子節點,遞歸調用calc_delta函數for s in slices:self.calc_delta(children_delta[s[1]:s[2]],parent.children[s[0]])def calc_gradient(self, parent):'''計算每個節點權重的梯度,並將它們求和,得到最終的梯度'''W_grad = np.zeros((self.node_width,self.node_width * self.child_count))b_grad = np.zeros((self.node_width, 1))if not parent.children:return W_grad, b_gradparent.W_grad = np.dot(parent.delta, parent.children_data.T)parent.b_grad = parent.deltaW_grad += parent.W_gradb_grad += parent.b_gradfor child in parent.children:W, b = self.calc_gradient(child)W_grad += Wb_grad += breturn W_grad, b_grad
在上述算法中,calc_delta函數和calc_gradient函數分別計算各個節點的誤差項以及最終的梯度。它們都采用遞歸算法,先序遍歷整個樹,並逐一完成每個節點的計算。
下面是梯度下降算法的實現(沒有weight decay),這個非常簡單:
def update(self):'''使用SGD算法更新權重'''self.W -= self.learning_rate * self.W_gradself.b -= self.learning_rate * self.b_grad
以上就是遞歸神經網絡的實現,總共100行左右,和上一篇文章的LSTM相比簡單多了。
最后,我們用梯度檢查來驗證程序的正確性:
def gradient_check():'''梯度檢查'''# 設計一個誤差函數,取所有節點輸出項之和error_function = lambda o: o.sum()rnn = RecursiveLayer(2, 2, IdentityActivator(), 1e-3)# 計算forward值x, d = data_set()rnn.forward(x[0], x[1])rnn.forward(rnn.root, x[2])# 求取sensitivity mapsensitivity_array = np.ones((rnn.node_width, 1),dtype=np.float64)# 計算梯度rnn.backward(sensitivity_array)# 檢查梯度epsilon = 10e-4for i in range(rnn.W.shape[0]):for j in range(rnn.W.shape[1]):rnn.W[i,j] += epsilonrnn.reset_state()rnn.forward(x[0], x[1])rnn.forward(rnn.root, x[2])err1 = error_function(rnn.root.data)rnn.W[i,j] -= 2*epsilonrnn.reset_state()rnn.forward(x[0], x[1])rnn.forward(rnn.root, x[2])err2 = error_function(rnn.root.data)expect_grad = (err1 - err2) / (2 * epsilon)rnn.W[i,j] += epsilonprint 'weights(%d,%d): expected - actural %.4e - %.4e' % (i, j, expect_grad, rnn.W_grad[i,j])return rnn
下面是梯度檢查的結果,完全正確,OH YEAH!

遞歸神經網絡的應用
自然語言和自然場景解析
在自然語言處理任務中,如果我們能夠實現一個解析器,將自然語言解析為語法樹,那么毫無疑問,這將大大提升我們對自然語言的處理能力。解析器如下所示:

可以看出,遞歸神經網絡能夠完成句子的語法分析,並產生一個語法解析樹。
除了自然語言之外,自然場景也具有可組合的性質。因此,我們可以用類似的模型完成自然場景的解析,如下圖所示:

兩種不同的場景,可以用相同的遞歸神經網絡模型來實現。我們以第一個場景,自然語言解析為例。
我們希望將一句話逐字輸入到神經網絡中,然后,神經網絡返回一個解析好的樹。為了做到這一點,我們需要給神經網絡再加上一層,負責打分。分數越高,說明兩個子節點結合更加緊密,分數越低,說明兩個子節點結合更松散。如下圖所示:

一旦這個打分函數訓練好了(也就是矩陣U的各項值變為合適的值),我們就可以利用貪心算法來實現句子的解析。第一步,我們先將詞按照順序兩兩輸入神經網絡,得到第一組打分:

我們發現,現在分數最高的是第一組,The cat,說明它們的結合是最緊密的。這樣,我們可以先將它們組合為一個節點。然后,再次兩兩計算相鄰子節點的打分:

現在,分數最高的是最后一組,the mat。於是,我們將它們組合為一個節點,再兩兩計算相鄰節點的打分:

這時,我們發現最高的分數是on the mat,把它們組合為一個節點,繼續兩兩計算相鄰節點的打分......最終,我們就能夠得到整個解析樹:

現在,我們困惑這樣牛逼的打分函數score是怎樣訓練出來的呢?我們需要定義一個目標函數。這里,我們使用Max-Margin目標函數。它的定義如下:
在上式中,、分別表示第i個訓練樣本的輸入和標簽,注意這里的標簽是一棵解析樹。就是打分函數s對第i個訓練樣本的打分。因為訓練樣本的標簽肯定是正確的,我們希望s對它的打分越高越好,也就是越大越好。是所有可能的解析樹的集合,而則是對某個可能的解析樹的打分。是對錯誤的懲罰。也就是說,如果某個解析樹和標簽是一樣的,那么為0,如果網絡的輸出錯的越離譜,那么懲罰項的值就越高。表示所有樹里面最高得分。在這里,懲罰項相當於Margin,也就是我們雖然希望打分函數s對正確的樹打分比對錯誤的樹打分高,但也不要高過Margin的值。我們優化,使目標函數取最小值,即:
下面是懲罰函數的定義:
上式中,N(y)是樹y節點的集合;subTree(d)是以d為節點的子樹。上式的含義是,如果以d為節點的子樹沒有出現在標簽中,那么函數值+1。最終,懲罰函數的值,是樹y中沒有出現在樹中的子樹的個數,再乘上一個系數k。其實也就是關於兩棵樹差異的一個度量。
是對一個樣本最終的打分,它是對樹y每個節點打分的總和。
具體細節,讀者可以查閱『參考資料3』的論文。
小結
我們在系列文章中已經介紹的全連接神經網絡、卷積神經網絡、循環神經網絡和遞歸神經網絡,在訓練時都使用了監督學習(Supervised Learning)作為訓練方法。在監督學習中,每個訓練樣本既包括輸入特征,也包括標記,即樣本。然而,很多情況下,我們無法獲得形如的樣本,這時,我們就不能采用監督學習的方法。在接下來的幾篇文章中,我們重點介紹另外一種學習方法:增強學習(Reinforcement Learning)。在了解增強學習的主要算法之后,我們還將介紹著名的圍棋軟件AlphaGo,它是一個把監督學習和增強學習進行完美結合的案例。

