《NLP情感分析》(五)——CNN情感分析

語言: CN / TW / HK

4:卷積情感分析

在本節中,我們將利用卷積神經網路(CNN)進行情感分析,實現 Convolutional Neural Networks for Sentence Classification中的模型。

:CNN詳細知識請檢視此處這裡

卷積神經網路在計算機視覺問題上表現出色,原因在於其能夠從區域性輸入影象塊中提取特徵,並能將表示模組化,同時可以高效地利用資料。同樣的,卷積神經網路也可以用於處理序列資料,時間可以被看作一個空間維度,就像二維影象的高度和寬度。

那麼為什麼要在文字上使用卷積神經網路呢?與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個句子的嵌入句:

image.png

然後我們可以使用一個 [n x emb_dim] 的filter。這將完全覆蓋 $n$ 個words,因為它們的寬度為emb_dim 尺寸。考慮下面的影象,我們的單詞向量用綠色表示。這裡我們有4個詞和5維嵌入,建立了一個[4x5] "image" 張量。一次覆蓋兩個詞(即bi-grams))的filter 將是 [2x5] filter,以黃色顯示,filter 的每個元素都有一個與之相關的 weight。此filter 的輸出(以紅色顯示)將是一個實數,它是filter覆蓋的所有元素的加權和。

image.png

然後,filter "down" 移動影象(或穿過句子)以覆蓋下一個bi-gram,並計算另一個輸出(weighted sum)。

image.png

最後,filter 再次向下移動,並計算此 filter 的最終輸出。

image.png

一般情況下,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)。

image.png

最大值是文字情感分析中“最重要”特徵,對應於評論中的“最重要”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}%')

```

image.png

我們得到的測試結果與前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 += [''] * (min_len - len(tokenized)) indexed = [TEXT.vocab.stoi[t] for t in tokenized] tensor = torch.LongTensor(indexed).to(device) tensor = tensor.unsqueeze(0) prediction = torch.sigmoid(model(tensor)) return prediction.item() ```

負面評論的例子:

python predict_sentiment(model, "This film is terrible")

0.09913548082113266

正面評論的例子:

python predict_sentiment(model, "This film is great")

0.9769725799560547

小結

在下一節中,我們將學習多型別情感分析。