深度學習(四)之電影評論分類

段小輝發表於2022-04-08

任務目標

對中文電影評論進行情感分類,實現測試準確率在\(83\%\)以上。本文采用了3種模型對電影評論進行分類,最終,模型在測試集的結果分別為:

模型 acc precision recall f1-score
LSTM \(81.57\%\) [\(77.49\%\),\(86.69\%\)] [\(88.46\%\),\(74.87%\)] [\(82.56\%\),\(80.46\%\)]
TextCNN \(84.28\%\) [\(81.96\%\),\(86.86\%\)] [\(87.36\%\),\(81.28\%\)] [\(84.57\%\),\(83.98\%\)]
Attention-Based BiLSTM \(85.64\%\) [\(85.64\%\),\(85.64\%\)] [\(85.16\%\),\(86.10\%\)] [\(85.40\%\),\(85.87\%\)]

實驗環境:

  • python:3.9.7
  • pytorch:1.11.0
  • numpy:1.21.2
  • opencc:1.1.3 用於簡繁體轉換
  • sklearn:1.0.2

程式碼地址:https://github.com/xiaohuiduan/deeplearning-study/tree/main/電影評論分類

資料集預處理

資料集一共由3個檔案構成:train.txt,validation.txt和test.txt。分別用於訓練,驗證和測試。

部分資料如下所示,每一行代表一個樣本資料,0代表好評,1代表差評。其中,每條評論都進行了預先分詞處理(詞與詞之間使用空格進行分隔)。因此,在寫程式碼進行資料預處理階段並不需要對資料集的資料進行分詞處理。

簡繁文字轉換

在電影評論資料中,存在一些繁體字,一般來說,繁體字和簡體的字的意思應該是一樣的。所以我們可以將繁體字轉成簡體字,這樣在一定程度上可以減少模型的複雜度。

from opencc import OpenCC
def t2s(file_path,output_file_path):
    """
        file_path:原始檔地址
        output_file_path:轉換成簡體後儲存的地址
    """
    input = open(file_path).read()
    output = open(output_file_path,"w")
    output.write(OpenCC("t2s").convert(input))
    output.close()
t2s("./data/train.txt","./data/train_zh.txt")
t2s("./data/test.txt","./data/test_zh.txt")
t2s("./data/validation.txt","./data/validation_zh.txt")

構建詞彙表

在構建詞彙表的過程中,只使用訓練集構建詞彙表,不使用驗證集和測試集去構建。同時,因為在驗證集或者測試集很大概率會存在某些詞彙無法在訓練集中找到,所以在構建詞彙表中,需要加入<unk>來代表未知詞。

那麼,此時便存在一個問題,對於訓練集我們是已知的,怎樣從訓練集中構建<unk>詞彙表呢?本文解決的方法如下:

首先對訓練集中的詞彙按照出現的次數進行排序,然後將前\(99.9\%\)的詞彙構建詞彙表,剩下的\(0.1\%\)的詞彙使用<unk>表示。(實際上訓練集中一共有51426個詞彙,進行上述操作後,則構成了一個大小為51376大小的詞彙表【包括<unk>和<pad>,<unk>代表未知詞彙,<pad>代表資料填充】)。

from collections import Counter
def build_word_vocab(train_file_path):
    """
        構建訓練集的詞彙表
    """
    with open(train_file_path) as f:
        lines = f.readlines()
        words = []
        for line in lines:
            text = line.split()[1:]
            words.extend([x for x in text])
    counter = Counter(words)
    # 使用訓練集中前99.9%的詞彙
    counter = counter.most_common(int(len(counter)*0.999)) # [(word,count),(word,count)]

    words = [word for word,_ in counter] 

    word2idx = {word:index+2 for index,word in enumerate(words)}
    word2idx["<pad>"] = 0
    word2idx["<unk>"] = 1

    idx2word = {index+2:word for index,word in enumerate(words)}
    idx2word[0] = "<pad>"
    idx2word[1] = "<unk>"

    return word2idx,idx2word

word2idx,idx2word = build_word_vocab("./data/train_zh.txt")

構建資料集X,Y

構建資料集很簡單,就是提取資料集中的資料,然後構建\(X,Y\)\(X\)代表評論資料,比如說“這電影真難看”,我們需要將這句話轉成網路模型能夠輸入的資料:[11,241,5,312]。\(Y\)代表好評或者差評。

import numpy as np
def return_file_data_x_y(file_path):
    """
        解析檔案中的資料,並返回每條資料的label和內容的index
        return X:[[2,4,15,112,4],[1,55,213]] Y:[0,1]
    """
    X = []
    Y = []
    with open(file_path) as f:
        lines = f.readlines()
        for line in lines:
            data = line.split()
            # 如果碰到空白行,則無需理會
            if(len(data) == 0):
                continue
            # 如果碰到不再詞表中的詞,則使用<unk>替代。
            x = [word2idx[i] if i in word2idx.keys() else word2idx["<unk>"] for i in data[1:]]
            y = int(data[0])
            X.append(x) 
            Y.append(y)
    return X,Y

train_X,train_Y = return_file_data_x_y("./data/train_zh.txt")
validation_X,validation_Y = return_file_data_x_y("./data/validation_zh.txt")
test_X,test_Y = return_file_data_x_y("./data/test_zh.txt")

載入預訓練的詞向量並處理

提供的詞向量檔案是預先訓練好的詞向量模型(檔名為"wiki_word2vec_50.bin")。詞向量模型實際上是一個\(vocab\_size \times embeeding\_size\)矩陣。vocab_size代表的是詞彙表的大小。儘管預訓練的詞向量是通過大量的資料進行訓練的,但是直接使用會存在兩個問題:

  • 訓練集中某些詞彙可能在詞向量模型不存在。
  • 詞向量模型中很多詞彙在訓練集中並不存在,因此需要進行進行精簡。
from gensim.models import keyedvectors
import torch

w2v=keyedvectors.load_word2vec_format("./data/wiki_word2vec_50.bin",binary=True)
vocab_size = len(word2idx) # 字典裡面有多少個詞
embedding_dim = w2v.vector_size # embedding之後的維度
# 初始化詞向量矩陣,用0初始化。
embedding_weight = torch.zeros(vocab_size,embedding_dim)
for id,word in idx2word.items():
    # 假如該詞彙存在於預訓練模型中,則直接使用預訓練模型中的值替代
    if word in w2v.key_to_index.keys():
        embedding_weight[id] = torch.from_numpy(w2v[word])

embedding_weight便是最終的詞向量權重,在訓練的過程中,會通過反向傳播會對詞向量權重進行更新。

DataLoader中的一些細節

在Pytorch中,每個batch的資料的shape必須要一樣(比如說在一個batch中,評論的長度都需要是一樣的)。但是,在評論資料中,每條評論並都是一樣長的,因此在將資料輸入到網路中,必須將不同長度的句子變成一樣長。本文使用<pad>進行填充,將每個batch中的評論資料變成一樣長。在pytorch中,提供了pad_sequence函式進行處理。

關於pytorch的填充操作,可以參考:pack_padded_sequence 和 pad_packed_sequence - 知乎 (zhihu.com)

from torch.utils.data import Dataset,DataLoader
from torch.nn import utils as nn_utils

class CommentDataset(Dataset):
    def __init__(self,X,Y):
        self.X = X
        self.Y = Y
        self.len = len(X)
    def __getitem__(self,index):
        return self.X[index],self.Y[index]
    def __len__(self):
        return self.len

def collate_fn(batch_data):
    """
        將batch_data中的句子變成一樣長,使用<pad>進行填充
    """
    X = []
    Y = []
    for data in batch_data:
        X.append(torch.LongTensor(data[0]))
        Y.append(data[1]) 

    # data_len代表句子的實際長度,在LSTM中,需要使用;在TextCNN並不需要使用
    data_len = [len(i) for i in X]

    input_data = nn_utils.rnn.pad_sequence(X,batch_first=True,padding_value=0) # 因為<pad>對應的id為0,所以padding_value=0
    return input_data,torch.LongTensor(Y),data_len

batch_size = 256

train_dataset = CommentDataset(train_X,train_Y)
train_dataloader = DataLoader(train_dataset,batch_size=batch_size,shuffle=True,collate_fn=collate_fn,num_workers=16)

valid_dataset = CommentDataset(validation_X,validation_Y)
valid_dataloader = DataLoader(valid_dataset,batch_size=batch_size,collate_fn=collate_fn,num_workers=16)

test_dataset = CommentDataset(test_X,test_Y)
test_dataloader = DataLoader(test_dataset,batch_size=batch_size,collate_fn=collate_fn,num_workers=16)

CommentDataset中構建了collate_fn了函式,在該函式中,對batch中的評論資料進行處理,使其變成一樣長。

TextCNN網路

原理及網路結構

TextCNN的原理圖如下所示(圖源:CNNs for Text Classification – Cezanne Camacho – Machine and deep learning educator.):

實際上,TextCNN與影像中的CNN處理是很相似的,其不同點在於:

  • TextCNN的卷積核的大小為(kernel_size,embedding_size),kernel_size的大小可以為3或者5,而embedding_size代表詞向量的維度。
  • TextCNN的channel為1。

A complete CNN with convolutional and classification layers for some input text, taken from Yoon Kim's paper.

在本文中,構建的TextCNN網路結構如下圖所示,只是簡單的使用了幾個網路結構進行處理。其中AdaptiveMaxPool是Pytorch提供的一個模組,它能夠自動調整池化層的kernel大小,使得輸出為指定的shape。

程式碼及結果

程式碼示意圖如下:

class MyNet(nn.Module):
    def __init__(self,embedding_size):
        super(MyNet,self).__init__()
        self.embedding = nn.Embedding.from_pretrained(embedding_weight,freeze=False)
        self.conv = nn.Conv2d(1,256,(3,embedding_size)) # kernel_size 為(3,embedding_size)
        self.adaptive_max_pool = nn.AdaptiveMaxPool1d(2)
        self.fc = nn.Sequential(
            nn.Linear(256*2,128),
            nn.Dropout(0.6),
            nn.ReLU(),

            nn.Linear(128,2),   
        )
    
    def forward(self,x): # (batch_size,seq_len)
        x = self.embedding(x) #(batch_size,seq_len,embedding_size)
        x = x.unsqueeze(1) # (batch_size,1,seq_len,embedding_size) ,因為CNN的input為(N,C,H,W)
        x = self.conv(x) #(batch_size,256,seq_len-2,1)
        x = x.squeeze(3) #(batch_size,256,seq_len-2)
        x = F.relu(x)
        x = self.adaptive_max_pool(x) #(batch_size,256,2)
        x = torch.cat((x[:,:,0],x[:,:,1]),dim=1) #(batch_size,256*2)
        output = self.fc(x)
        return F.log_softmax(output,dim=1)
        
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
my_net = MyNet(embedding_weight.shape[1]).to(device)

最終訓練過程的ACC如下所示,當驗證集的loss最小時,測試集的acc為\(84.28\%\)

混淆矩陣:

TextCNN positive negative
positive 152 23
negative 47 159

LSTM網路

原理及網路結構

LSTM的網路結構如下所示,在本文中,只是簡單的利用了LSTM的網路最後一個狀態的輸出\(h_n\),然後將其輸入到全連線層中,最後輸出預測結果。

程式碼及結果

程式碼如下,在模型中,使用了兩層的LSTM。同時,為了提高LSTM模型的訓練效率, 使用pack_padded_sequence對padding之後的資料進行處理(使用參考:pack_padded_sequence 和 pad_packed_sequence - 知乎 (zhihu.com))。

import torch.nn as nn
import torch
import torch.nn.functional as F
import torch.optim as optim

class MyNet(nn.Module):
    def __init__(self,embedding_size,hidden_size,num_layers=2):
        super(MyNet,self).__init__()
        self.embedding = nn.Embedding.from_pretrained(embedding_weight,freeze=False) 
        self.num_layers = num_layers
        self.lstm = nn.LSTM(embedding_size,hidden_size,batch_first=True,bidirectional=True,num_layers=self.num_layers)
        self.fc = nn.Sequential(
            nn.BatchNorm1d(hidden_size),
            nn.Linear(hidden_size,128),
            nn.Dropout(0.6),
            nn.ReLU(),
            nn.Linear(128,2),   
        )
    
    def forward(self,input,data_len):
        
        input = self.embedding(input)
        input = nn_utils.rnn.pack_padded_sequence(input,data_len,batch_first=True,enforce_sorted=False)
        _,(h_n,c_n) = self.lstm(input) # h_n(num_layers*2,batch_size,hidden_size)
        h_n = torch.permute(h_n,(1,0,2)) # h_n(batch_size,num_layers*2,hidden_size)
        h_n = torch.sum(h_n,dim=1) # h_n (batch_size,hidden_size)
        output = self.fc(h_n)
        return F.log_softmax(output,dim=1)
        
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
my_net = MyNet(embedding_weight.shape[1],512).to(device)

最終訓練過程的ACC如下所示,當驗證集的loss最小時,測試集的acc為\(81.57\%\)

混淆矩陣:

LSTM positive negative
positive 140 21
negative 47 161

Attention-Based BiLSTM

原理及網路結構

在https://aclanthology.org/P16-2034.pdf論文中,Attention-Based BiLSTM演算法的結構圖如下所示:

Attention Layer的輸入:

\[h_{i}=\left[\overrightarrow{h_{i}} \oplus \overleftarrow{h_{i}}\right] \]

\(H\)\(T\)個詞經過BiLSTM得到的向量的集合:\(H = [h_1,h_2,\dots,h_T]\)

Attention的計算,其中\(w\)便是需要訓練的引數。

\[M = tanh(H)\\ \alpha = softmax(w^TM) \]

Attention層的輸出為:

\[r = H\alpha^T \]

程式碼及結果

下面的程式碼分別參考了兩位博主的程式碼,被註釋的參考了5,沒被註釋的參考了6。沒註釋的程式碼跑出來的效果更好一點(但是被註釋的程式碼更加符合論文的流程)。

class MyNet(nn.Module):
    def __init__(self,embedding_size,hidden_size,num_layers=1):
        super(MyNet,self).__init__()
        # 使用與訓練好的詞向量權重
        self.embedding = nn.Embedding.from_pretrained(embedding_weight,freeze=False) 
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        self.lstm = nn.LSTM(embedding_size,hidden_size,batch_first=True,bidirectional=True,num_layers=self.num_layers)
        
        # self.w = nn.Parameter(torch.Tensor(hidden_size,1))
        self.attention_w = nn.Sequential(
            nn.Linear(hidden_size,hidden_size),
            nn.Dropout(0.6),
            nn.ReLU()
        )
        self.fc = nn.Sequential(
            nn.BatchNorm1d(hidden_size),
            nn.Linear(hidden_size,256),
            nn.Dropout(0.6),
            nn.ReLU(),
            nn.Linear(256,2),
        )
        
    def attention_layer(self,lstm_output,lstm_h_n = None):
        """
            lstm_output:(batch_size,seq_len,hidden_size*2)
            lstm_h_n:(num_layers*2,batch_size,hidden_size)
        """
        # H = lstm_output[:,:,:self.hidden_size] + lstm_output[:,:,self.hidden_size:] # (batch_size,seq_len,hidden_size)
        # M = H # (batch_size,seq_len,hidden_size)

        # # w
        # lstm_h_n = lstm_h_n.permute(1,0,2) # (batch_size,num_layers*2,hidden_size)
        # lstm_h_n = torch.sum(lstm_h_n,dim=1) # (batch_size,hidden_size)
        # w = self.attention_w(lstm_h_n) # (batch_size,hidden_size)
        # w = lstm_h_n.unsqueeze(dim=1) # (batch_size,1,hidden_size)

        # # 生成alpha
        # alpha = F.softmax(torch.bmm(w,M.permute(0,2,1)),dim=2) # (batch_size,1,seq_len)

        # # 生成r
        # r = torch.bmm(alpha,H) #(batch_size,1,hidden_size)
        # r = r.squeeze(1)    #(batch_size,hidden_size)
        # return r 

        lstm_h_n = lstm_h_n.permute(1,0,2) # (batch_size,num_layers*2,hidden_size)
        lstm_h_n = torch.sum(lstm_h_n,dim=1) # (batch_size,hidden_size)
        attention_w = self.attention_w(lstm_h_n) # (batch_size,hidden_size)
        attention_w = attention_w.unsqueeze(dim=2) # (batch_size,hidden_size,1)

        H = lstm_output[:,:,:self.hidden_size] + lstm_output[:,:,self.hidden_size:] # (batch_size,seq_len,hidden_size)
        
        # alpha = F.softmax(torch.matmul(H,self.w),dim=1) #(batch_size,seq_len,1)
        alpha = F.softmax(torch.matmul(H,attention_w),dim=1) #(batch_size,seq_len,1)

        r = H * alpha # (batch_size,seq_len,hidden_size)
        out = torch.relu(torch.sum(r,1)) #(batch_size,hidden_size)

        return out
    
    def forward(self,input,data_len=None):
        input = self.embedding(input)
        input = nn_utils.rnn.pack_padded_sequence(input,data_len,batch_first=True,enforce_sorted=False)
        output,(h_n,c_n) = self.lstm(input) # output (batch_size,seq_len,hidden_size*2) h_n(num_layers*2,batch_size,hidden_size)
        output,_ = nn_utils.rnn.pad_packed_sequence(output,batch_first=True)
        output = self.attention_layer(output,h_n) #(batch_size,hidden_size)
        output = self.fc(output) # (batch_size,2)
        return F.log_softmax(output,dim=1)
        
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
my_net = MyNet(embedding_weight.shape[1],512,num_layers=1).to(device)

最終訓練過程的ACC如下所示,當驗證集的loss最小時,測試集的acc為\(85.64\%\)

混淆矩陣:

Attention-Based BiLSTM positive negative
positive 161 27
negative 26 155

總結

本文使用了3中模型對電影評論進行了分類。在實驗中,發現TextCNN和Attention-Based BiLSTM模型的效果比較好。

儘管Attention-Based BiLSTM取得效果略好於TextCNN,但是其在訓練的過程中需要耗費更多的時間,這是由LSTM的特性所決定的。

Reference

  1. pack_padded_sequence 和 pad_packed_sequence - 知乎 (zhihu.com)

  2. CNNs for Text Classification – Cezanne Camacho – Machine and deep learning educator.

  3. AdaptiveMaxPool1d — PyTorch 1.11.0 documentation

  4. https://aclanthology.org/P16-2034.pdf

  5. xiaobaicxy/text-classification-BiLSTM-Attention-pytorch: 文字分類, 雙向lstm + attention 演算法 (github.com)

  6. 文字分類演算法之BiLSTM+Attention - 樸素貝葉斯 - 部落格園 (cnblogs.com)

相關文章