Last Updated on 2021-06-02 by Clay
接續之前旅館評論分類的工作,詳情請參閱《[PyTorch] 旅館評論情感分析實戰紀錄 (0)》這篇文章,我再次對原本的分類模型進行了簡單的改良。至於改良了什麼呢?還記得我在上一篇文章中提到我只是胡亂地將每個字斷開,個別給相異字一個 Index 去代表該字——簡單來講,就只是把文字資料轉成單個數字,好用來進行 Machine Learning。
而這樣的作法其實在自然語言處理 (NLP) 的任務當中是相當少見的,至少從我開始學習以來沒怎麼見過。我常用的方法是使用 Python 中的 Gensim 套件去建立 Word2Vec 的模型,將文字對應在向量空間當中,每個『詞彙』都有著自己的『向量』。
詳細 Gensim 的使用方法可以參閱我的這篇文章《在 Python 中使用 Gensim 將文字轉成向量》。
雖然今天只做了這麼一點的工作,但效果卻好得出奇,再次讓我見識到了 Word2Vec 的神奇。
以下就簡單講講我怎麼做的。
旅館評論資料集
老樣子,首先再次介紹資料集。
這份資料集我是在 https://github.com/Chunshan-Theta/NLPLab 這裡找到的,在此感謝整理資料並分享的開發者。
基本上正面的評論都放在名叫 positiveReviews 的資料夾中、負面的評論都放在 negativeReviews 的資料夾中,基本上與 IMDB 常見的分法相仿,使用起來非常方便。
正面的評論一共有 196,337 筆,負面的評論一共有 145,321 筆。
是繁體中文的資料,大致上長相如下:
每一筆資料都獨立地儲存在一個文件裡。
前處理
這次的前處理與上次截然不同。這次我首先拿了 Wiki 上的中文資料訓練 Word2Vec 的模型,步驟還是可以參閱這篇《在 Python 中使用 Gensim 將文字轉成向量》。
不過再開始使用前,我還要額外計算出所謂的『平均向量』:
import json import numpy as np from gensim.models import word2vec model = word2vec.Word2Vec.load('word2vec.model') vec_avg = sum(np.array(model[word]) for word in model.wv.vocab)/len(model.wv.vocab) with open('vec_avg.json', 'w', encoding='utf-8') as f: json.dump(vec_avg.tolist(), f)
之所以這麼做事有道理的:仔細想想就會發現,萬一我們旅館評論裡的詞彙 Wiki 當中沒有怎麼辦?丟進 W2V 模型裡轉換可是會報錯的。故此,我們計算出平均的向量,專門用來填充沒看過的詞彙。
然後再次進行 Training data 以及 Test data 的切割:
# -*- coding: utf-8 -*- import os import json import jieba from sklearn.model_selection import train_test_split from gensim.models import word2vec # Settings pos_path = 'data/positiveReviews/' neg_path = 'data/negativeReviews/' seed = 666 w2v_model = word2vec.Word2Vec.load('word2vec.model') vec_padding = [0 for _ in range(100)] with open('vec_avg.json', 'r', encoding='utf-8') as f: vec_avg = json.load(f) # Positive data pos_data = [] for file in os.listdir(pos_path): text = open(pos_path+file, 'r', encoding='utf-8').read() words = [w for w in jieba.lcut(text) if w != ' '] data_temp = [] for w in words: try: word_vec = w2v_model[w].tolist() except: word_vec = vec_avg data_temp.append(word_vec) while len(data_temp) < 50: data_temp.append(vec_padding) if len(data_temp) > 50: data_temp = data_temp[:50] pos_data.append((data_temp, 1)) # Negative data neg_data = [] for file in os.listdir(neg_path): text = open(neg_path + file, 'r', encoding='utf-8').read() words = [w for w in jieba.lcut(text) if w != ' '] data_temp = [] for w in words: try: word_vec = w2v_model[w].tolist() except: word_vec = vec_avg data_temp.append(word_vec) while len(data_temp) < 50: data_temp.append(vec_padding) if len(data_temp) > 50: data_temp = data_temp[:50] neg_data.append((data_temp, 0)) # Split data train, test = train_test_split(pos_data+neg_data, random_state=seed, train_size=0.7) # Save json file with open('train_data.json', 'w', encoding='utf-8') as f: json.dump(train, f) with open('test_data.json', 'w', encoding='utf-8') as f: json.dump(test, f) print(len(train)) print(len(test)) print('Finished.')
客製資料集
與之前相同,由於我是使用 PyTorch 來搭建這個分類器,故需要使用 PyTorch 當中的 Dataset 來製作自己的資料集。
這裡就直接看程式碼吧:
# -*- coding: utf-8 -*- import json import torch from torch.utils.data.dataset import Dataset # Data class reviewDataset(Dataset): def __init__(self, train): self.train = train self.data = [] self.label = [] # Train if self.train: with open('train_data.json', 'r', encoding='utf-8') as f: self.train_data = json.load(f) for train_data in self.train_data: self.data.append(train_data[0]) self.label.append(train_data[1]) # Test else: with open('test_data.json', 'r', encoding='utf-8') as f: self.test_data = json.load(f) for test_data in self.test_data: self.data.append(test_data[0]) self.label.append(test_data[1]) # List convert to tensor self.data = torch.tensor(self.data).float() self.label = torch.tensor(self.label).float() def __getitem__(self, index): return (self.data[index], self.label[index]) def __len__(self): return self.label.shape[0]
這次與前一次的資料集定義相同,因為就算資料維度變了也不需要更改返回的 data 以及 label。
模型定義
模型定義就與上次不同了,因應不同的維度,輸入的尺寸需要變動:
# -*- coding: utf-8 -*- import torch.nn as nn # Model class fully_connected_model(nn.Module): def __init__(self): super(fully_connected_model, self).__init__() self.main = nn.Sequential( nn.Linear(5000, 2048), nn.ReLU(), nn.Linear(2048, 1024), nn.ReLU(), nn.Linear(1024, 256), nn.ReLU(), nn.Linear(256, 16), nn.ReLU(), nn.Linear(16, 1), nn.Sigmoid() ) def forward(self, input): return self.main(input)
訓練
# -*- coding: utf-8 -*- import time import torch import torch.nn as nn import torch.optim as optim import torch.utils.data as data from model import fully_connected_model from customDataset import reviewDataset start_time = time.time() # GPU device = 'cuda:0' if torch.cuda.is_available() else 'cpu' print('GPU State:', device) # Loss def loss_function(inputs, targets): return nn.BCELoss()(inputs, targets) # Model model = fully_connected_model().to(device) print(model) # Settings epochs = 20 lr = 0.002 batch_size = 16 optimizer = optim.Adam(model.parameters(), lr=lr) # DataLoader train_set = reviewDataset(train=True) train_loader = data.DataLoader(train_set, batch_size=batch_size, shuffle=True) # Train for epoch in range(epochs): epoch += 1 for times, data in enumerate(train_loader): times += 1 inputs = data[0].to(device) inputs = inputs.view(-1, 5000) labels = data[1].to(device) # Zero gradients optimizer.zero_grad() # Forward & Backward outputs = model(inputs).to(device) loss = loss_function(outputs, labels) loss.backward() optimizer.step() # Display loss if times % 100 == 0 or times == len(train_loader): print('[{}/{}, {}/{}] loss: {:.3f}'.format(epoch, epochs, times, len(train_loader), loss.item())) print('Training Finished.') # Saved torch.save(model, 'fc.pth') print('Model saved.')
跟之前相同的訓練流程,只是需要將輸入模型的資料使用 view() Resize 尺寸,畢竟模型可接受的尺寸與之前不同。
評估模型好壞
終於來到最後關頭了,上次的 F1 是 0.796%,現在使用了 Word2Vec 之後又能進步多少呢?
# -*- coding: utf-8 -*- import torch import torch.utils.data as data from customDataset import reviewDataset from sklearn import metrics # GPU device = 'cuda:0' if torch.cuda.is_available() else 'cpu' print('GPU State:', device) # Settings batch_size = 16 threshold = torch.tensor([0.5]).to(device) # Data test_set = reviewDataset(train=False) test_loader = data.DataLoader(test_set, batch_size=batch_size) # Model model = torch.load('fc.pth') model.eval() print(model) # Test pred = [] true = [] with torch.no_grad(): for data in test_loader: inputs = data[0].to(device) inputs = inputs.view(-1, 5000) labels = data[1].to(device) outputs = model(inputs).to(device) outputs = (outputs>threshold).float()*1 for n in range(len(outputs)): pred.append(outputs[n].tolist()[0]) true.append(labels[n].tolist()) print('Accuracy: {:.3f}%'.format(metrics.accuracy_score(true, pred))) print('Precision: {:.3f}%'.format(metrics.precision_score(true, pred))) print('Recall: {:.3f}%'.format(metrics.recall_score(true, pred))) print('F1: {:.3f}%'.format(metrics.f1_score(true, pred)))
Output:
Accuracy: 0.940
Precision: 0.948
Recall: 0.947
F1: 0.947
效果提昇地非常顯著!
後記
老實說,當我看到訓練出來的模型成果時,我有點不確定接下來還會不會繼續寫這個系列。應該說這個旅館評論的分類打從一開始直接以 Character 轉成 Index 時的分數就比我想像中高很多了。應該是因為評論使用的字正面跟負面真的差很多吧!
使用 Word2Vec 之後,效果更是提昇到我認為再調整一下模型就到極限的狀態。或許之後會試試看使用 RNN、LSTM、GRU 等等的神經網路?