1. 程式人生 > >RNN在自然語言處理中的應用及其PyTorch實現

RNN在自然語言處理中的應用及其PyTorch實現

作者:廖星宇
本文節選自《深度學習入門之PyTorch》,本書從人工智慧的介紹入手,瞭解機器學習和深度學習的基礎理論,並學習如何用PyTorch框架對模型進行搭建。

對於人類而言,以前見過的事物會在腦海裡面留下記憶,雖然隨後記憶會慢慢消失,但是每當經過提醒,人們往往能夠重拾記憶。在神經網路的研究中,讓模型充滿記憶力的研究很早便開始了,Saratha Sathasivam 於1982 年提出了霍普菲爾德網路,但是由於它實現困難,在提出的時候也沒有很好的應用場景,所以逐漸被遺忘。深度學習的興起又讓人們重新開始研究迴圈神經網路(Recurrent Neural Network),並在序列問題和自然語言處理等領域取得很大的成功。

本文將從迴圈神經網路的基本結構出發,介紹RNN在自然語言處理中的應用及其PyTorch 實現。

迴圈神經網路

前一章介紹了卷積神經網路,卷積神經網路相當於人類的視覺,但是它並沒有記憶能力,所以它只能處理一種特定的視覺任務,沒辦法根據以前的記憶來處理新的任務。那麼記憶力對於網路而言到底是不是必要的呢?很顯然在某些問題上是必要的,比如,在一場電影中推斷下一個時間點的場景,這個時候僅依賴於現在的情景並不夠,需要依賴於前面發生的情節。對於這樣一些不僅依賴於當前情況,還依賴於過去情況的問題,傳統的神經網路結構無法很好地處理,所以基於記憶的網路模型是必不可少的。

迴圈神經網路的提出便是基於記憶模型的想法,期望網路能夠記住前面出現的特徵,並依據特徵推斷後面的結果,而且整體的網路結構不斷迴圈,因為得名迴圈神經
網路。

迴圈神經網路的基本結構

迴圈神經網路的基本結構特別簡單,就是將網路的輸出儲存在一個記憶單元中,這個記憶單元和下一次的輸入一起進入神經網路中。使用一個簡單的兩層網路作為示範,在它的基礎上擴充為迴圈神經網路的結構,我們用圖1簡單地表示。

可以看到網路在輸入的時候會聯合記憶單元一起作為輸入,網路不僅輸出結果,還會將結果儲存到記憶單元中,圖1就是一個最簡單的迴圈神經網路在一次輸入時的結構示意圖。

輸入序列的順序改變, 會改變網路的輸出結果,這是因為記憶單元的存在,使得兩個序列在順序改變之後記憶單元中的元素也改變了,所以會影響最終的輸出結果。

圖片描述

圖1 將一個數據點傳入網路

圖1是序列中一個數據點傳入網路的示意圖,那麼整個序列如何傳入網路呢?將序列中的每個資料點依次傳入網路即可,如圖2所示。

圖片描述

圖2 將整個序列傳入網路

無論序列有多長,都能不斷輸入網路,最終得到結果。可能看到這裡,讀者會有一些疑問,圖2中每一個網路是不是都是獨立的權重?對於這個問題,先考慮一下如果是不同的序列,那麼圖2 中格子的數目就是不同的,對於一個網路結構,不太可能出現這種引數數目變化的情況。

事實上,這裡再次使用了引數共享的概念,也就是說雖然上面有三個格子,其實它們都是同一個格子,而網路的輸出依賴於輸入和記憶單元,可以用圖5.5表示。

如圖5.5所示,左邊就是迴圈神經網路實際的網路流,右邊是將其展開的結果,可以看到網路中具有迴圈結構,這也是迴圈神經網路名字的由來。同時根據迴圈神經網路的結構也可以看出它在處理序列型別的資料上具有天然的優勢,因為網路本身就是一個序列結構,這也是所有迴圈神經網路最本質的結構。

圖片描述

圖3 網路的輸入和記憶單元

迴圈神經網路也可以有很深的網路層結構,如圖4所示。

圖片描述

圖4 深層網路結構

可以看到網路是單方向的,這代表網路只能知道單側的資訊,有的時候序列的資訊不只是單邊有用,雙邊的資訊對預測結果也很重要,比如語音訊號,這時候就需要看到兩側資訊的迴圈神經網路結構。這並不需要用兩個迴圈神經網路分別從左右兩邊開始讀取序列輸入,使用一個雙向的迴圈神經網路就能完成這個任務,如圖5所示。

圖片描述

圖5 雙向迴圈神經網路

使用雙向迴圈神經網路,網路會先從序列的正方向讀取資料,再從反方向讀取資料,最後將網路輸出的兩種結果合在一起形成網路的最終輸出結果。

自然語言處理的應用

迴圈神經網路目前在自然語言處理中應用最為火熱,所以這一小節將介紹自然語言處理中如何使用迴圈神經網路。

詞嵌入

首先介紹自然語言處理中的第一個概念——詞嵌入(word embedding),也可以稱為詞向量。

影象分類問題會使用one-hot 編碼,比如一共有五類,那麼屬於第二類的話,它的編碼就是(0, 1, 0, 0, 0),對於分類問題,這樣當然特別簡明。但是在自然語言處理中,因為單詞的數目過多,這樣做就行不通了,比如有10000 個不同的詞,那麼使用one-hot這樣的方式來定義,效率就特別低,每個單詞都是10000 維的向量,其中只有一位是1,其餘都是0,特別佔用記憶體。除此之外,也不能體現單詞的詞性,因為每一個單詞都是one-hot,雖然有些單詞在語義上會更加接近,但是one-hot 沒辦法體現這個特點,所以必須使用另外一種方式定義每一個單詞,這就引出了詞嵌入。

詞嵌入到底是什麼意思呢?其實很簡單,對於每個詞,可以使用一個高維向量去表示它,這裡的高維向量和one-hot 的區別在於,這個向量不再是0 和1 的形式,向量的每一位都是一些實數,而這些實數隱含著這個單詞的某種屬性。這樣解釋可能不太直觀,先舉四個例子,下面有4 段話:

(1)The cat likes playing ball.
(2)The kitty likes playing wool.
(3)The dog likes playing ball.
(4)The boy likes playing ball.

重點分析裡面的4 個詞,cat、kitty、dog 和boy。如果使用one-hot,那麼cat 就可以表示成(1, 0, 0, 0),kitty 就可以表示成(0, 1, 0, 0),但是cat 和kitty 其實都表示小貓,所以這兩個詞語義是接近的,但是one-hot 並不能體現這個特點。

下面使用詞嵌入的方式來表示這4 個詞,假如使用一個二維向量(a, b) 來表示一個詞,其中a,b 分別代表這個詞的一種屬性,比如a 代表是否喜歡玩球,b 代表是否喜歡玩毛線,並且這個數值越大表示越喜歡,這樣就能夠定義每一個詞的詞嵌入,並且通過這個來區分語義,下面來解釋一下原因。

對於cat,可以定義它的詞嵌入是(-1, 4),因為它不喜歡玩球,喜歡玩毛線;而對於kitty,它的詞嵌入可以定義為(-2, 5);那麼對於dog,它的詞嵌入就是(3, -2),因為它喜歡玩球,不喜歡玩毛線;最後對於boy,它的詞向量就是(-2, -3),因為這兩樣東西他都不喜歡。定義好了這樣的詞嵌入,怎麼去定義它們之間的語義相似度呢?可以通過詞向量之間的夾角來定義它們的相似度。下面先將每個詞向量都在座標系中表示出來,如圖6所示。

圖片描述

圖6 不同詞向量的夾角

圖6 就顯示出了不同詞向量之間的夾角,可以發現kitty 和cat 的夾角更小,所以它們更加相似的,而dog 和boy 之間夾角很大,所以它們不相似。

通過這樣一個簡單的例子能夠看出詞嵌入對於單詞的表示具有很好的優勢,但是問題來了,對於一個詞,怎麼知道如何去定義它的詞嵌入?如果向量的維數只有5 維,可能還能定義出來,如果向量的維數是100 維,那麼怎麼知道每一維體是多少呢?

這個問題可以交給神經網路去解決,只需要定義我們想要的維度,比如100 維,神經網路就會自己去更新每個詞嵌入中的元素。而之前介紹過詞嵌入的每個元素表示一種屬性,當然對於維數比較低的時候,可能我們能夠推斷出每一維具體的屬性含義,然而維度比較高之後,我們並不需要關心每一維到底代表著什麼含義,因為每一維都是網路自己學習出來的屬性,只需要知道詞向量的夾角越小,表示它們之間的語義更加接近就可以了。這就好比卷積網路會對一張圖片提取出很厚的特徵圖,並不需要關心網路提取出來的特徵到底是什麼,只需要知道抽象的特徵能夠幫助我們分類影象就可以了。

詞嵌入的PyTorch 實現

詞嵌入在PyTorch 中是如何實現的呢?下面來具體實現一下。

PyTorch 中的詞嵌入是通過函式nn.Embedding(m, n) 來實現的,其中m 表示所有的單詞數目,n 表示詞嵌入的維度,下面舉一個例子:

1 word_to_ix = {'hello': 0, 'world': 1}
2 embeds = nn.Embedding(2, 5)
3 hello_idx = torch.LongTensor([word_to_ix['hello']])
4 hello_idx = Variable(hello_idx)
5 hello_embed = embeds(hello_idx)
6 print(hello_embed)

上面就是輸出的hello 的詞嵌入,下面來解釋一下程式碼。首先需要給每個單詞建立一個對應下標,這樣每個單詞都可以用一個數字去表示,比如需要hello 的時候,就可以用0來表示,用這種方式,訪問每個詞會特別方便。接著是詞嵌入的定義nn.Embedding(2,5),如上面介紹過的,表示有兩個詞,每個詞向量是5 維,也就是一個2 * 5 的矩陣,只不過矩陣中的元素是可以被學習更新的,所以如果有1000 個詞,每個詞向量希望是100 維,就可以這樣定義詞嵌入nn.Embedding(1000, 100)。訪問每一個詞的詞向量需要將tensor 轉換成Variable,因為詞向量也是網路中更新的引數,所以在計算圖中,需要通過Variable 去訪問。另外這裡的詞向量只是初始的詞向量,並沒有經過學習更新,需要建立神經網路優化更新,修改詞向量裡面的引數使得詞向量能夠表示不同的詞,且語義相近的詞能夠有更小的夾角。

以上介紹了詞嵌入在PyTorch 中是如何實現的,下一節將介紹詞嵌入是如何更新的,以及它如何結合N Gram 語言模型進行預測。

N Gram 模型

首先介紹N Gram 模型的原理和它要解決的問題。在一篇文章中,每一句話都是由很多單片語成的,而且這些單詞的排列順序也是非常重要的。在一句話中,是否可以由前面幾個詞來預測這些詞後面的一個單詞?比如在“I lived in France for 10 years, I can speak _ .”這句話中,我們希望能夠預測最後這個詞是French。

知道想要解決的問題後,就可以引出N Gram 語言模型了。對於一句話T,它由w1;w2;…wn 這n 個詞構成,可以得到下面的公式:

圖片描述

但是這樣的一個模型存在著一些缺陷,比如引數空間過大,預測一個詞需要前面所有的詞作為條件來計算條件概率,所以在實際中沒辦法使用。為了解決這個問題,引入了馬爾科夫假設,也就是說這個單詞只與前面的幾個詞有關係,並不是和前面所有的詞都有關係,有了這個假設,就能夠在實際中使用N Gram 模型了。

對於這個條件概率,傳統的方法是統計語料中每個單詞出現的頻率,據此來估計這個條件概率,這裡使用詞嵌入的辦法,直接在語料中計算這個條件概率,然後最大化條件概率從而優化詞向量,據此進行預測。

單詞預測的PyTorch 實現

首先給出一段文章作為訓練集:

1 CONTEXT_SIZE = 2
2 EMBEDDING_DIM = 10
3 # We will use Shakespeare Sonnet 2
4 test_sentence = """When forty winters shall besiege thy brow,
5 And dig deep trenches in thy beauty's field,
6 Thy youth's proud livery so gazed on now,
7 Will be a totter'd weed of small worth held:
8 Then being asked, where all thy beauty lies,
9 Where all the treasure of thy lusty days;
10 To say, within thine own deep sunken eyes,
11 Were an all-eating shame, and thriftless praise.
12 How much more praise deserv'd thy beauty's use,
13 If thou couldst answer 'This fair child of mine
14 Shall sum my count, and make my old excuse,'
15 Proving his beauty by succession thine!
16 This were to be new made when thou art old,
17 And see thy blood warm when thou feel'st it cold.""".split()

CONTEXT_SIZE 表示想由前面的幾個單詞來預測這個單詞,這裡設定為2,就是說我們希望通過這個單詞的前兩個單詞來預測這一個單詞,EMBEDDING_DIM 表示詞嵌入的維數。

接著建立訓練集,遍歷所有語料來建立,將資料整理好,需要將單詞分三個組,每個組前兩個作為傳入的資料,而最後一個作為預測的結果。

1 trigram = [((test_sentence[i], test_sentence[i+1]), test_sentence[i+2])
2 for i in range(len(test_sentence)-2)]

將每個單詞編碼,即用數字來表示每個單詞,只有這樣才能夠傳入nn.Embedding得到詞向量。

1 vocb = set(test_sentence) # 通過set將重複的單詞去掉
2 word_to_idx = {word: i for i, word in enumerate(vocb)}
3 idx_to_word = {word_to_idx[word]: word for word in word_to_idx}

然後可以定義N Gram 模型如下:

1 class NgramModel(nn.Module):
2 def __init__(self, vocb_size, context_size, n_dim):
3 super(NgramModel, self).__init__()
4 self.n_word = vocb_size
5 self.embedding = nn.Embedding(self.n_word, n_dim)
6 self.linear1 = nn.Linear(context_size*n_dim, 128)
7 self.linear2 = nn.Linear(128, self.n_word)
8
9 def forward(self, x):
10 emb = self.embedding(x)
11 emb = emb.view(1, -1)
12 out = self.linear1(emb)
13 out = F.relu(out)
14 out = self.linear2(out)
15 log_prob = F.log_softmax(out)
16 return log_prob

模型需要傳入的引數有三個,分別是所有的單詞數、預測單詞所依賴的單詞數、即CONTEXT_SIZE 和詞向量的維度。網路在向前傳播中,首先傳入單詞得到詞向量,模型是根據前面兩個詞預測第三個詞的,所以需要傳入兩個詞,得到的詞向量是(2, 100),然後將詞向量展開成(1, 200),接著經過線性變換,經過relu 啟用函式,再經過一個線性變換,輸出的維數是單詞總數,最後經過一個log softmax 啟用函式得到概率分佈,最大化條件概率,可以用下面的公式表示:

圖片描述

在網路的訓練中,不僅會更新線性層的引數,還會更新詞嵌入中的引數,訓練100次模型,可以發現loss 已經降到了0.37,也可以通過預測來檢測模型是否有效:

1 word, label = trigram[3]
2 word = Variable(torch.LongTensor([word_to_idx[i] for i in word]))
3 out = ngrammodel(word)
4 _, predict_label = torch.max(out, 1)
5 predict_word = idx_to_word[predict_label.data[0][0]]
6 print('real word is {}, predict word is {}'.format(label, predict_word))

執行上面的程式碼,可以發現真實的單詞跟預測的單詞都是一樣的,雖然這是在訓練集上,但是在一定程度上也說明這個小模型能夠處理N Gram 模型的問題。

上面介紹瞭如何通過最簡單的單邊N Gram 模型預測單詞,還有一種複雜一點的N Gram 模型通過雙邊的單詞來預測中間的單詞,這種模型有個專門的名字,叫Continuous Bag-of-Words model(CBOW),具體內容差別不大,就不再贅述。

詞性判斷

上面只使用了詞嵌入和N Gram 模型進行自然語言處理,還沒有真正使用迴圈神經網路,下面介紹RNN 在自然語言處理中的應用。在這個例子中,我們將使用LSTM 做詞性判斷,因為同一個單詞有著不同的詞性,比如book 可以表示名詞,也可以表示動詞,所以需要結合前後文給出具體的判斷。先介紹使用LSTM 做詞性判斷的原理。

  • 基本原理

定義好一個LSTM 網路,然後給出一個由很多個詞構成的句子,根據前面的內容,每個詞可以用一個詞向量表示,這樣一句話就可以看做是一個序列,序列中的每個元素都是一個高維向量,將這個序列傳入LSTM,可以得到與序列等長的輸出,每個輸出都表示為對詞性的判斷,比如名詞、動詞等。從本質上看,這是一個分類問題,雖然使用了LSTM,但實際上是根據這個詞前面的一些詞來對它進行分類,看它是屬於幾種詞性中的哪一種。

思考一下為什麼LSTM 在這個問題裡面起著重要的作用。如果完全孤立地對一個詞做詞性的判斷,往往無法得到比較準確的結果,但是通過LSTM,根據它記憶的特性,就能夠通過這個單詞前面記憶的一些詞語來對它做一個判斷,比如前面的單詞如果是my,那麼它緊跟的詞很有可能就是一個名詞,這樣就能夠充分地利用上文來處理這個問題。

  • 字元增強

還可以通過引入字元來增強表達,這是什麼意思呢?就是說一些單詞存在著字首或者字尾,比如-ly 這種字尾很可能是一個副詞,這樣我們就能夠在字元水平上對詞性進行進一步判斷,把兩種方法整合起來,能夠得到一個更好的結果。

在實現上還是用LSTM,只是這次不再將句子作為一個序列,而是將每個單詞作為一個序列。每個單詞由不同的字母組成,比如apple 由a p p l e 構成,給這些字元建立詞向量,形成了一個長度為5 的序列,將它傳入LSTM 網路,只取最後輸出的狀態層作為它的一種字元表達,不需要關心提取出來的字元表達到底是什麼樣,它作為一種抽象的特徵,能夠更好地預測結果。

詞性判斷的PyTorch 實現

作為演示,使用一個簡單的訓練資料,下面有兩句話,每句話中的每個詞都給出了詞性:

1 training_data = [
2 ("The dog ate the apple".split(), ["DET", "NN", "V", "DET", "NN"]),
3 ("Everybody read that book".split(), ["NN", "V", "DET", "NN"])
4 ]

接著對單詞和詞性由a 到z 的字元進行編碼:

1 word_to_idx = {}
2 tag_to_idx = {}
3 for context, tag in training_data:
4 for word in context:
5 if word not in word_to_idx:
6 word_to_idx[word] = len(word_to_idx)
7 for label in tag:
8 if label not in tag_to_idx:
9 tag_to_idx[label] = len(tag_to_idx)
10
11 alphabet = 'abcdefghijklmnopqrstuvwxyz'
12 character_to_idx = {}
13 for i in range(len(alphabet)):
14 character_to_idx[alphabet[i]] = i

接著先定義字元水準上的LSTM,定義方式和之前類似:

1 class CharLSTM(nn.Module):
2 def __init__(self, n_char, char_dim, char_hidden):
3 super(CharLSTM, self).__init__()
4 self.char_embedding = nn.Embedding(n_char, char_dim)
5 self.char_lstm = nn.LSTM(char_dim, char_hidden, batch_first=True)
6
7 def forward(self, x):
8 x = self.char_embedding(x)
9 _, h = self.char_lstm(x)
10 return h[0]

定義兩層結構:第一層是詞嵌入,第二層是LSTM。在網路的前向傳播中,先將單詞的n 個字元傳入網路,再通過nn.Embedding 得到詞向量,接著傳入LSTM 網路,得到隱藏狀態輸出h,然後通過h[0] 得到想要的輸出狀態。對於每個單詞,都可以通過CharLSTM 用相應的字元表示。

接著完成目標,分析每個單詞的詞性,首先定義好詞性的LSTM 網路:

1 class LSTMTagger(nn.Module):
2 def __init__(self, n_word, n_char, char_dim, n_dim, char_hidden,
3 n_hidden, n_tag):
4 super(LSTMTagger, self).__init__()
5 self.word_embedding = nn.Embedding(n_word, n_dim)
6 self.char_lstm = CharLSTM(n_char, char_dim, char_hidden)
7 self.lstm = nn.LSTM(n_dim+char_hidden, n_hidden, batch_first=True)
8 self.linear1 = nn.Linear(n_hidden, n_tag)
9
10 def forward(self, x, word_data):
11 word = [i for i in word_data]
12 char = torch.FloatTensor()
13 for each in word:
14 word_list = []
15 for letter in each:
16 word_list.append(character_to_idx[letter.lower()])
17 word_list = torch.LongTensor(word_list)
18 word_list = word_list.unsqueeze(0)
19 tempchar = self.char_lstm(Variable(word_list).cuda())
20 tempchar = tempchar.squeeze(0)
21 char = torch.cat((char, tempchar.cpu().data), 0)
22 char = char.squeeze(1)
23 char = Variable(char).cuda()
24 x = self.word_embedding(x)
25 x = torch.cat((x, char), 1)
26 x = x.unsqueeze(0)
27 x, _ = self.lstm(x)
28 x = x.squeeze(0)
29 x = self.linear1(x)
30 y = F.log_softmax(x)
31 return y

看著有點複雜,慢慢來介紹。首先使用n_word 和n_dim 定義單詞的詞向量矩陣的維度,n_char 和char_dim 定義字元的詞向量維度,char_hidden 表示字元水準上的LSTM 輸出的維度,n_hidden 表示每個單詞作為序列輸入LSTM 的輸出維度,最後n_tag 表示輸出的詞性分類。介紹完裡面引數的含義,下面具體介紹其中網路的向前傳播。

學習過PyTorch 的動態圖結構,網路的向前傳播就非常簡單了。因為要使用字元增強,所以在傳入一個句子作為序列的同時,還需要傳入句子中的單詞,用word_data表示。動態圖結構使得前向傳播中可以使用for 迴圈將每個單詞都傳入CharLSTM,得到的結果和單詞的詞向量拼在一起作為新的序列輸入,將它傳入LSTM 中,最後接一個全連線層,將輸出維數定義為詞性的數目。

這是基本的思路,就不具體解釋每句話的含義了,只是要注意程式碼裡面有unsqueeze和squeeze 的操作,原因前面介紹過,LSTM 的輸入要帶上batch_size,所以需要將維度擴大。

網路訓練經過了300 次,loss 降到了0.16 左右。為了驗證模型的準確性,可以預測“Everybody ate the apple”這句話中每個詞的詞性,一共有三種詞:DET、NN、V。最後得到的結果如圖7所示。

結果是一個4 行3 列的向量,每一行表示一個單詞,每一列表示一種詞性,從左到右的詞性分別是DET、NN、V。從每行裡面取最大值,那麼第一個詞的詞性就是NN,第二個詞是V,第三個詞是DET,第四個詞是NN,與想要的結果相符。

圖片描述

圖7 網路訓練結果

以上,通過幾個簡單的例子介紹了迴圈神經網路在自然語言處理中的應用,當然真正的應用會更多,同時也更加複雜,這裡就不再深入介紹了,對自然語言處理感興趣的讀者可以進行更深入地探究。

圖片描述