《NLP情感分析》(二)——Baseline

語言: CN / TW / HK

1.1 簡介

在這一部分,我們將使用pytorch和torchtext構造一個簡單的機器學習模型來預測句子的情緒(即句子表達的情緒是正面還是負面)。本系列教程將在電影評論數據集:IMDb數據集上完成。

為了快速帶領大家進入情感分析領域的學習,在第一部分,我們不會涉及太難的理論知識,不會注重模型的效果,只是搭建出了一個情感分析的小例子。在後面的學習中,我們會通過學習更多的知識來完善這個系統。

MDb數據集來源: @InProceedings{maas-EtAl:2011:ACL-HLT2011, author = {Maas, Andrew L. and Daly, Raymond E. and Pham, Peter T. and Huang, Dan and Ng, Andrew Y. and Potts, Christopher}, title = {Learning Word Vectors for Sentiment Analysis}, booktitle = {Proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies}, month = {June}, year = {2011}, address = {Portland, Oregon, USA}, publisher = {Association for Computational Linguistics}, pages = {142--150}, url = {http://www.aclweb.org/anthology/P11-1015} }

1.2 數據預處理

TorchText 的主要概念之一是Field,這些定義了應如何處理數據。 我們的數據集為帶標籤數據集,即數據由評論的原始字符串和情感組成,“pos”表示積極情緒,“neg”表示消極情緒。

Field 的參數指定應該如何處理數據。

我們使用 TEXT 字段來定義應該如何處理評論,並使用 LABEL 字段來處理情緒。

我們的 TEXT 字段有 tokenize='spacy' 作為參數。這定義了“標記化”(將字符串拆分為離散的“標記”的行為)應該使用 spaCy 標記器完成。如果沒有設置 tokenize 參數,默認值是使用空格拆分字符串。我們還需要指定一個 tokenizer_language 來告訴 torchtext 使用哪個 spaCy 模型。我們使用 en_core_web_sm 模型。

下載en_core_web_sm 模型的方法: python -m spacy download en_core_web_sm

LABELLabelField 定義,它是專門用於處理標籤的 Field 類的特殊子集。稍後我們將解釋 dtype 參數。

有關Field的更多信息,請訪問這裏

```Python import torch from torchtext.legacy import data

設置隨機種子數,該數可以保證隨機數是可重複的

SEED = 1234

設置種子

torch.manual_seed(SEED)

將這個 flag 置為True的話,每次返回的卷積算法將是確定的,即默認算法。如果配合上設置 Torch 的隨機種子為固定值的話,應該可以保證每次運行網絡的時候相同輸入的輸出是固定的

torch.backends.cudnn.deterministic = True

讀取數據和標籤

TEXT = data.Field(tokenize = 'spacy', tokenizer_language = 'en_core_web_sm') LABEL = data.LabelField(dtype = torch.float) ``TorchText` 的另一個方便的功能是它支持自然語言處理 (NLP) 中使用的常見數據集。

以下代碼自動下載 IMDb 數據集並將其拆分為規範的訓練集和測試集,作為 torchtext.datasets 對象。 它使用我們之前定義的Fields處理數據。 IMDb 數據集包含 50,000 條電影評論,每條評論都標記為正面或負面評論。

```Python from torchtext.legacy import datasets

train_data, test_data = datasets.IMDB.splits(TEXT, LABEL) ```

image.png 查看我們的訓練集和測試集大小:

Python print(f'Number of training examples: {len(train_data)}') print(f'Number of testing examples: {len(test_data)}')

image.png

看一下示例數據:

python print(vars(train_data.examples[0]))

image.png

IMDb 數據集劃分了訓練集和測試集,這裏我們還需要創建一個驗證集。 可以使用 .split() 方法來做。

默認情況下,數據將按照70%和30%的比例劃分為訓練集和驗證集,可以通過設置split_ratio參數來設置訓練集和驗證集的比例,即 split_ratio 為 0.8 意味着 80% 的示例構成訓練集,20% 構成驗證集。

這裏還需要將我們之前設置的隨機種子SEED傳遞給 random_state 參數,確保我們每次獲得相同的訓練集和驗證集。

```Python import random

train_data, valid_data = train_data.split(split_ratio=0.8 , random_state = random.seed(SEED)) ``` 現在我們看一下訓練集,驗證集和測試集分別有多少數據

Python print(f'Number of training examples: {len(train_data)}') print(f'Number of validation examples: {len(valid_data)}') print(f'Number of testing examples: {len(test_data)}')

image.png

接下來,我們必須構建一個 詞彙表。 這是一個查找表,其中數據集中的每個單詞都有唯一對應的 index(整數)。

我們這樣做是因為我們的模型不能對字符串進行操作,只能對數字進行操作。 每個 index 用於為每個詞構造一個 one-hot 向量,通常用 $V$ 表示。

image.png

我們訓練集中不同的單詞數超過100,000,這意味着我們的one-hot向量超過100,000維,這將大大延長訓練時間,甚至不適合在本地運行。

有兩種方法可以優化我們的one-hot向量,一種是可以只取前n個出現次數最多的單詞作為one-hot的基,另一種是忽略出現次數小於m個的單詞。 在本例中,我們使用第一種方法:使用25,000個最常見的單詞作為one-hot編碼。

這樣就會出現一個問題:有些單詞在數據集中出現了,但卻無法直接進行one-hot編碼。這裏我們使用一個特別的<unk>來編碼它們。舉個例子,如果我們的句子是"This film is great and I love it",但是單詞"love"不在詞彙表中,那麼我們將這句話轉換成:"This film is great and I <unk> it"。

下面我們構建詞彙表,只保留最常見的 max_size 標記。

```Python MAX_VOCAB_SIZE = 25000

TEXT.build_vocab(train_data, max_size = MAX_VOCAB_SIZE) LABEL.build_vocab(train_data) ```

為什麼只在訓練集上建立詞彙表?因為在測試模型時,都不能以任何方式影響測試集。 當然也不包括驗證集,因為希望驗證集儘可能地反映測試集。

Python print(f"Unique tokens in TEXT vocabulary: {len(TEXT.vocab)}") print(f"Unique tokens in LABEL vocabulary: {len(LABEL.vocab)}")

image.png

為什麼詞典大小是25002而不是25000?另外兩個額外的token是 <unk><pad>.

當我們將句子輸入我們的模型時,我們一次輸入一個_batch_,並且批次中的所有句子都需要具有相同的長度。 因此,得設置一個maxlength,為了確保批次中的每個句子的大小相同,填充任何短於maxlength的句子,填充得部分設置為0,大於maxlength的部分直接截取。

image.png

我們還可以查看詞彙表中最常見的單詞及其他們在數據集中出現的次數。

Python print(TEXT.vocab.freqs.most_common(20))

image.png

也可以使用 stoi (string to int) or itos (int to string) 方法,以下輸出text-vocab的前10個詞彙。

Python print(TEXT.vocab.itos[:10])

image.png

準備數據的最後一步是創建迭代器. 需要創建驗證集,測試集,以及訓練集的迭代器, 每一次的迭代都會返回一個batch的數據。

我們將使用一個“BucketIterator”,它是一種特殊類型的迭代器,它將返回一批示例,其中每個樣本的長度差不多,從而最小化每個樣本的padding數。

如何有gpu的話,當然可以將迭代器返回的張量放在GPU上.可以用torch.device,可以將張量放到gpu或者cpu上。

```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) ```

1.3 構建模型

下一步就是建立我們接下來要train和evaluate的模型

在 PyTorch 中使用RNN的模型的時候,創建RNN的時候不是使用RNN類,而是nn.module的子類。

__init__ 我們定義的模型的層數. 三層模型分別是嵌入層,RNN層,最後還有全連接層. 所有層的參數初始化都是隨機的,除非對某些參數進行特別設置。 embedding層將稀疏的one-hot轉換為密集嵌入到空間的向量.embedding 層是一個簡單的單層的全連接層. 這樣也減少了輸入到RNN的維數,減少了數據的運算的計算量,這裏有一種理論是,對評論的情緒有相似影響的詞在向量空間中被緊密地映射在一塊. 更多信息可以看 here.

RNN層接受之前的狀態$h_{t-1}$和當前輸入對應的密集嵌入向量, 這兩部分用來計算下一層的隱藏層狀態, $h_t$.

image.png

最終, 最後一層線性層就會得到RNN輸出的最後一層隱藏層狀態。這層隱藏層狀態包含了之前所有的信息,將RNN最後一層的隱藏層狀態輸入到全連接層,得到 $f(h_T)$, 最終轉換成batch_size*num_classes. forward方法是當我們將訓練數據,驗證數據集,測試數據集輸入到模型時,數據就會傳到forward方法,得到模型輸出得結果。

在每一個batch中, text,是一個大小為 _[sentence length, batch size]_的tensor. 這都是每一個句子對應的one-hot向量轉換得到的。

而每個句子的one-hot向量都是由對應詞典生成索引,然後根據索引值就可以得到每個句子的one-hot向量表示方式。

每一個輸入的batch經過embedding層都會被embedded, 得到每一個句子的密集向量表示. embedded後的向量size為 [sentence length, batch size, embedding dim].

在某些框架中,使用RNN需要初始化$h_0$,但在pytorch中不用,默認為全0。 使用 RNN 會返回 2個tensors, outputhidden。 output的size為_[sentence length, batch size, hidden dim] and hidden的size為[1, batch size, hidden dim]_. output 為每一層的隱藏層狀態, 而 hidden 是最後一層的隱藏層狀態. 小記: squeeze 方法, 可以消除維度為1的維度。

其實我們一般用hidden就行了,不用管output 。最終,通過線性層 fc, 產生最終的預測。

```Python import torch.nn as nn

class RNN(nn.Module): def init(self, input_dim, embedding_dim, hidden_dim, output_dim):

    super().__init__()

    self.embedding = nn.Embedding(input_dim, embedding_dim)

    self.rnn = nn.RNN(embedding_dim, hidden_dim)

    self.fc = nn.Linear(hidden_dim, output_dim)

def forward(self, text):

    #text = [sent len, batch size]

    embedded = self.embedding(text)

    #embedded = [sent len, batch size, emb dim]

    output, hidden = self.rnn(embedded)

    #output = [sent len, batch size, hid dim]
    #hidden = [1, batch size, hid dim]

    assert torch.equal(output[-1,:,:], hidden.squeeze(0))

    return self.fc(hidden.squeeze(0))

``` 下面,我們可以做建立一個RNN的例子

輸入維度就是對應one-hot向量的維度, 也等同於詞典的維度.

embedding 維度是可以設置的超參數. 通常設置為 50-250 維度, 某種程度上也和詞典大小有關.

隱藏層維度就是最後一層隱藏層的大小. 通常可以設置為100-500維, 這個也會詞典大小,任務的複雜程度都有關係

輸出的維度就是要分類的類別的數目。

```Python INPUT_DIM = len(TEXT.vocab) #詞典大小 EMBEDDING_DIM = 100 HIDDEN_DIM = 256 OUTPUT_DIM = 1

model = RNN(INPUT_DIM, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM) ``` 也可以輸出要訓練的參數數目看看.

```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') ```

image.png

1.4 訓練模型

在模型訓練前,先要設置優化器,這裏我們選擇的是SGD,隨機梯度下降計算,model.parameters()表示需要更新的參數,lr為學習率

```Python import torch.optim as optim

optimizer = optim.SGD(model.parameters(), lr=1e-3) ``` 接下來定義損失函數,BCEWithLogitsLoss一般用來做二分類。

Python criterion = nn.BCEWithLogitsLoss().to, 可以將張量放到gpu上計算。

Python model = model.to(device) criterion = criterion.to(device) 損失函數用來計算損失值,還需要計算準確率的函數。

將sigmoid層輸出的預測結果輸入到計算準確率的函數, 取整到最近的整數.大於0.5,就取1。反之取0。

計算出預測的結果和label一致的值,在除以所有的值,就可以得到準確率。

```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

``train` 函數迭代所有的樣本,每次都是一個batch。

model.train() 將model處於 "training 模式", 也會打開 dropoutbatch normalization. 在每一次的batch, 先將梯度清0. 模型的每一個參數都有一個 grad 屬性, 存儲着損失函數計算的梯度值. PyTorch 不會自動刪除(或“歸零”)從上次梯度計算中計算出的梯度,因此必須手動將其歸零。

每次輸入, batch.text, 到模型中. 只需要調用模型即可.

loss.backward()計算梯度,更新參數使用的是 optimizer.step()

損失值和準確率在整個 epoch 中累積, .item()抽取張量中只含有一個值的張量中的值。

最後,我們返回損失和準確率,在整個 epoch 中取平均值. len可以得到epoch中的batch數

當然在計算的時候,要記得將LongTensor轉化為 torch.float。這是因為 TorchText 默認將張量設置為 LongTensor

```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) #計算Loss

    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)  #返回整個epoch中損失和準確率的平均值

``evaluatetrain`相似, 只要將train函數稍微進行修改即可。

model.eval() 將模型置於"evaluation 模式", 這會關掉 dropoutbatch normalization.

with no_grad() 下,不會進行梯度計算. 這會導致使用更少的內存並加快計算速度.

其他函數在train中類似,在evaluate中移除了 optimizer.zero_grad(), loss.backward() and optimizer.step(), 因為不再需要更新參數了

```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 ``` 然後,我們通過多個 epoch 來訓練模型,每一個 epoch 是對訓練和驗證集中所有樣本的完整傳遞。

在每個epoch,如果在驗證集上的損失值是迄今為止我們所見過的最好的,我們將保存模型的參數,然後在訓練完成後我們將在測試集上使用該模型。

```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(), 'tut1-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 如上所示,損失並沒有真正減少多少,而且準確性很差。 這是由於這是baseline,我們將在下一個notbook中改進的模型的幾個問題。

最後,要得到真正關心的指標,測試集上的損失和準確性,參數將從已經訓練好的模型中獲得,這些參數為我們提供了最好的驗證集上的損失。

```Python model.load_state_dict(torch.load('tut1-model.pt'))

test_loss, test_acc = evaluate(model, test_iterator, criterion)

print(f'Test Loss: {test_loss:.3f} | Test Acc: {test_acc*100:.2f}%') ```

image.png

1.5 小結

在下一篇文章, 會有以下優化: - 壓縮填充張量 - 預訓練的詞嵌入 - 不同的 RNN 架構 - 雙向 RNN - 多層 RNN - 正則化 - 不同的優化器

最終提高準確率(84%)