將正解做 One-hot Encoding
到目前為止,我們已經將所有的新聞標題以數字型態表示,只剩分類欄位 label
要進行從文本到數字的轉換了:
train.label[:5]
不過 label
的處理相對簡單。跟新聞標題相同,我們一樣需要一個字典將分類的文字轉換成索引:
import numpy as np # 定義每一個分類對應到的索引數字 label_to_index = { 'unrelated': 0, 'agreed': 1, 'disagreed': 2 } # 將分類標籤對應到剛定義的數字 y_train = train.label.apply( lambda x: label_to_index[x]) y_train = np.asarray(y_train) \ .astype('float32') y_train[:5]
現在每個分類的文字標籤都已經被轉成對應的數字,接著讓我們利用 Keras 做 One Hot Encoding:
y_train = keras \ .utils \ .to_categorical(y_train) y_train[:5]
上述矩陣的每一列即為 1 個 label,而你可以看到現在每個 label 都從 1 個數字變成一個 3 維的向量(Vector)。
每 1 維度則對應到 1 個分類:
-
[1, 0, 0]
代表 label 為unrelated
-
[0, 1, 0]
代表 label 為agreed
-
[0, 0, 1]
代表 label 為disagreed
用這樣的方式表達 label 的好處是我們可以把分類結果想成機率分佈。[1, 0, 0]
就代表一組新聞標題 A、B 為 unrelated
的機率等于 100 %。
[圖片上傳失敗...(image-9343c1-1589534455753)]
One Hot Encoding 示意圖
在決定如何衡量模型的表現一節我們會看到,給定一組新聞標題 A、B,我們的模型會預測此成對標題屬于每個分類的機率值,比方說 [0.7, 0.2, 0.1]
。而此預測結果代表模型認為這 2 個新聞標題的關係有 70 % 的機率為 unrelated
、20 % 的機率是 agreed
而 10 % 為 disagreed
。
因此,如果正解也事先用同樣的方式表達的話,會讓我們比較好計算以下兩者之間的差距:
- 正確的分類的機率分佈(
[1, 0, 0]
) - 模型預測出的機率分佈(
[0.7, 0.2, 0.1]
)
在知道預測結果跟正確解答之間差距多少之后,深度學習模型就會自動修正學習方向,想盡辦法拉近這個差距。
好,到此為止所有的數據都已經被我們轉換成方便機器使用的格式了。最后,讓我們將整個資料集拆成訓練資料集 & 驗證資料集 以方便之后測試模型的效能。
(別哀號,我保證這是最后的前處理步驟了!)
切割訓練資料集 & 驗證資料集
這部分很簡單,我們只需決定要將整個訓練資料集(Training Set)的多少比例切出來當作驗證資料集(Validation Set)。此例中我們用 10 %。
但為何要再把本來的訓練資料集切成 2 個部分呢?
一般來說,我們在訓練時只會讓模型看到訓練資料集,并用模型沒看過的驗證資料集來測試該模型在真實世界的表現。(畢竟我們沒有測試資料集的答案)
我們會反覆在 Train / Valid Set 上訓練并測試模型,最后用 Test Set 一決生死 (圖片來源)
等到模型在驗證資料集也表現得夠好后,便在最終的測試資料集(Test Set)進行最后一次的預測并將該結果上傳到 Kaggle。
要了解為何我們需要驗證資料集可以查看這邊的討論。
簡而言之,當你多次利用驗證資料集的預測結果以修正模型,并讓它在該資料集表現更好時,過適(Overfitting)的風險就已經產生了。
反覆利用驗證資料集的結果來修正模型表現,事實上就等于讓模型「偷看」到驗證資料集本身的資訊了
儘管你沒有直接讓模型看到驗證資料集(Validation Set)內的任何數據,你還是間接地洩漏了該資料集的重要資訊:你讓模型知道怎樣的參數設定會讓它在該資料集表現比較好,亦或表現較差。
因此有一個完全跟模型訓練過程獨立的測試資料集(Test Set)就顯得重要許多了。(這也是為何我到現在都還沒有碰它的原因)
機器學習模型努力從夏令營(訓練及驗證資料集)學習技能,并在真實世界(測試資料集)展示其學習結果。
回歸正題,要切訓練資料集 / 驗證資料集,scikit-learn 中的 train_test_split
函式是一個不錯的選擇:
from sklearn.model_selection \ import train_test_split VALIDATION_RATIO = 0.1 # 小彩蛋 RANDOM_STATE = 9527 x1_train, x1_val, \ x2_train, x2_val, \ y_train, y_val = \ train_test_split( x1_train, x2_train, y_train, test_size=VALIDATION_RATIO, random_state=RANDOM_STATE )
在這邊,我們分別將新聞標題 A x1_train
、新聞標題 B x2_train
以及分類標籤 y_train
都分成兩個部分:訓練部分 & 驗證部分。
以假新聞 A 的標題 x1_train
為例,本來完整 32 萬筆的 x1_train
會被分為包含 90 % 數據的訓練資料集 x1_train
以及 10 % 的驗證資料集 x1_val
。
print("Training Set") print("-" * 10) print(f"x1_train: {x1_train.shape}") print(f"x2_train: {x2_train.shape}") print(f"y_train : {y_train.shape}") print("-" * 10) print(f"x1_val: {x1_val.shape}") print(f"x2_val: {x2_val.shape}") print(f"y_val : {y_val.shape}") print("-" * 10) print("Test Set")
我們可以看到,切割后的訓練資料集有 288,488 筆資料。每一筆資料裡頭,成對新聞標題 A & B 的長度皆為 20 個 Tokens,分類結果則有 3 個;驗證資料集的內容一模一樣,僅差在資料筆數較少(32,055 筆)。
到此為此,資料前處理大功告成!
既然我們已經為機器準備好它們容易理解的數字序列資料,接著就讓我們來看看要使用怎麼樣的 NLP 模型來處理這些數據。
有記憶的循環神經網路
針對這次的 Kaggle 競賽,我們將使用循環神經網路(Recurrent Neural Network, 后簡稱 RNN)來處理剛剛得到的序列數據。
RNN 是一種有「記憶力」的神經網路,其最為人所知的形式如下:
如同上圖等號左側所示,RNN 跟一般深度學習中常見的前饋神經網路(Feedforward Neural Network, 后簡稱 FFNN)最不一樣的地方在于它有一個迴圈(Loop)。
要了解這個迴圈在 RNN 里頭怎麼運作,現在讓我們想像有一個輸入序列 X(Input Sequence)其長相如下:
不同于 FFNN,RNN 在第一個時間點 t0
并不會直接把整個序列 X 讀入。反之,在第一個時間點 t0
,它只將該序列中的第一個元素 x0
讀入中間的細胞 A。細胞 A 則會針對 x0
做些處理以后,更新自己的「狀態」并輸出第一個結果 h0
。
在下個時間點 t1
,RNN 如法炮製,讀入序列 X 中的下一個元素 x1
,并利用剛剛處理完 x0
得到的細胞狀態,處理 x1
并更新自己的狀態(也被稱為記憶),接著輸出另個結果 h1
。
剩下的 xt
都會被以同樣的方式處理。但不管輸入的序列 X 有多長,RNN 的本體從頭到尾都是等號左邊的樣子:迴圈代表細胞 A 利用「上」一個時間點(比方說 t1
)儲存的狀態,來處理當下的輸入(比方說 x2
)。
但如果你將不同時間點(t0
、t1
...)的 RNN 以及它的輸入一起截圖,并把所有截圖從左到右一字排開的話,就會長得像等號右邊的形式。
將 RNN 以右邊的形式表示的話,你可以很清楚地了解,當輸入序列越長,向右展開的 RNN 也就越長。(模型也就需要訓練更久時間,這也是為何我們在資料前處理時設定了序列的最長長度)
為了確保你 100 % 理解 RNN,讓我們假設剛剛的序列 X 實際上是一個內容如下的英文問句:
而且 RNN 已經處理完前兩個元素 What
和 time
了。
則接下來 RNN 會這樣處理剩下的句子:
RNN 一次只讀入并處理序列的「一個」元素 (圖片來源)
現在你可以想像為何 RNN 非常適合拿來處理像是自然語言這種序列數據了。
就像你現在閱讀這段話一樣,你是由左到右逐字在大腦裡處理我現在寫的文字,同時不斷地更新你腦中的記憶狀態。
每當下個詞彙映入眼中,你腦中的處理都會跟以下兩者相關:
- 前面所有已讀的詞彙
- 目前腦中的記憶狀態
當然,實際人腦的閱讀機制更為複雜,但 RNN 抓到這個處理精髓,利用內在迴圈以及細胞內的「記憶狀態」來處理序列資料。
RNN 按照順序,處理一連串詞彙的機制跟我們理解自然語言的方式有許多相似之處
到此為止,你應該已經了解 RNN 的基本運作方式了。現在你可能會問:「那我們該如何實作一個 RNN 呢?」
好問題,以下是一個簡化到不行的 RNN 實現:
state_t = 0 for input_t in input_sequence: output_t = f(input_t, state_t) state_t = output_t
在 RNN 每次讀入任何新的序列數據前,細胞 A 中的記憶狀態 state_t
都會被初始化為 0。
接著在每個時間點 t
,RNN 會重複以下步驟:
- 讀入
input_sequence
序列中的一個新元素input_t
- 利用
f
函式將當前細胞的狀態state_t
以及輸入input_t
做些處理產生output_t
- 輸出
output_t
并同時更新自己的狀態state_t
不需要自己發明輪子,在 Keras 里頭只要 2 行就可以建立一個 RNN layer:
from keras import layers rnn = layers.SimpleRNN()
使用深度學習框架可以幫我們省下非常多的寶貴時間并避免可能的程式錯誤。
我們后面還會看到,一個完整的神經網路通常會分成好幾層(layer):每一層取得前一層的結果作為輸入,進行特定的資料轉換后再輸出給下一層。
常見的神經網路形式。圖中框內有迴圈的就是 RNN 層 (圖片來源)
好啦,相信你現在已經掌握基本 RNN 了。事實上,除了 SimpleRNN
以外,Keras 裡頭還有其他更常被使用的 Layer,現在就讓我們看看一個知名的例子:長短期記憶。
記憶力好的 LSTM 細胞
讓我們再看一次前面的簡易 RNN 實作:
state_t = 0 # 細胞 A 會重複執行以下處理 for input_t in input_sequence: output_t = f(input_t, state_t) state_t = output_t
在了解 RNN 的基本運作方式以后,你會發現 RNN 真正的魔法,事實上藏在細胞 A 的 f
函式里頭。
要如何將細胞 A 當下的記憶 state_t
與輸入 input_t
結合,才能產生最有意義的輸出 output_t
呢?
在 SimpleRNN
的細胞 A 裡頭,這個 f
的實作很簡單。而這導致其記憶狀態 state_t
沒辦法很好地「記住」前面處理過的序列元素,造成 RNN 在處理后來的元素時,就已經把前面重要的資訊給忘記了。
這就好像一個人在講了好長一段話以后,忘了前面到底講過些什麼的情境。
長短期記憶(Long Short-Term Memory, 后簡稱 LSTM)就是被設計來解決 RNN 的這個問題。
如下圖所示,你可以把 LSTM 想成是 RNN 中用來實現細胞 A 內部處理邏輯的一個特定方法:
以抽象的層次來看,LSTM 就是實現 RNN 中細胞 A 邏輯的一個方式 (圖片來源)
基本上一個 LSTM 細胞裡頭會有 3 個閘門(Gates)來控制細胞在不同時間點的記憶狀態:
- Forget Gate:決定細胞是否要遺忘目前的記憶狀態
- Input Gate:決定目前輸入有沒有重要到值得處理
- Output Gate:決定更新后的記憶狀態有多少要輸出
透過這些閘門控管機制,LSTM 可以將很久以前的記憶狀態儲存下來,在需要的時候再次拿出來使用。值得一提的是,這些閘門的參數也都是神經網路自己訓練出來的。
下圖顯示各個閘門所在的位置:
LSTM 細胞頂端那條 cell state 正代表著細胞記憶的轉換過程 (圖片來源)
想像 LSTM 細胞裡頭的記憶狀態是一個包裹,上面那條直線就代表著一個輸送帶。
LSTM 可以把任意時間點的記憶狀態(包裹)放上該輸送帶,然后在未來的某個時間點將其原封不動地取下來使用。
因為這樣的機制,讓 LSTM 即使面對很長的序列數據也能有效處理,不遺忘以前的記憶。
因為效果卓越,LSTM 非常廣泛地被使用。事實上,當有人跟你說他用 RNN 做了什麼 NLP 專案時,有 9 成機率他是使用 LSTM 或是 GRU(LSTM 的改良版,只使用 2 個閘門) 來實作,而不是使用最簡單的 SimpleRNN
。
因此,在這次 Kaggle 競賽中,我們的第一個模型也將使用 LSTM。而在 Keras 裡頭要使用 LSTM 也是輕鬆寫意:
from keras import layers lstm = layers.LSTM()
現在,既然我們已經有了在資料前處理步驟被轉換完成的序列數據,也決定好要使用 LSTM 作為我們的 NLP 模型,接著就讓我們試著將這些數據讀入 LSTM 吧!
詞向量:將詞彙表達成有意義的向量
在將序列數據塞入模型之前,讓我們重新檢視一下數據。比方說,以下是在訓練資料集裡頭前 5 筆的假新聞標題 A:
for i, seq in enumerate(x1_train[:5]): print(f"新聞標題 {i + 1}: ") print(seq) print()
你可以看到,每個新聞標題都被轉成長度為 20 的數字序列。裡頭的每個數字都代表著一個詞彙( 0
代表 Zero Padding)。
x1_train.shape
而我們在訓練資料集則總共有 288,488 筆新聞標題,每筆標題如同剛剛所說的,是一個包含 20 個數字的序列。
當然,我們可以用 tokenizer
裡頭的字典 index_word
還原文本看看:
for i, seq in enumerate(x1_train[:5]): print(f"新聞標題 {i + 1}: ") print([tokenizer.index_word.get(idx, '') for idx in seq]) print()
其他新聞標題像是:
- 訓練資料集中的新聞標題 B
x2_train
- 驗證資料集中的新聞標題 A
x1_val
- 驗證資料集中的新聞標題 B
x2_val
也都是以這樣的數字序列形式被儲存。
但事實上要讓神經網路能夠處理標題序列內的詞彙,我們要將它們表示成向量(更精準地說,是張量:Tensor),而不是一個單純數字。
如果我們能做到這件事情,則 RNN 就能用以下的方式讀入我們的資料:
注意:在每個時間點被塞入 RNN 的「詞彙」不再是 1 個數字,而是一個 N 維向量(圖中 N 為 3) (圖片來源)
所以現在的問題變成:
「要怎麼將一個詞彙表示成一個 N 維向量 ?」
其中一個方法是我們隨便決定一個 N,然后為語料庫裡頭的每一個詞彙都指派一個隨機生成的 N 維向量。
假設我們現在有 5 個詞彙:
- 野狼
- 老虎
- 狗
- 貓
- 喵咪
依照剛剛說的方法,我們可以設定 N = 2,并為每個詞彙隨機分配一個 2 維向量后將它們畫在一個平面空間裡頭:
這些代表詞彙的向量被稱之為詞向量,但是你可以想像這樣的隨機轉換很沒意義。
比方說上圖,我們就無法理解:
- 為何「狗」是跟「老虎」而不是跟同為犬科的「野狼」比較接近?
- 為何「貓」的維度 2 比「狗」高,但卻比「野狼」低?
- 維度 2 的值的大小到底代表什麼意義?
- 「喵咪」怎麼會在那裡?
這是因為我們只是將詞彙隨機地轉換到 2 維空間,并沒有讓這些轉換的結果(向量)反應出詞彙本身的語意(Semantic)。
一個理想的轉換應該是像底下這樣:
在這個 2 維空間裡頭,我們可以發現一個好的轉換有 2 個特性:
- 距離有意義:「喵咪」與意思相近的詞彙「貓」距離接近,而與較不相關的「狗」距離較遠
- 維度有意義:看看(狗, 貓)與(野狼, 老虎)這兩對組合,可以發現我們能將維度 1 解釋為貓科 VS 犬科;維度 2 解釋為寵物與野生動物
如果我們能把語料庫(Corpus)里頭的每個詞彙都表示成一個像是這樣有意義的詞向量,神經網路就能幫我們找到潛藏在大量詞彙中的語義關係,并進一步改善 NLP 任務的精準度。
好消息是,大部分的情況我們并不需要自己手動設定每個詞彙的詞向量。我們可以隨機初始化所有詞向量(如前述的隨機轉換),并利用平常訓練神經網路的反向傳播算法(Backpropagation),讓神經網路自動學到一組適合當前 NLP 任務的詞向量(如上張圖的理想狀態)。
反向傳播讓神經網路可以在訓練過程中修正參數,持續減少預測錯誤的可能性 (圖片來源)
在 NLP 里頭,這種將一個詞彙或句子轉換成一個實數詞向量(Vectors of real numbers)的技術被稱之為詞嵌入(Word Embedding)。
而在 Keras 裡頭,我們可以使用 Embedding
層來幫我們做到這件事情:
from keras import layers embedding_layer = layers.Embedding( MAX_NUM_WORDS, NUM_EMBEDDING_DIM)
MAX_NUM_WORDS
是我們的字典大小(10,000 個詞彙)、NUM_EMBEDDING_DIM
則是詞向量的維度。常見的詞向量維度有 128、256 或甚至 1,024。
Embedding
層一次接收 k 個長度任意的數字序列,并輸出 k 個長度相同的序列。輸出的序列中,每個元素不再是數字,而是一個 NUM_EMBEDDING_DIM
維的詞向量。
假如我們將第一筆(也就是 k = 1)假新聞標題 A 丟入 Embedding
層,并設定 NUM_EMBEDDING_DIM
為 3 的話,原來的標題 A:
就會被轉換成類似以下的形式:
序列裡頭的每個數字(即詞彙)都被轉換成一個 3 維的詞向量,而相同數字則當然都會對應到同一個詞向量(如前 3 個 0
所對應到的詞向量)。
Keras 的 Embedding Layer 讓我們可以輕鬆地將詞彙轉換成適合神經網路的詞向量 (圖片來源)
有了這樣的轉換,我們就能將轉換后的詞向量丟入 RNN / LSTM 里頭,讓模型逐步修正隨機初始化的詞向量,使得詞向量裡頭的值越來越有意義。
有了兩個新聞標題的詞向量,接著讓我們瞧瞧能夠處理這些數據的神經網路架構吧!
一個神經網路,兩個新聞標題
一般來說,多數你見過的神經網路只會接受一個資料來源:
- 輸入一張圖片,判斷是狗還是貓
- 輸入一個音訊,將其轉成文字
- 輸入一篇新聞,判斷是娛樂還是運動新聞
單一輸入的神經網路架構可以解決大部分的深度學習問題。但在這個 Kaggle 競賽裡頭,我們想要的是一個能夠讀入成對新聞標題,并判斷兩者之間關係的神經網路架構:
- 不相關(unrelated)
- 新聞 B 同意 A(agreed)
- 新聞 B 不同意 A(disagreed)
要怎麼做到這件事情呢?
我們可以使用孿生神經網路(Siamese Network)架構:
使用孿生神經網路架構來處理同類型的 2 個新聞標題
這張圖是本文最重要的一張圖,但現在你只需關注紅框的部分即可。剩馀細節我會在后面的定義神經網路的架構小節詳述。
重複觀察幾次,我相信你就會知道何謂孿生神經網路架構:一部份的神經網路(紅框部分)被重複用來處理多個不同的資料來源(在本篇中為 2 篇不同的新聞標題)。
而會想這樣做,是因為不管標題內容是新聞 A 還是新聞 B,其標題本身的語法 & 語義結構大同小異。
神經網路說到底,就跟其他機器學習方法相同,都是對輸入進行一連串有意義的數據轉換步驟。神經網路將輸入的數據轉換成更適合解決當前任務的數據格式,并利用轉換后的數據進行預測。
以這樣的觀點來看的話,我們并不需要兩個不同的 LSTM 來分別將新聞 A 以及新聞 B 的詞向量做有意義的轉換,而是只需要讓標題 A 與標題 B 共享一個 LSTM 即可。畢竟,標題 A 跟標題 B 的數據結構很像。
如果我們只寫一個 Python 函式就能處理 2 個相同格式的輸入的話,為何要寫 2 個函式呢?
孿生神經網路也是相同的概念。
孿生神經網路名稱概念來自 Siamese Twins,這是發生在美國 19 世紀的一對連體泰國人兄弟的故事。你可以想像孿生神經網路架構裡頭也有 2 個一模一樣的神經網路雙胞胎。(感謝網友 Hu Josh 糾正) (圖片來源)
好了,在了解如何同時讀入 2 個資料來源后,就讓我們實際用 Keras 動手將此模型建出來吧!