9.2 文本分類-RNN-LSTM
前言
本節介紹RNN和LSTM,並採用它們在電影評論資料集上實現文本分類,本小節會涉及以下幾個知識點。
- 詞表構建:包括資料清洗,詞頻統計,詞頻截斷,詞表構建
- 預訓練詞向量應用:下載並載入Glove的預訓練embedding進行訓練,主要是如何把詞向量放到nn.embedding層中的權重。
- RNN及LSTM構建:涉及nn.RNN和nn.LSTM的使用
任務介紹
本節採用的資料集是斯坦福大學的大型電影評論資料集(large movie review dataset) https://ai.stanford.edu/~amaas/data/sentiment/
包含25000個訓練樣本,25000個測試樣本,下載解壓後得到aclImdb資料夾,aclImdb下有train和test,neg和pos下分別有txt檔,txt中為電影評論文本。
來看看一條具體的樣本,train/pos/3_10.txt:
"All the world's a stage and its people actors in it"--or something like that. Who the hell said that theatre stopped at the orchestra pit--or even at the theatre door? Why is not the audience participants in the theatrical experience, including the story itself?<br /><br />This film was a grand experiment that said: "Hey! the story is you and it needs more than your attention, it needs your active participation". "Sometimes we bring the story to you, sometimes you have to go to the story."<br /><br />Alas no one listened, but that does not mean it should not have been said.
Copy
本節任務就是對這樣的一條文本進行處理,輸出積極/消極的二分類概率向量。
資料模組
文本任務與圖像任務不同,輸入不再是圖元這樣的數值,而是字串,因此需要將字串轉為矩陣運算可接受的向量形式。
為此需要在資料處理模組完成以下步驟:
- 分詞:將一長串文本切分為一個個獨立語義的詞,英文可用空格來切分。
- 詞嵌入:詞嵌入通常分兩步。首先將詞字串轉為索引序號,然後索引序號根據詞嵌入矩陣(embedding層)取對應的向量。其中詞與索引之間的映射關係需要提前構建,這就是詞表構建的過程。
因此,代碼開發整體流程:
- 編寫分詞功能函數
- 構建詞表:對訓練資料進行分詞,統計詞頻,並構建詞表。例如{'UNK': 0, 'PAD': 1, 'the': 2, '.': 3, 'and': 4, 'a': 5, 'of': 6, 'to': 7, ...}
- 編寫PyTorch的Dataset,實現分詞、詞轉序號、長度填充/截斷
序號轉詞向量的過程由模型的nn.Embedding層實現,因此資料模組只需將詞變為索引序號即可,接下來一一解析各環節核心功能代碼實現。
詞表構建
參考配套代碼a_gen_vocabulary.py,首先編寫分詞功能函數,分詞前做一些簡單的資料清洗,例如在標點符號前加入空格、去除掉不是大小寫字母及 .!? 符號的資料。
def text_split(content: str) -> List[str]:
content = re.sub(r"([.!?])", r" \1", content) # 在 .!? 之前添加一個空格
content = re.sub(r"[^a-zA-Z.!?]+", r" ", content) # 去除掉不是大小寫字母及 .!? 符號的資料
token = [i.strip().lower() for i in content.split()] # 全部轉換為小寫,然後去除兩邊空格,將字串轉換成list,
return token
Copy
接著,寫一個詞表統計類實現詞頻統計,和詞表字典的創建,代碼注釋非常詳細,這裡不贅述。
運行代碼,即可完成詞頻統計,詞表的構建,並保存到本地npy檔,在訓練及推理過程中使用。
class Vocabulary:
UNK_TAG = "UNK" # 遇到未知字元,用UNK表示
PAD_TAG = "PAD" # 用PAD補全句子長度
UNK = 0 # UNK字元對應的數位
PAD = 1 # PAD字元對應的數位
def __init__(self):
self.inverse_vocab = None
self.vocabulary = {self.UNK_TAG: self.UNK, self.PAD_TAG: self.PAD}
self.count = {} # 統計詞頻、
def fit(self, sentence_: List[str]):
def build_vocab(self, min=0, max=None, max_vocab_size=None) -> Tuple[dict, dict]
Copy
在詞表構建過程中有一個截斷數量的超參數需要設置,這裡設置為20000,即最多有20000個詞的表示,不在字典中的詞被歸為UNK這個詞。
在這個資料集中,原始詞表長度為74952,即通過split切分後,有7萬多個不一樣的字串,通常可以通過降冪排列,取前面一部分即可。
代碼會輸出詞頻統計圖,也可以觀察出詞頻下降的速度以及高頻詞是哪些。
Dataset編寫
參考配套代碼aclImdb_dataset.py,getitem中主要做兩件事,首先獲取label,然後獲取文本預處理後的清單,清單中元素是詞所對應的索引序號。
def __getitem__(self, item):
# 讀取檔路徑
file_path = self.total_file_path[item]
# 獲取 label
label = 0 if os.path.basename(os.path.dirname(file_path)) == "neg" else 1 # neg -> 0; pos -> 1
# tokenize & encode to index
token_list = text_split(open(file_path, encoding='utf-8').read()) # 切分
token_idx_list = self.word2index.encode(token_list, self.vocab, self.max_len)
return np.array(token_idx_list), label
Copy
在self.word2index.encode中需要注意設置文本最大長度self.max_len,這是由於需要將所有文本處理到相同長度,長度不足的用詞填充,長度超出則截斷。
模型模組——RNN
模型的構建相對簡單,理論知識在這裡不介紹,需要瞭解和溫習的推薦看看《動手學》。這裡借助動手學的RNN圖片講解代碼的實現。
在構建的模型RNNTextClassifier中,需要三個子module,分別是:
- nn.Embedding:將詞序號變為詞向量,用於後續矩陣運算
- nn.RNN:迴圈神經網路的實現
- nn.Linear:最終分類輸出層的實現
在forward時,流程如下:
- 獲取詞向量
- 構建初始化隱藏層,默認為全0
- rnn推理獲得輸出層和隱藏層
- fc層輸出分類概率:fc層的輸入是rnn最後一個隱藏層
def forward(self, x):
x_embed = self.embedding(x) # [batch_size, max_len] -> [batch_size, text_len, embed_len]
bs_, text_len, embed_len = x_embed.shape
hidden_init = self.init_hidden(bs_)
outputs, hidden = self.rnn(x_embed, hidden_init)
# Extract the last hidden state
last_hidden = hidden[-1].squeeze(0) # [num_layers, bs, hidden_size] -> [bs, hidden_size]
fc_output = self.fc(last_hidden)
return fc_output
Copy
更多關於nn.RNN的參數設置,可以參考官方文檔:torch.nn.RNN(self, input_size, hidden_size, num_layers=1, nonlinearity='tanh', bias=True, batch_first=False, dropout=0.0, bidirectional=False, device=None, dtype=None)
模型模組——LSTM
RNN是神經網路中處理時序任務最為經典的設計,但是其也存在一些缺點,例如梯度消失和梯度爆炸,以及長期依賴問題。
當序列很長時,RNN模型很難捕捉到遠距離的依賴關係,導致模型預測不準確。
為此,帶門控機制的RNN湧現,包括GRU(Gated Recurrent Unit,門控迴圈單元)和LSTM(Long Short-Term Memory,長短期記憶網路),其中LSTM應用最廣,這裡直接跳過GRU。
LSTM模型引入了三個門(input gate、forget gate和output gate),用於控制輸入、輸出和遺忘的流動,允許模型有選擇性地忘記或記住一些資訊。
- input gate用於控制輸入的流動
- forget gate用於控制遺忘的流動
- output gate用於控制輸出的流動
相較於RNN,除了輸出隱藏層向量h,還輸出記憶層向量c,不過對於下游使用,不需要關心向量c的存在。
同樣地,借助《動手學》中的LSTM示意圖來理解代碼。
在這裡,借鑒《動手學》的代碼,採用的LSTM為雙向LSTM,這裡簡單介紹雙向迴圈神經網路的概念。
雙向迴圈神經網路(Bidirectional Recurrent Neural Network,Bi-RNN)同時考慮前向和後向的上下文資訊,前向層和後向層的輸出在每個時間步驟上都被連接起來,形成了一個綜合的輸出,這樣可以更好地捕捉序列中的上下文資訊。
在pytorch代碼中,只需要將bidirectional設置為True即可,nn.LSTM(embed_size, num_hiddens, num_layers=num_layers, bidirectional=True)。
當採用雙向時,需要注意output矩陣的shape為 [ sequence length , batch size ,2×hidden size]
更多關於nn.LSTM的參數設置,可以參考官方文檔:torch.nn.LSTM(self, input_size, hidden_size, num_layers=1, bias=True, batch_first=False, dropout=0.0, bidirectional=False, proj_size=0, device=None, dtype=None)
詳細參考:https://pytorch.org/docs/stable/generated/torch.nn.LSTM.html#torch.nn.LSTM
embedding預訓練載入
模型構建好之後,詞向量的embedding層是隨機初始化的,要從頭訓練具備一定邏輯關係的詞向量表示是費時費力的,通常可以採用在大規模預料上訓練好的詞向量矩陣。
這裡可以參考斯坦福大學的GloVe(Global Vectors for Word Representation)預訓練詞向量。
GloVe是一種無監督學習演算法,用於獲取單詞的向量表示,GloVe預訓練詞向量可以有效地捕捉單詞之間的語義關係,被廣泛應用于自然語言處理領域的各種任務,例如文本分類、命名實體識別和機器翻譯等。
Glove有四大類,根據資料量不同進行區分,相同資料下又根據向量長度分
- Wikipedia 2014 + Gigaword 5 (6B tokens, 400K vocab, uncased, 50d, 100d, 200d, & 300d vectors, 822 MB download): glove.6B.zip
- Common Crawl (42B tokens, 1.9M vocab, uncased, 300d vectors, 1.75 GB download): glove.42B.300d.zip
- Common Crawl (840B tokens, 2.2M vocab, cased, 300d vectors, 2.03 GB download): glove.840B.300d.zip
- Twitter (2B tweets, 27B tokens, 1.2M vocab, uncased, 25d, 50d, 100d, & 200d vectors, 1.42 GB download): glove.twitter.27B.zip
在這裡,採用Wikipedia 2014 + Gigaword 5 中的100d,即詞向量長度為100,向量的token數量有6B。
下載好的GloVe詞向量矩陣是一個txt檔,一行是一個詞和詞向量,中間用空格隔開,因此載入該預訓練詞向量矩陣可以這樣。
def load_glove_vectors(glove_file_path, word2idx):
"""
載入預訓練詞向量權重
:param glove_file_path:
:param word2idx:
:return:
"""
with open(glove_file_path, 'r', encoding='utf-8') as f:
vectors = {}
for line in f:
split = line.split()
word = split[0]
vector = torch.FloatTensor([float(num) for num in split[1:]])
vectors[word] = vector
return vectors
Copy
原始GloVe預訓練詞向量有40萬個詞,在這裡只關心詞表中有的詞,因此可以在載入字典時加一行過濾,即在詞表中的詞,才去獲取它的詞向量。
if word in word2idx:
vector = torch.FloatTensor([float(num) for num in split[1:]])
vectors[word] = vector
Copy
在本案例中,詞表大小是2萬,根據匹配,只有19720個詞在GloVe中找到了詞向量,其餘的詞向量就需要隨機初始化。
獲取GloVe預訓練詞向量字典後,需要把詞向量放到embedding層中的矩陣,對弈embedding層來說,一行是一個詞的詞向量,因此通過詞表的序號找到對應的行,然後把預訓練詞向量放進去即可,代碼如下:
word2idx = np.load(vocab_path, allow_pickle=True).item() # 詞表順序仍舊根據訓練集統計得到的詞表順序
for word, idx in word2idx.items():
if word in glove_vectors:
model.embedding.weight.data[idx] = glove_vectors[word]
if args.is_freeze:
model.embedding.weight.requires_grad = False # embedding層是否還需要更新
Copy
訓練及實驗記錄
準備好了資料和模型,接下來按照常規模型訓練即可。
這裡將會做一些對比實驗,包括模型對比:
- RNN vs LSTM
- 有預訓練詞向量 vs 無預訓練詞向量
- 凍結預訓練詞向量 vs 放開預訓練詞向量
具體指令如下,推薦放到bash文件中,一次性跑
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode rnn --glove-file-path ""
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode rnn --glove-file-path /workspace/data/glove_6B/glove.6B.100d.txt --is-freeze
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode rnn --glove-file-path /workspace/data/glove_6B/glove.6B.100d.txt
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode lstm --glove-file-path ""
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode lstm --glove-file-path /workspace/data/glove_6B/glove.6B.100d.txt --is-freeze
python train_main.py --data-path /workspace/data/aclImdb --batch-size 64 --epochs 50 --lr 0.01 --model-mode lstm --glove-file-path /workspace/data/glove_6B/glove.6B.100d.txt
Copy
實驗結果如下所示:
- RNN整體不work,經過分析發現設置的文本token長度太長,導致RNN梯度消失,以至於無法訓練。調整text_max_len為50後,train acc=0.8+, val=0.62,整體效果較差。
- 有了預訓練詞向量要比沒有預訓練詞向量高出10多個點
- 放開詞向量訓練,效果會好一些,但是不明顯
RNN(text_len=500) |
LSTM |
RNN(text_len=50) |
|
---|---|---|---|
random init embedding |
0.50 |
0.53, 0.70 |
0.58 |
FREEZE-glove 6B 100d |
0.50 |
0.85 |
0.67 |
TRAIN-glove 6B 100d |
0.50 |
0.88 |
0.67 |
補充實驗:將RNN模型的文本最長token數量設置為50,其餘保持不變,得到的三種embedding方式的結果如下:
結論:
- LSTM較RNN在長文本處理上效果更好
- 預訓練詞向量在小樣本資料集上很關鍵,有10多個點的提升
- 放開與凍結embedding層訓練,效果差不多
小結
本小節通過電影影評資料集實現文本分類任務,通過該任務可以瞭解:
- 文本預處理機制:包括清洗、分詞、詞頻統計、詞表構建、詞表截斷、UNK與PAD特殊詞設定等
- 預訓練詞向量使用:包括GloVe的下載及載入、nn.embedding層的設置
- RNN系列網路模型使用:大致瞭解迴圈神經網路的輸入/輸出是如何構建,如何配合fc層實現文本分類
- RNN可接收的文本長度有限:文本過長,導致梯度消失,文本過短,導致無法捕獲更多文本資訊,因此推薦採用LSTM等門控機制的模型
這一小節是經典的seq2cls的任務,下一小節,將對seq2seq進行介紹。