《NLP情感分析》(三)——Update
2:Updated情感分析
在上一篇文章中我們已經學習了情感分析的基本工作流程,下面我們將學習如何改進優化模型:涉及到如何使用壓縮填充序列、加載和使用預訓練詞向量、採用不同的優化器、選擇不同的RNN體系結構(包括雙向RNN、多層RNN)和正則化。
本章主要內容如下: - 序列填充 - 預訓練詞嵌入 - LSTM - 雙向 RNN - 多層 RNN - 正則化 - 優化
2.1 準備數據
首先設置seed,並將其分類訓練、測試、驗證集。
在準備數據的時候需要注意到,由於 RNN 只能處理序列中的非 padded 元素(即非0數據),對於任何 padded 元素輸出都是 0 。所以注意到我們在準備數據的時候將include_length設置為True,以獲得句子的實際長度,後續需要使用。
```python import torch from torchtext.legacy import data
SEED = 1234
torch.manual_seed(SEED) torch.backends.cudnn.deterministic = True
TEXT = data.Field(tokenize = 'spacy', tokenizer_language = 'en_core_web_sm', include_lengths = True)
LABEL = data.LabelField(dtype = torch.float) ```
加載 IMDb 數據集
```python from torchtext.legacy import datasets
train_data, test_data = datasets.IMDB.splits(TEXT, LABEL) ```
從訓練集中選取部分做驗證集
```python import random
train_data, valid_data = train_data.split(random_state = random.seed(SEED)) ```
2.2 詞向量
接下來,使用預訓練詞向量進行初始化操作,其中獲取這些詞向量是通過指定參數傳遞給 build_vocab 得到的。
在這裏,我們選取GloVe詞向量,GloVe的全稱是:Global Vectors for Word Representation。此處有關於其有詳細的介紹和大量資源。本教程將不介紹該詞向量是如何具體得到的,僅簡單描述下如何使用此詞向量,這裏我們使用的是 "glove.6B.100d" ,其中,6B表示詞向量是在60億規模的tokens上訓練得到的,100d表示詞向量是100維的(注意,這個詞向量有800多兆)
當然也可以選擇其他的詞向量。理論上,這些預訓練詞向量在詞嵌入向量空間中的距離在一定程度上表徵了詞之間的語義關係,例如,“terrible”、“awful”、“dreadful” ,它們的詞嵌入向量空間的距離會非常近。
TEXT.build_vocab表示從預訓練的詞向量中,將當前訓練數據中的詞彙的詞向量抽取出來,構成當前訓練集的 Vocab(詞彙表)。對於當前詞向量語料庫中沒有出現的單詞(記為UNK,unknown),通過高斯分佈隨機初始化(unk_init = torch.Tensor.normal_)。
```python MAX_VOCAB_SIZE = 25_000
TEXT.build_vocab(train_data, max_size = MAX_VOCAB_SIZE, vectors = "glove.6B.100d", unk_init = torch.Tensor.normal_)
LABEL.build_vocab(train_data) ```
2.3 創建迭代器+選取GPU
```python BATCH_SIZE = 64
根據當前環境選擇是否調用GPU進行訓練
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
創建數據迭代器
train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits( (train_data, valid_data, test_data), batch_size = BATCH_SIZE, sort_within_batch = True, device = device) ```
2.4 構建模型
LSTM
LSTM是標準RNN的一種變體,它增加了一種攜帶信息跨越多個時間步的方法,一定程度上克服了標準的RNN存在梯度消失的問題。 具體來説,LSTM加入記憶單元$c_t$,其可看作是LSTM的“內存”,存儲了時刻$t$時LSTM的記憶,可以認為其中保存了從過去到時刻$t$的所有必要信息,同時使用多個門控制信息流入和流出內存,具體地看此處。 因此我們可以將LSTM看作是$x_t$、$h_t$和$c_t$的函數,而不僅僅是$x_t$和$h_t$。
$$(h_t, c_t) = \text{LSTM}(x_t, h_t, c_t)$$
因此,使用LSTM的模型結構看起來類似下面這種構造(省略了嵌入層):
與初始隱藏狀態一樣,初始記憶狀態$c_0$初始化為全零張量。需要注意的是,情感預測只使用最終隱藏狀態,而不是最終記憶單元狀態,即 $\hat{y}=f(h_T)$。
雙向RNN
雙向RNN在之前的標準RNN層上添加了一個反方向處理的RNN層。然後,拼接各個時刻的兩個RNN層的隱藏狀態,將其作為最後的隱藏狀態向量。也就是在時間步$t$時,前向RNN處理單詞$x_t$,後向RNN處理單詞$x_{T-t+1}$。通過這樣的雙向處理,每個單詞對應的隱藏狀態可以從左右兩個方向聚集信息,這樣一來,這些向量就編碼了更均衡的信息。
我們使用前向RNN的最後一個隱藏狀態(從句子的最後一個單詞獲得)$h_T^\rightarrow$和後向RNN的最後一個隱藏狀態(從句子的第一個單詞獲得)$h_T^\leftarrow$進行情感預測,即 $\hat{y}=f(h_T^\rightarrow, h_T^\leftarrow)$,下圖顯示了一個雙向RNN,前向RNN為橙色,後向RNN為綠色,線性層為銀色。
多層RNN
多層RNN(也稱為深層RNN):在初始標準RNN上多加幾層RNN。第一個(底部)RNN在時間步$t$時輸出的隱藏狀態將是在時間步$t$時其上方RNN的輸入,然後根據最終(最高)層的最終隱藏狀態進行預測。 下圖顯示了多層單向RNN,其中層號以上標形式給出。還要注意,每個層都需要自己的初始隱藏狀態,$h_0^L$。
正則化
我們已經對模型進行了各方面的改進,但是我們需要注意的是,當模型的參數逐漸增加的同時,模型過擬合的可能性就越大。為了解決這個問題,我們添加dropout正則化。Dropout的工作原理是在前向傳播過程中,層中的神經元隨機Dropout(設置為0)。每個神經元是否被drop的概率則由一個超參數設置,並不受其他神經元影響。
關於為什麼dropout有效的一種理論是,參數dropout的模型可以被視為“weaker”(參數較少)的模型。因此,最終的模型可以被認為是所有這些weaker模型的集合,這些模型都沒有過度參數化,因此降低了過擬合的可能性。
Implementation Details
1.針對模型訓練過程中的一點補充:在模型訓練過程中,對於每個樣本中補齊後加上的pad token,模型是不應該對其進行訓練的,也就是並不會學習“\<pad>”標記的嵌入。因為padding token跟句子的情感是無關的。這就意味着pad token的嵌入層(詞向量)會一直保持初始化的狀態(初始化為全零)。具體而言,我們是通過往nn.Embedding 層傳入pad token 的index索引,作為padding_idx參數。
2.因為實驗中使用的雙向LSTM的包含了前向傳播和後向傳播過程,所以最後的隱藏狀態向量包含了前向和後向的隱藏狀態,所以在下一層nn.Linear層中的輸入的形狀就是隱藏層維度形狀的兩倍。
3.在將embeddings(詞向量)輸入RNN前,我們需要藉助nn.utils.rnn.packed_padded_sequence
將它們‘打包’,以此來保證RNN只會處理不是pad的token。我們得到的輸出包括packed_output
(a packed sequence)以及hidden sate
和 cell state
。如果沒有進行‘打包’操作,那麼輸出的hidden state
和cell state
大概率是來自句子的pad token。如果使用packed padded sentences,輸出的就會是最後一個非padded元素的hidden state
和 cell state
。
4.之後我們藉助nn.utils.rnn.pad_packed_sequence
將輸出的句子‘解壓’轉換成一個tensor張量。需要注意的是來自padding tokens的輸出是零張量,通常情況下,我們只有在後續的模型中使用輸出時才需要‘解壓’。雖然在本案例中下不需要,這裏只是為展示其步驟。
5.final hidden sate:也就是hidden,其形狀是[num layers * num directions, batch size, hid dim]。因為我們只要最後的前向和後向傳播的hidden states,我們只要最後2個hidden layers就行hidden[-2,:,:] 和hidden[-1,:,:],然後將他們合併在一起,再傳入線性層linear layer。
這裏不知道怎麼解釋會比較好,還需調整。
```python import torch.nn as nn
class RNN(nn.Module): def init(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, bidirectional, dropout, pad_idx):
super().__init__()
# embedding嵌入層(詞向量)
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
# RNN變體——雙向LSTM
self.rnn = nn.LSTM(embedding_dim, # input_size
hidden_dim, #output_size
num_layers=n_layers, # 層數
bidirectional=bidirectional, #是否雙向
dropout=dropout) #隨機去除神經元
# 線性連接層
self.fc = nn.Linear(hidden_dim * 2, output_dim) # 因為前向傳播+後向傳播有兩個hidden sate,且合併在一起,所以乘以2
# 隨機去除神經元
self.dropout = nn.Dropout(dropout)
def forward(self, text, text_lengths):
#text 的形狀 [sent len, batch size]
embedded = self.dropout(self.embedding(text))
#embedded 的形狀 [sent len, batch size, emb dim]
# pack sequence
# lengths need to be on CPU!
packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, text_lengths.to('cpu'))
packed_output, (hidden, cell) = self.rnn(packed_embedded)
#unpack sequence
output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output)
#output的形狀[sent len, batch size, hid dim * num directions]
#output中的 padding tokens是數值為0的張量
#hidden 的形狀 [num layers * num directions, batch size, hid dim]
#cell 的形狀 [num layers * num directions, batch size, hid dim]
#concat the final forward (hidden[-2,:,:]) and backward (hidden[-1,:,:]) hidden layers
#and apply dropout
hidden = self.dropout(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1))
#hidden 的形狀 [batch size, hid dim * num directions]
return self.fc(hidden)
```
2.5 實例化模型+傳入參數
為了保證pre-trained 詞向量可以加載到模型中,EMBEDDING_DIM 必須等於預訓練的GloVe詞向量的大小。
```python INPUT_DIM = len(TEXT.vocab) # 250002: 之前設置的只取25000個最頻繁的詞,加上pad_token和unknown token EMBEDDING_DIM = 100 HIDDEN_DIM = 256 OUTPUT_DIM = 1 N_LAYERS = 2 BIDIRECTIONAL = True DROPOUT = 0.5 PAD_IDX = TEXT.vocab.stoi[TEXT.pad_token] #指定參數,定義pad_token的index索引值,讓模型不管pad token
model = RNN(INPUT_DIM, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM, N_LAYERS, BIDIRECTIONAL, DROPOUT, PAD_IDX) ```
查看模型參數量
```python def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f'The model has {count_parameters(model):,} trainable parameters') ```
接下來,把前面加載好的預訓練詞向量複製進我們模型中的embedding嵌入層,用預訓練的embeddings詞向量替換掉原來模型初始化的權重參數。
我們從字段的vocab中檢索嵌入,並檢查它們的大小是否正確,[vocab size, embedding dim]
```python pretrained_embeddings = TEXT.vocab.vectors
檢查詞向量形狀 [vocab size, embedding dim]
print(pretrained_embeddings.shape) ```
```python
用預訓練的embedding詞向量替換原始模型初始化的權重參數
model.embedding.weight.data.copy_(pretrained_embeddings) ```
因為我們的<unk>
和<pad>
token不在預訓練詞表裏,它們已經在構建我們自己的詞表時,使用 unk_init
(an $\mathcal{N}(0,1)$ distribution)初始化了。所以,最好顯式地告訴模型,將它們初始化變為0,它們與情感無關。
我們是通過手動設置他們的詞向量權重為0的。
注意:與初始化嵌入一樣,這應該在“weight.data”而不是“weight”上完成!
```python
將unknown 和padding token設置為0
UNK_IDX = TEXT.vocab.stoi[TEXT.unk_token]
model.embedding.weight.data[UNK_IDX] = torch.zeros(EMBEDDING_DIM) model.embedding.weight.data[PAD_IDX] = torch.zeros(EMBEDDING_DIM)
print(model.embedding.weight.data) ```
我們現在可以看到嵌入權重矩陣的前兩行值都是0,需要注意的是,pad token的詞向量在模型訓練過程中始終不會被學習。而unknown token的詞向量是會被學習的。
2.6 訓練模型
現在開始訓練模型!
我們是將隨機梯度下降優化器從'SGD'更改為'Adam'。SGD使用我們設定好的學習率同步更新所有參數,而Adam會調整每個參數的學習率,給出更新頻率更高的參數,以及更新頻率更低的參數和更新頻率不高的參數。有關“Adam”(和其他優化器)的更多信息,請參見此處。
要將'SGD'更改為'Adam',我們只需將'optim.SGD'更改為'optim.Adam',還要注意,我們不提供 Adam初始學習率,因為PyTorch提供了默認的初始學習率。
2.6.1 設置優化器
```python import torch.optim as optim
optimizer = optim.Adam(model.parameters()) ```
2.6.2 設置損失函數和GPU
訓練模型的其它步驟保持不變。
```python criterion = nn.BCEWithLogitsLoss() # 損失函數. criterion 在本task中時損失函數的意思
model = model.to(device) criterion = criterion.to(device) ```
2.6.3 計算精確度
```python def binary_accuracy(preds, y): """ Returns accuracy per batch, i.e. if you get 8/10 right, this returns 0.8, NOT 8 """
#round predictions to the closest integer
rounded_preds = torch.round(torch.sigmoid(preds))
correct = (rounded_preds == y).float() #convert into float for division
acc = correct.sum() / len(correct)
return acc
```
2.6.4 定義一個訓練函數,用來訓練模型
正如我們設置的“include_length=True”,我們的“batch.text”現在是一個元組,第一個元素是數字張量,第二個元素是每個序列的實際長度。在將它們傳遞給模型之前,我們將它們分成各自的變量“text”和“text_length”。
注意:因為現在使用的是dropout,我們必須記住使用model.train()
以確保在訓練時開啟 dropout。
```python def train(model, iterator, optimizer, criterion):
epoch_loss = 0
epoch_acc = 0
model.train()
for batch in iterator:
optimizer.zero_grad() # 梯度清零
text, text_lengths = batch.text # batch.text返回的是一個元組(數字化的張量,每個句子的長度)
predictions = model(text, text_lengths).squeeze(1)
loss = criterion(predictions, batch.label)
acc = binary_accuracy(predictions, batch.label)
loss.backward()
optimizer.step()
epoch_loss += loss.item()
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator)
```
2.6.5 定義一個測試函數
注意:因為現在使用的是dropout,我們必須記住使用model.eval()
以確保在評估時關閉 dropout。
```python def evaluate(model, iterator, criterion):
epoch_loss = 0
epoch_acc = 0
model.eval()
with torch.no_grad():
for batch in iterator:
text, text_lengths = batch.text #batch.text返回的是一個元組(數字化的張量,每個句子的長度)
predictions = model(text, text_lengths).squeeze(1)
loss = criterion(predictions, batch.label)
acc = binary_accuracy(predictions, batch.label)
epoch_loss += loss.item()
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator)
```
還可以創建一個函數告訴我們epochs訓練需要多長時間。
```python import time
def epoch_time(start_time, end_time): elapsed_time = end_time - start_time elapsed_mins = int(elapsed_time / 60) elapsed_secs = int(elapsed_time - (elapsed_mins * 60)) return elapsed_mins, elapsed_secs ```
2.6.6 正式訓練模型
```python N_EPOCHS = 5
best_valid_loss = float('inf')
for epoch in range(N_EPOCHS):
start_time = time.time()
train_loss, train_acc = train(model, train_iterator, optimizer, criterion)
valid_loss, valid_acc = evaluate(model, valid_iterator, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
# 保留最好的訓練結果的那個模型參數,之後加載這個進行預測
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model.state_dict(), 'tut2-model.pt')
print(f'Epoch: {epoch+1:02} | Epoch Time: {epoch_mins}m {epoch_secs}s')
print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. Acc: {valid_acc*100:.2f}%')
```
2.6.7 最終測試結果
```python model.load_state_dict(torch.load('tut2-model.pt'))
test_loss, test_acc = evaluate(model, test_iterator, criterion)
print(f'Test Loss: {test_loss:.3f} | Test Acc: {test_acc*100:.2f}%') ```
Test Loss: 0.334 | Test Acc: 85.28%
2.7 模型驗證
我們現在可以使用這個模型來預測我們給出的任何句子的情感了,注意需要提供的句子是電影評論方面的。
當使用模型進行實際預測時,模型要始終在evaluation mode評估模式。
“predict_sentiment”函數的作用如下:
- 將模型切換為evaluate模式
- 對句子進行分詞操作
- 將分詞後的每個詞,對應着詞彙表,轉換成對應的index索引,
- 獲取句子的長度
- 將indexes,從list轉化成tensor
- 通過unsqueezing 添加一個batch維度
- 將length轉化成張量tensor
- 用sigmoid函數將預測值壓縮到0-1之間
- 用item()方法,將只有一個值的張量tensor轉化成整數
負面評論返回接近0的值,正面評論返回接近1的值。
```python import spacy nlp = spacy.load('en_core_web_sm')
def predict_sentiment(model, sentence): model.eval() tokenized = [tok.text for tok in nlp.tokenizer(sentence)] indexed = [TEXT.vocab.stoi[t] for t in tokenized] length = [len(indexed)] tensor = torch.LongTensor(indexed).to(device) tensor = tensor.unsqueeze(1) length_tensor = torch.LongTensor(length) prediction = torch.sigmoid(model(tensor, length_tensor)) return prediction.item() ```
負面評論的例子:
python
predict_sentiment(model, "This film is terrible")
0.05380420759320259
正面評論的例子:
python
predict_sentiment(model, "This film is great")
0.94941645860672
小結
我們現在已經為電影評論建立了一個情感分析模型。在下一小節中,我們將實現一個模型,這個模型會以更少的參數獲得更高的精度、更快的訓練速度。
參考資料
https://blog.csdn.net/weixin_42167712/article/details/112196925