《NLP情感分析》(五)——CNN情感分析
4:卷積情感分析
在本節中,我們將利用卷積神經網路(CNN)進行情感分析,實現 Convolutional Neural Networks for Sentence Classification中的模型。
卷積神經網路在計算機視覺問題上表現出色,原因在於其能夠從區域性輸入影象塊中提取特徵,並能將表示模組化,同時可以高效地利用資料。同樣的,卷積神經網路也可以用於處理序列資料,時間可以被看作一個空間維度,就像二維影象的高度和寬度。
那麼為什麼要在文字上使用卷積神經網路呢?與3x3 filter可以檢視影象塊的方式相同,1x2 filter 可以檢視一段文字中的兩個連續單詞,即雙字元。在上一個教程中,我們研究了FastText模型,該模型通過將bi-gram顯式新增到文字末尾來使用bi-gram,在這個CNN模型中,我們將使用多個不同大小的filter,這些filter將檢視文字中的bi-grams(a 1x2 filter)、tri-grams(a 1x3 filter)and/or n-grams(a 1x$n$ filter)。
4.1 資料預處理
與 task3 使用FastText模型的方法不同,本節不再需要刻意地建立bi-gram將它們附加到句子末尾。
```python import torch from torchtext.legacy import data from torchtext.legacy import datasets import random import numpy as np
SEED = 1234
random.seed(SEED) np.random.seed(SEED) torch.manual_seed(SEED) torch.backends.cudnn.deterministic = True
TEXT = data.Field(tokenize = 'spacy', tokenizer_language = 'en_core_web_sm', batch_first = True) LABEL = data.LabelField(dtype = torch.float)
train_data, test_data = datasets.IMDB.splits(TEXT, LABEL)
train_data, valid_data = train_data.split(random_state = random.seed(SEED)) ```
構建vocab,載入預訓練詞嵌入:
```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) ```
建立迭代器:
```python BATCH_SIZE = 64
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, device = device) ```
4.2 構建模型
開始構建模型!
第一個主要問題是如何將CNN用於文字。影象一般是二維的,而文字是一維的。所以我們可以將一段文字中的每個單詞沿著一個軸展開,向量中的元素沿著另一個維度展開。如考慮下面2個句子的嵌入句:
然後我們可以使用一個 [n x emb_dim] 的filter。這將完全覆蓋 $n$ 個words,因為它們的寬度為emb_dim
尺寸。考慮下面的影象,我們的單詞向量用綠色表示。這裡我們有4個詞和5維嵌入,建立了一個[4x5] "image" 張量。一次覆蓋兩個詞(即bi-grams))的filter 將是 [2x5] filter,以黃色顯示,filter 的每個元素都有一個與之相關的 weight。此filter 的輸出(以紅色顯示)將是一個實數,它是filter覆蓋的所有元素的加權和。
然後,filter "down" 移動影象(或穿過句子)以覆蓋下一個bi-gram,並計算另一個輸出(weighted sum)。
最後,filter 再次向下移動,並計算此 filter 的最終輸出。
一般情況下,filter 的寬度等於"image" 的寬度,我們得到的輸出是一個向量,其元素數等於影象的高度(或詞的長度)減去 filter 的高度加上一。在當前例子中,$4-2+1=3$。
上面的例子介紹瞭如何去計算一個filter的輸出。我們的模型(以及幾乎所有的CNN)有很多這樣的 filter。其思想是,每個filter將學習不同的特徵來提取。在上面的例子中,我們希望 [2 x emb_dim] filter中的每一個都會查詢不同 bi-grams 的出現。
在我們的模型中,我們還有不同尺寸的filter,高度為3、4和5,每個filter有100個。我們將尋找與分析電影評論情感相關的不同3-grams, 4-grams 和 5-grams 的情況。
我們模型中的下一步是在卷積層的輸出上使用pooling(具體是 max pooling)。這類似於FastText模型,不同的是在該模型中,我們計算其最大值,而非是FastText模型中每個詞向量進行平均,下面的例子是從卷積層輸出中獲取得到向量的最大值(0.9)。
最大值是文字情感分析中“最重要”特徵,對應於評論中的“最重要”n-gram。由於我們的模型有3種不同大小的100個filters,這意味著我們有300個模型認為重要的不同 n-grams。我們將它們連線成一個向量,並將它們通過線性層來預測最終情感。我們可以將這一線性層的權重視為"weighting up the evidence" 的權重,通過綜合300個n-gram做出最終預測。
實施細節
1.我們藉助 nn.Conv2d
實現卷積層。in_channels
引數是影象中進入卷積層的“通道”數。在實際影象中,通常有3個通道(紅色、藍色和綠色通道各有一個通道),但是當使用文字時,我們只有一個通道,即文字本身。out_channels
是 filters 的數量,kernel_size
是 filters 的大小。我們的每個“卷積核大小”都將是 [n x emb_dim] 其中 $n$ 是n-grams的大小。
2.之後,我們通過卷積層和池層傳遞張量,在卷積層之後使用'ReLU'啟用函式。池化層的另一個很好的特性是它們可以處理不同長度的句子。而卷積層的輸出大小取決於輸入的大小,不同的批次包含不同長度的句子。如果沒有最大池層,線性層的輸入將取決於輸入語句的長度,為了避免這種情況,我們將所有句子修剪/填充到相同的長度,但是線性層來說,線性層的輸入一直都是filter的總數。
注:如果句子的長度小於實驗設定的最大filter,那麼必須將句子填充到最大filter的長度。在IMDb資料中不會存在這種情況,所以我們不必擔心。
3.最後,我們對合並之後的filter輸出執行dropout操作,然後將它們通過線性層進行預測。
```python import torch.nn as nn import torch.nn.functional as F
class CNN(nn.Module): def init(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, dropout, pad_idx):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
self.conv_0 = nn.Conv2d(in_channels = 1,
out_channels = n_filters,
kernel_size = (filter_sizes[0], embedding_dim))
self.conv_1 = nn.Conv2d(in_channels = 1,
out_channels = n_filters,
kernel_size = (filter_sizes[1], embedding_dim))
self.conv_2 = nn.Conv2d(in_channels = 1,
out_channels = n_filters,
kernel_size = (filter_sizes[2], embedding_dim))
self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, text):
#text = [batch size, sent len]
embedded = self.embedding(text)
#embedded = [batch size, sent len, emb dim]
embedded = embedded.unsqueeze(1)
#embedded = [batch size, 1, sent len, emb dim]
conved_0 = F.relu(self.conv_0(embedded).squeeze(3))
conved_1 = F.relu(self.conv_1(embedded).squeeze(3))
conved_2 = F.relu(self.conv_2(embedded).squeeze(3))
#conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
pooled_0 = F.max_pool1d(conved_0, conved_0.shape[2]).squeeze(2)
pooled_1 = F.max_pool1d(conved_1, conved_1.shape[2]).squeeze(2)
pooled_2 = F.max_pool1d(conved_2, conved_2.shape[2]).squeeze(2)
#pooled_n = [batch size, n_filters]
cat = self.dropout(torch.cat((pooled_0, pooled_1, pooled_2), dim = 1))
#cat = [batch size, n_filters * len(filter_sizes)]
return self.fc(cat)
```
目前,CNN
模型使用了3個不同大小的filters,但我們實際上可以改進我們模型的程式碼,使其更通用,並且可以使用任意數量的filters。
```python class CNN(nn.Module): def init(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, dropout, pad_idx):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
self.convs = nn.ModuleList([
nn.Conv2d(in_channels = 1,
out_channels = n_filters,
kernel_size = (fs, embedding_dim))
for fs in filter_sizes
])
self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, text):
#text = [batch size, sent len]
embedded = self.embedding(text)
#embedded = [batch size, sent len, emb dim]
embedded = embedded.unsqueeze(1)
#embedded = [batch size, 1, sent len, emb dim]
conved = [F.relu(conv(embedded)).squeeze(3) for conv in self.convs]
#conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
#pooled_n = [batch size, n_filters]
cat = self.dropout(torch.cat(pooled, dim = 1))
#cat = [batch size, n_filters * len(filter_sizes)]
return self.fc(cat)
```
還可以使用一維卷積層實現上述模型,其中嵌入維度是 filter 的深度,句子中的token數是寬度。
在本task中使用二維卷積模型進行測試,其中的一維模型的實現大家感興趣的可以自行試一試。
```python class CNN1d(nn.Module): def init(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, dropout, pad_idx):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
self.convs = nn.ModuleList([
nn.Conv1d(in_channels = embedding_dim,
out_channels = n_filters,
kernel_size = fs)
for fs in filter_sizes
])
self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, text):
#text = [batch size, sent len]
embedded = self.embedding(text)
#embedded = [batch size, sent len, emb dim]
embedded = embedded.permute(0, 2, 1)
#embedded = [batch size, emb dim, sent len]
conved = [F.relu(conv(embedded)) for conv in self.convs]
#conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
#pooled_n = [batch size, n_filters]
cat = self.dropout(torch.cat(pooled, dim = 1))
#cat = [batch size, n_filters * len(filter_sizes)]
return self.fc(cat)
```
建立了CNN
類的一個例項。
如果想執行一維卷積模型,我們可以將CNN
改為CNN1d
,注意兩個模型給出的結果幾乎相同。
```python INPUT_DIM = len(TEXT.vocab) EMBEDDING_DIM = 100 N_FILTERS = 100 FILTER_SIZES = [3,4,5] OUTPUT_DIM = 1 DROPOUT = 0.5 PAD_IDX = TEXT.vocab.stoi[TEXT.pad_token]
model = CNN(INPUT_DIM, EMBEDDING_DIM, N_FILTERS, FILTER_SIZES, OUTPUT_DIM, DROPOUT, PAD_IDX) ```
檢查我們模型中的引數數量,我們可以看到它與FastText模型大致相同。
“CNN”和“CNN1d”模型的引數數量完全相同。
```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') ```
The model has 2,620,801 trainable parameters
接下來,載入預訓練詞嵌入
```python pretrained_embeddings = TEXT.vocab.vectors
model.embedding.weight.data.copy_(pretrained_embeddings) ```
tensor([[-0.1117, -0.4966, 0.1631, ..., 1.2647, -0.2753, -0.1325],
[-0.8555, -0.7208, 1.3755, ..., 0.0825, -1.1314, 0.3997],
[-0.0382, -0.2449, 0.7281, ..., -0.1459, 0.8278, 0.2706],
...,
[ 0.6783, 0.0488, 0.5860, ..., 0.2680, -0.0086, 0.5758],
[-0.6208, -0.0480, -0.1046, ..., 0.3718, 0.1225, 0.1061],
[-0.6553, -0.6292, 0.9967, ..., 0.2278, -0.1975, 0.0857]])
然後,將未知標記和填充標記的初始權重歸零。
```python 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) ```
4.3 訓練模型
訓練和前面task一樣,我們初始化優化器、損失函式(標準),並將模型和標準放置在GPU上。
```python import torch.optim as optim
optimizer = optim.Adam(model.parameters())
criterion = nn.BCEWithLogitsLoss()
model = model.to(device) criterion = criterion.to(device) ```
實現了計算精度的函式:
```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
```
定義了一個函式來訓練我們的模型:
注意:由於再次使用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()
predictions = model(batch.text).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)
```
定義了一個函式來測試我們的模型:
注意:同樣,由於使用的是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:
predictions = model(batch.text).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)
```
通過函式得到一個epoch需要多長時間:
```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 ```
最後,訓練我們的模型:
```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(), 'tut4-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個模型結果差不多!
```python model.load_state_dict(torch.load('tut4-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.343 | Test Acc: 85.31%
4.4 模型驗證
```python import spacy nlp = spacy.load('en_core_web_sm')
def predict_sentiment(model, sentence, min_len = 5):
model.eval()
tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
if len(tokenized) < min_len:
tokenized += ['
負面評論的例子:
python
predict_sentiment(model, "This film is terrible")
0.09913548082113266
正面評論的例子:
python
predict_sentiment(model, "This film is great")
0.9769725799560547
小結
在下一節中,我們將學習多型別情感分析。