猿创征文丨深度学习基于双向LSTM模型完成文本分类任务
大家好,我是猿童學,本期猿創征文的第三期,也是最后一期,給大家帶來神經網絡中的循環神經網絡案例,基于雙向LSTM模型完成文本分類任務,數據集來自kaggle,對電影評論進行文本分類。
電影評論可以蘊含豐富的情感:比如喜歡、討厭、等等.情感分析(Sentiment Analysis)是為一個文本分類問題,即使用判定給定的一段文本信息表達的情感屬于積極情緒,還是消極情緒.
本實踐使用 IMDB 電影評論數據集,使用雙向 LSTM 對電影評論進行情感分析.
一、 數據處理
IMDB電影評論數據集是一份關于電影評論的經典二分類數據集.IMDB 按照評分的高低篩選出了積極評論和消極評論,如果評分 ≥7\ge 7≥7,則認為是積極評論;如果評分 ≤4\le4≤4,則認為是消極評論.數據集包含訓練集和測試集數據,數量各為 25000 條,每條數據都是一段用戶關于某個電影的真實評價,以及觀眾對這個電影的情感傾向,其目錄結構如下所示
├── train/├── neg # 消極數據 ├── pos # 積極數據├── unsup # 無標簽數據├── test/├── neg # 消極數據├── pos # 積極數據在test/neg目錄中任選一條電影評論數據,內容如下:
“Cover Girl” is a lacklustre WWII musical with absolutely nothing memorable about it, save for its signature song, “Long Ago and Far Away.”
LSTM 模型不能直接處理文本數據,需要先將文本中單詞轉為向量表示,稱為詞向量(Word Embedding).為了提高轉換效率,通常會事先把文本的每個單詞轉換為數字 ID,再使用第節中介紹的方法進行向量轉換.因此,需要準備一個詞典(Vocabulary),將文本中的每個單詞轉換為它在詞典中的序號 ID.同時還要設置一個特殊的詞 [UNK],表示未知詞.在處理文本時,如果碰到不在詞表的詞,一律按 [UNK] 處理.
1.1 數據加載
原始訓練集和測試集數據分別25000條,本節將原始的測試集平均分為兩份,分別作為驗證集和測試集,存放于./dataset目錄下。使用如下代碼便可以將數據加載至內存:
import os # 加載數據集 def load_imdb_data(path):assert os.path.exists(path) trainset, devset, testset = [], [], []with open(os.path.join(path, "train.txt"), "r") as fr:for line in fr:sentence_label, sentence = line.strip().lower().split("\t", maxsplit=1)trainset.append((sentence, sentence_label))with open(os.path.join(path, "dev.txt"), "r") as fr:for line in fr:sentence_label, sentence = line.strip().lower().split("\t", maxsplit=1)devset.append((sentence, sentence_label))with open(os.path.join(path, "test.txt"), "r") as fr:for line in fr:sentence_label, sentence = line.strip().lower().split("\t", maxsplit=1)testset.append((sentence, sentence_label))return trainset, devset, testset# 加載IMDB數據集 train_data, dev_data, test_data = load_imdb_data("./dataset/") # 打印一下加載后的數據樣式 print(train_data[4])(“the premise of an african-american female scrooge in the modern, struggling city was inspired, but nothing else in this film is. here, ms. scrooge is a miserly banker who takes advantage of the employees and customers in the largely poor and black neighborhood it inhabits. there is no doubt about the good intentions of the people involved. part of the problem is that story’s roots don’t translate well into the urban setting of this film, and the script fails to make the update work. also, the constant message about sharing and giving is repeated so endlessly, the audience becomes tired of it well before the movie reaches its familiar end. this is a message film that doesn’t know when to quit. in the title role, the talented cicely tyson gives an overly uptight performance, and at times lines are difficult to understand. the charles dickens novel has been adapted so many times, it’s a struggle to adapt it in a way that makes it fresh and relevant, in spite of its very relevant message.”, ‘0’)
從輸出結果看,加載后的每條樣本包含兩部分內容:文本串和標簽。
1.2 構造Dataset類
首先,我們構造IMDBDataset類用于數據管理,它繼承自paddle.io.DataSet類。
由于這里的輸入是文本序列,需要先將其中的每個詞轉換為該詞在詞表中的序號 ID,然后根據詞表ID查詢這些詞對應的詞向量,該過程同第同6.1節中將數字向量化的操作,在獲得詞向量后會將其輸入至模型進行后續計算??梢允褂肐MDBDataset類中的words_to_id方法實現這個功能。 具體而言,利用詞表word2id_dict將序列中的每個詞映射為對應的數字編號,便于進一步轉為為詞向量。當序列中的詞沒有包含在詞表時,默認會將該詞用[UNK]代替。words_to_id方法利用一個如圖6.14所示的哈希表來進行轉換。
圖6.14 word2id詞表示例代碼實現如下:
import paddle import paddle.nn as nn from paddle.io import Dataset from utils.data import load_vocabclass IMDBDataset(Dataset):def __init__(self, examples, word2id_dict):super(IMDBDataset, self).__init__()# 詞典,用于將單詞轉為字典索引的數字self.word2id_dict = word2id_dict# 加載后的數據集self.examples = self.words_to_id(examples)def words_to_id(self, examples):tmp_examples = []for idx, example in enumerate(examples):seq, label = example# 將單詞映射為字典索引的ID, 對于詞典中沒有的單詞用[UNK]對應的ID進行替代seq = [self.word2id_dict.get(word, self.word2id_dict['[UNK]']) for word in seq.split(" ")]label = int(label)tmp_examples.append([seq, label])return tmp_examplesdef __getitem__(self, idx):seq, label = self.examples[idx]return seq, labeldef __len__(self):return len(self.examples)# 加載詞表 word2id_dict= load_vocab("./dataset/vocab.txt") # 實例化Dataset train_set = IMDBDataset(train_data, word2id_dict) dev_set = IMDBDataset(dev_data, word2id_dict) test_set = IMDBDataset(test_data, word2id_dict)print('訓練集樣本數:', len(train_set)) print('樣本示例:', train_set[4])訓練集樣本數: 25000
樣本示例: ([2, 976, 5, 32, 6860, 618, 7673, 8, 2, 13073, 2525, 724, 14, 22837, 18, 164, 416, 8, 10, 24, 701, 611, 1743, 7673, 7, 3, 56391, 21652, 36, 271, 3495, 5, 2, 11373, 4, 13244, 8, 2, 2157, 350, 4, 328, 4118, 12, 48810, 52, 7, 60, 860, 43, 2, 56, 4393, 5, 2, 89, 4152, 182, 5, 2, 461, 7, 11, 7321, 7730, 86, 7931, 107, 72, 2, 2830, 1165, 5, 10, 151, 4, 2, 272, 1003, 6, 91, 2, 10491, 912, 826, 2, 1750, 889, 43, 6723, 4, 647, 7, 2535, 38, 39222, 2, 357, 398, 1505, 5, 12, 107, 179, 2, 20, 4279, 83, 1163, 692, 10, 7, 3, 889, 24, 11, 141, 118, 50, 6, 28642, 8, 2, 490, 1469, 2, 1039, 98975, 24541, 344, 32, 2074, 11852, 1683, 4, 29, 286, 478, 22, 823, 6, 5222, 2, 1490, 6893, 883, 41, 71, 3254, 38, 100, 1021, 44, 3, 1700, 6, 8768, 12, 8, 3, 108, 11, 146, 12, 1761, 4, 92295, 8, 2641, 5, 83, 49, 3866, 5352], 0)
1.3 封裝DataLoader
在構建 Dataset 類之后,我們構造對應的 DataLoader,用于批次數據的迭代.和前幾章的 DataLoader 不同,這里的 DataLoader 需要引入下面兩個功能:
對于長度限制,我們使用max_seq_len參數對于過長的文本進行截斷.
對于長度補齊,我們先統計該批數據中序列的最大長度,并將短的序列填充一些沒有特殊意義的占位符 [PAD],將長度補齊到該批次的最大長度,這樣便能使得同一批次的數據變得規整.比如給定兩個句子:
- 句子1: This movie was craptacular.
- 句子2: I got stuck in traffic on the way to the theater.
將上面的兩個句子補齊,變為:
- 句子1: This movie was craptacular [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]
- 句子2: I got stuck in traffic on the way to the theater
具體來講,本節定義了一個collate_fn函數來做數據的截斷和填充. 該函數可以作為回調函數傳入 DataLoader,DataLoader 在返回一批數據之前,調用該函數去處理數據,并返回處理后的序列數據和對應標簽。
另外,使用[PAD]占位符對短序列填充后,再進行文本分類任務時,默認無須使用[PAD]位置,因此需要使用變量seq_lens來表示序列中非[PAD]位置的真實長度。seq_lens可以在collate_fn函數處理批次數據時進行獲取并返回。需要注意的是,由于RunnerV3類默認按照輸入數據和標簽兩類信息獲取數據,因此需要將序列數據和序列長度組成元組作為輸入數據進行返回,以方便RunnerV3解析數據。
代碼實現如下:
from functools import partialdef collate_fn(batch_data, pad_val=0, max_seq_len=256):seqs, seq_lens, labels = [], [], []max_len = 0for example in batch_data:seq, label = example# 對數據序列進行截斷seq = seq[:max_seq_len]# 對數據截斷并保存于seqs中seqs.append(seq)seq_lens.append(len(seq))labels.append(label)# 保存序列最大長度max_len = max(max_len, len(seq))# 對數據序列進行填充至最大長度for i in range(len(seqs)):seqs[i] = seqs[i] + [pad_val] * (max_len - len(seqs[i]))return (paddle.to_tensor(seqs), paddle.to_tensor(seq_lens)), paddle.to_tensor(labels)下面我們自定義一批數據來測試一下collate_fn函數的功能,這里假定一下max_seq_len為5,然后定義序列長度分別為6和3的兩條數據,傳入collate_fn函數中。
max_seq_len = 5 batch_data = [[[1, 2, 3, 4, 5, 6], 1], [[2,4,6], 0]] (seqs, seq_lens), labels = collate_fn(batch_data, pad_val=word2id_dict["[PAD]"], max_seq_len=max_seq_len) print("seqs: ", seqs) print("seq_lens: ", seq_lens) print("labels: ", labels)seqs: Tensor(shape=[2, 5], dtype=int64, place=CPUPlace, stop_gradient=True,
[[1, 2, 3, 4, 5],
[2, 4, 6, 0, 0]])
seq_lens: Tensor(shape=[2], dtype=int64, place=CPUPlace, stop_gradient=True,
[5, 3])
labels: Tensor(shape=[2], dtype=int64, place=CPUPlace, stop_gradient=True,
[1, 0])
可以看到,原始序列中長度為6的序列被截斷為5,同時原始序列中長度為3的序列被填充到5,同時返回了非[PAD]的序列長度。
接下來,我們將collate_fn作為回調函數傳入DataLoader中, 其在返回一批數據時,可以通過collate_fn函數處理該批次的數據。 這里需要注意的是,這里通過partial函數對collate_fn函數中的關鍵詞參數進行設置,并返回一個新的函數對象作為collate_fn。
在使用DataLoader按批次迭代數據時,最后一批的數據樣本數量可能不夠設定的batch_size,可以通過參數drop_last來判斷是否丟棄最后一個batch的數據。
max_seq_len = 256 batch_size = 128 collate_fn = partial(collate_fn, pad_val=word2id_dict["[PAD]"], max_seq_len=max_seq_len) train_loader = paddle.io.DataLoader(train_set, batch_size=batch_size, shuffle=True, drop_last=False, collate_fn=collate_fn) dev_loader = paddle.io.DataLoader(dev_set, batch_size=batch_size, shuffle=False, drop_last=False, collate_fn=collate_fn) test_loader = paddle.io.DataLoader(test_set, batch_size=batch_size, shuffle=False, drop_last=False, collate_fn=collate_fn)二、模型構建
本實踐的整個模型結構如圖6.15所示.
圖6.15 基于雙向LSTM的文本分類模型結構由如下幾部分組成:
(1)嵌入層:將輸入的數字序列進行向量化,即將每個數字映射為向量。這里直接使用飛槳API:paddle.nn.Embedding來完成。
class paddle.nn.Embedding(num_embeddings, embedding_dim, padding_idx=None, sparse=False, weight_attr=None, name=None)
該API有兩個重要的參數:num_embeddings表示需要用到的Embedding的數量。embedding_dim表示嵌入向量的維度。
paddle.nn.Embedding會根據[num_embeddings, embedding_dim]自動構造一個二維嵌入矩陣。參數padding_idx是指用來補齊序列的占位符[PAD]對應的詞表ID,那么在訓練過程中遇到此ID時,其參數及對應的梯度將會以0進行填充。在實現中為了簡單起見,我們通常會將[PAD]放在詞表中的第一位,即對應的ID為0。
(2)雙向LSTM層:接收向量序列,分別用前向和反向更新循環單元。這里我們直接使用飛槳API:paddle.nn.LSTM來完成。只需要在定義LSTM時設置參數direction為bidirectional,便可以直接使用雙向LSTM。
思考: 在實現雙向LSTM時,因為需要進行序列補齊,在計算反向LSTM時,占位符[PAD]是否會對LSTM參數梯度的更新有影響。如果有的話,如何消除影響?
注:在調用paddle.nn.LSTM實現雙向LSTM時,可以傳入該批次數據的真實長度,paddle.nn.LSTM會根據真實序列長度處理數據,對占位符[PAD]進行掩蔽,[PAD]位置將返回零向量。
(3)聚合層:將雙向LSTM層所有位置上的隱狀態進行平均,作為整個句子的表示。
(4)輸出層:輸出層,輸出分類的幾率。這里可以直接調用paddle.nn.Linear來完成。
動手練習6.5:改進第6.3.1.1節中的LSTM算子,使其可以支持一個批次中包含不同長度的序列樣本。
上面模型中的嵌入層、雙向LSTM層和線性層都可以直接調用飛槳API來實現,這里我們只需要實現匯聚層算子。需要注意的是,雖然飛槳內置LSTM在傳入批次數據的真實長度后,會對[PAD]位置返回零向量,但考慮到匯聚層與處理序列數據的模型進行解耦,因此在本節匯聚層的實現中,會對[PAD]位置進行掩碼。
匯聚層算子
匯聚層算子將雙向LSTM層所有位置上的隱狀態進行平均,作為整個句子的表示。這里我們實現了AveragePooling算子進行隱狀態的匯聚,首先利用序列長度向量生成掩碼(Mask)矩陣,用于對文本序列中[PAD]位置的向量進行掩蔽,然后將該序列的向量進行相加后取均值。代碼實現如下:
將上面各個模塊匯總到一起,代碼實現如下:
class AveragePooling(nn.Layer):def __init__(self):super(AveragePooling, self).__init__()def forward(self, sequence_output, sequence_length):sequence_length = paddle.cast(sequence_length.unsqueeze(-1), dtype="float32")# 根據sequence_length生成mask矩陣,用于對Padding位置的信息進行maskmax_len = sequence_output.shape[1]mask = paddle.arange(max_len) < sequence_lengthmask = paddle.cast(mask, dtype="float32").unsqueeze(-1)# 對序列中paddling部分進行masksequence_output = paddle.multiply(sequence_output, mask)# 對序列中的向量取均值batch_mean_hidden = paddle.divide(paddle.sum(sequence_output, axis=1), sequence_length)return batch_mean_hidden模型匯總
將上面的算子匯總,組合為最終的分類模型。代碼實現如下:
class Model_BiLSTM_FC(nn.Layer):def __init__(self, num_embeddings, input_size, hidden_size, num_classes=2):super(Model_BiLSTM_FC, self).__init__()# 詞典大小self.num_embeddings = num_embeddings# 單詞向量的維度self.input_size = input_size# LSTM隱藏單元數量self.hidden_size = hidden_size# 情感分類類別數量self.num_classes = num_classes# 實例化嵌入層self.embedding_layer = nn.Embedding(num_embeddings, input_size, padding_idx=0)# 實例化LSTM層self.lstm_layer = nn.LSTM(input_size, hidden_size, direction="bidirectional")# 實例化聚合層self.average_layer = AveragePooling()# 實例化輸出層self.output_layer = nn.Linear(hidden_size * 2, num_classes)def forward(self, inputs):# 對模型輸入拆分為序列數據和maskinput_ids, sequence_length = inputs# 獲取詞向量inputs_emb = self.embedding_layer(input_ids)# 使用lstm處理數據sequence_output, _ = self.lstm_layer(inputs_emb, sequence_length=sequence_length)# 使用聚合層聚合sequence_outputbatch_mean_hidden = self.average_layer(sequence_output, sequence_length)# 輸出文本分類logitslogits = self.output_layer(batch_mean_hidden)return logits三、模型訓練
本節將基于RunnerV3進行訓練,首先指定模型訓練的超參,然后設定模型、優化器、損失函數和評估指標,其中損失函數使用paddle.nn.CrossEntropyLoss,該損失函數內部會對預測結果使用softmax進行計算,數字預測模型輸出層的輸出logits不需要使用softmax進行歸一化,定義完Runner的相關組件后,便可以進行模型訓練。代碼實現如下。
import time import random import numpy as np from nndl import Accuracy, RunnerV3np.random.seed(0) random.seed(0) paddle.seed(0)# 指定訓練輪次 num_epochs = 3 # 指定學習率 learning_rate = 0.001 # 指定embedding的數量為詞表長度 num_embeddings = len(word2id_dict) # embedding向量的維度 input_size = 256 # LSTM網絡隱狀態向量的維度 hidden_size = 256# 實例化模型 model = Model_BiLSTM_FC(num_embeddings, input_size, hidden_size) # 指定優化器 optimizer = paddle.optimizer.Adam(learning_rate=learning_rate, beta1=0.9, beta2=0.999, parameters= model.parameters()) # 指定損失函數 loss_fn = paddle.nn.CrossEntropyLoss() # 指定評估指標 metric = Accuracy() # 實例化Runner runner = RunnerV3(model, optimizer, loss_fn, metric) # 模型訓練 start_time = time.time() runner.train(train_loader, dev_loader, num_epochs=num_epochs, eval_steps=10, log_steps=10, save_path="./checkpoints/best.pdparams") end_time = time.time() print("time: ", (end_time-start_time))[Train] epoch: 0/3, step: 0/588, loss: 0.69294
繪制訓練過程中在訓練集和驗證集上的損失圖像和在驗證集上的準確率圖像:
from nndl import plot_training_loss_acc# 圖像名字 fig_name = "./images/6.16.pdf" # sample_step: 訓練損失的采樣step,即每隔多少個點選擇1個點繪制 # loss_legend_loc: loss 圖像的圖例放置位置 # acc_legend_loc: acc 圖像的圖例放置位置 plot_training_loss_acc(runner, fig_name, fig_size=(16,6), sample_step=10, loss_legend_loc="lower left", acc_legend_loc="lower right")圖6.16 展示了文本分類模型在訓練過程中的損失曲線和在驗證集上的準確率曲線,其中在損失圖像中,實線表示訓練集上的損失變化,虛線表示驗證集上的損失變化. 可以看到,隨著訓練過程的進行,訓練集的損失不斷下降, 驗證集上的損失在大概200步后開始上升,這是因為在訓練過程中發生了過擬合,可以選擇保存在訓練過程中在驗證集上效果最好的模型來解決這個問題. 從準確率曲線上可以看到,首先在驗證集上的準確率大幅度上升,然后大概200步后準確率不再上升,并且由于過擬合的因素,在驗證集上的準確率稍微降低。
圖6.16 文本分類模型訓練損失變化圖四、模型評價
加載訓練過程中效果最好的模型,然后使用測試集進行測試。
model_path = "./checkpoints/best.pdparams" runner.load_model(model_path) accuracy, _ = runner.evaluate(test_loader) print(f"Evaluate on test set, Accuracy: {accuracy:.5f}")五、模型預測
給定任意的一句話,使用訓練好的模型進行預測,判斷這句話中所蘊含的情感極性。
id2label={0:"消極情緒", 1:"積極情緒"} text = "this movie is so great. I watched it three times already" # 處理單條文本 sentence = text.split(" ") words = [word2id_dict[word] if word in word2id_dict else word2id_dict['[UNK]'] for word in sentence] words = words[:max_seq_len] sequence_length = paddle.to_tensor([len(words)], dtype="int64") words = paddle.to_tensor(words, dtype="int64").unsqueeze(0) # 使用模型進行預測 logits = runner.predict((words, sequence_length)) max_label_id = paddle.argmax(logits, axis=-1).numpy()[0] pred_label = id2label[max_label_id] print("Label: ", pred_label)六、小結
本章通過實踐來加深對循環神經網絡的基本概念、網絡結構和長程依賴問題問題的理解.我們構建一個數字求和任務,并動手實現了 SRN 和 LSTM 模型,對比它們在數字求和任務上的記憶能力.在實踐部分,我們利用雙向 LSTM 模型來進行文本分類任務:IMDB 電影評論情感分析,并了解如何通過嵌入層將文本數據轉換為向量表示.
總結
以上是生活随笔為你收集整理的猿创征文丨深度学习基于双向LSTM模型完成文本分类任务的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java进阶之--------集合2
- 下一篇: 【需求】Python利用selenium