文本情感分類
文本分類是自然語言處理的一個常見任務,它把一段不定長的文本序列變換為文本的類別。本節關注它的一個子問題:使用文本情感分類來分析文本作者的情緒。這個問題也叫情感分析,並有着廣泛的應用。
同搜索近義詞和類比詞一樣,文本分類也屬於詞嵌入的下游應用。在本節中,我們將應用預訓練的詞向量和含多個隱藏層的雙向循環神經網絡與卷積神經網絡,來判斷一段不定長的文本序列中包含的是正面還是負面的情緒。后續內容將從以下幾個方面展開:
- 文本情感分類數據集
- 使用循環神經網絡進行情感分類
- 使用卷積神經網絡進行情感分類
import collections
import os
import random
import time
from tqdm import tqdm
import torch
from torch import nn
import torchtext.vocab as Vocab
import torch.utils.data as Data
import torch.nn.functional as F
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
文本情感分類數據
我們使用斯坦福的IMDb數據集(Stanford’s Large Movie Review Dataset)作為文本情感分類的數據集。
讀取數據
數據集文件夾結構:
| aclImdb_v1
| train
| | pos
| | | 0_9.txt
| | | 1_7.txt
| | | ...
| | neg
| | | 0_3.txt
| | | 1_1.txt
| | ...
| test
| | pos
| | neg
| | ...
| ...
def read_imdb(folder='train', data_root="/home/kesci/input/IMDB2578/aclImdb_v1/aclImdb"):
data = []
for label in ['pos', 'neg']:
folder_name = os.path.join(data_root, folder, label)
for file in tqdm(os.listdir(folder_name)):
with open(os.path.join(folder_name, file), 'rb') as f:
review = f.read().decode('utf-8').replace('\n', '').lower()
data.append([review, 1 if label == 'pos' else 0])
random.shuffle(data)
return data
DATA_ROOT = "/home/kesci/input/IMDB2578/aclImdb_v1/"
data_root = os.path.join(DATA_ROOT, "aclImdb")
train_data, test_data = read_imdb('train', data_root), read_imdb('test', data_root)
# 打印訓練數據中的前五個sample
for sample in train_data[:5]:
print(sample[1], '\t', sample[0][:50])
100%|██████████| 12500/12500 [00:00<00:00, 15484.71it/s]
100%|██████████| 12500/12500 [00:00<00:00, 53658.60it/s]
100%|██████████| 12500/12500 [00:00<00:00, 53187.52it/s]
100%|██████████| 12500/12500 [00:00<00:00, 52966.52it/s]
1 i'm 60 years old, a guitarist, (lead/rhythm), and
0 it's the worst movie i've ever seen. the action is
1 i have seen the movie holes and say that it has to
1 i just saw this last night, it was broadcast on th
0 ...well, pop this into the dvd, waste an hour and
預處理數據
讀取數據后,我們先根據文本的格式進行單詞的切分,再利用 torchtext.vocab.Vocab
創建詞典。
def get_tokenized_imdb(data):
'''
@params:
data: 數據的列表,列表中的每個元素為 [文本字符串,0/1標簽] 二元組
@return: 切分詞后的文本的列表,列表中的每個元素為切分后的詞序列
'''
def tokenizer(text):
return [tok.lower() for tok in text.split(' ')]
return [tokenizer(review) for review, _ in data]
def get_vocab_imdb(data):
'''
@params:
data: 同上
@return: 數據集上的詞典,Vocab 的實例(freqs, stoi, itos)
'''
tokenized_data = get_tokenized_imdb(data)
counter = collections.Counter([tk for st in tokenized_data for tk in st])
return Vocab.Vocab(counter, min_freq=5)
vocab = get_vocab_imdb(train_data)
print('# words in vocab:', len(vocab))
# words in vocab: 46152
詞典和詞語的索引創建好后,就可以將數據集的文本從字符串的形式轉換為單詞下標序列的形式,以待之后的使用。
def preprocess_imdb(data, vocab):
'''
@params:
data: 同上,原始的讀入數據
vocab: 訓練集上生成的詞典
@return:
features: 單詞下標序列,形狀為 (n, max_l) 的整數張量
labels: 情感標簽,形狀為 (n,) 的0/1整數張量
'''
max_l = 500 # 將每條評論通過截斷或者補0,使得長度變成500
def pad(x):
return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x))
tokenized_data = get_tokenized_imdb(data)
features = torch.tensor([pad([vocab.stoi[word] for word in words]) for words in tokenized_data])
labels = torch.tensor([score for _, score in data])
return features, labels
創建數據迭代器
利用 torch.utils.data.TensorDataset
,可以創建 PyTorch 格式的數據集,從而創建數據迭代器。
train_set = Data.TensorDataset(*preprocess_imdb(train_data, vocab))
test_set = Data.TensorDataset(*preprocess_imdb(test_data, vocab))
# 上面的代碼等價於下面的注釋代碼
# train_features, train_labels = preprocess_imdb(train_data, vocab)
# test_features, test_labels = preprocess_imdb(test_data, vocab)
# train_set = Data.TensorDataset(train_features, train_labels)
# test_set = Data.TensorDataset(test_features, test_labels)
# len(train_set) = features.shape[0] or labels.shape[0]
# train_set[index] = (features[index], labels[index])
batch_size = 64
train_iter = Data.DataLoader(train_set, batch_size, shuffle=True)
test_iter = Data.DataLoader(test_set, batch_size)
for X, y in train_iter:
print('X', X.shape, 'y', y.shape)
break
print('#batches:', len(train_iter))
X torch.Size([64, 500]) y torch.Size([64])
#batches: 391
使用循環神經網絡
雙向循環神經網絡
在“雙向循環神經網絡”一節中,我們介紹了其模型與前向計算的公式,這里簡單回顧一下:
給定輸入序列 \(\{\boldsymbol{X}_1,\boldsymbol{X}_2,\dots,\boldsymbol{X}_T\}\),其中 \(\boldsymbol{X}_t\in\mathbb{R}^{n\times d}\) 為時間步(批量大小為 \(n\),輸入維度為 \(d\))。在雙向循環神經網絡的架構中,設時間步 \(t\) 上的正向隱藏狀態為 \(\overrightarrow{\boldsymbol{H}}_{t} \in \mathbb{R}^{n \times h}\) (正向隱藏狀態維度為 \(h\)),反向隱藏狀態為 \(\overleftarrow{\boldsymbol{H}}_{t} \in \mathbb{R}^{n \times h}\) (反向隱藏狀態維度為 \(h\))。我們可以分別計算正向隱藏狀態和反向隱藏狀態:
其中權重 \(\boldsymbol{W}_{x h}^{(f)} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{h h}^{(f)} \in \mathbb{R}^{h \times h}, \boldsymbol{W}_{x h}^{(b)} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{h h}^{(b)} \in \mathbb{R}^{h \times h}\) 和偏差 \(\boldsymbol{b}_{h}^{(f)} \in \mathbb{R}^{1 \times h}, \boldsymbol{b}_{h}^{(b)} \in \mathbb{R}^{1 \times h}\) 均為模型參數,\(\phi\) 為隱藏層激活函數。
然后我們連結兩個方向的隱藏狀態 \(\overrightarrow{\boldsymbol{H}}_{t}\) 和 \(\overleftarrow{\boldsymbol{H}}_{t}\) 來得到隱藏狀態 \(\boldsymbol{H}_{t} \in \mathbb{R}^{n \times 2 h}\),並將其輸入到輸出層。輸出層計算輸出 \(\boldsymbol{O}_{t} \in \mathbb{R}^{n \times q}\)(輸出維度為 \(q\)):
其中權重 \(\boldsymbol{W}_{h q} \in \mathbb{R}^{2 h \times q}\) 和偏差 \(\boldsymbol{b}_{q} \in \mathbb{R}^{1 \times q}\) 為輸出層的模型參數。不同方向上的隱藏單元維度也可以不同。
利用 torch.nn.RNN
或 torch.nn.LSTM
模組,我們可以很方便地實現雙向循環神經網絡,下面是以 LSTM 為例的代碼。
class BiRNN(nn.Module):
def __init__(self, vocab, embed_size, num_hiddens, num_layers):
'''
@params:
vocab: 在數據集上創建的詞典,用於獲取詞典大小
embed_size: 嵌入維度大小
num_hiddens: 隱藏狀態維度大小
num_layers: 隱藏層個數
'''
super(BiRNN, self).__init__()
self.embedding = nn.Embedding(len(vocab), embed_size)
# encoder-decoder framework
# bidirectional設為True即得到雙向循環神經網絡
self.encoder = nn.LSTM(input_size=embed_size,
hidden_size=num_hiddens,
num_layers=num_layers,
bidirectional=True)
self.decoder = nn.Linear(4*num_hiddens, 2) # 初始時間步和最終時間步的隱藏狀態作為全連接層輸入
def forward(self, inputs):
'''
@params:
inputs: 詞語下標序列,形狀為 (batch_size, seq_len) 的整數張量
@return:
outs: 對文本情感的預測,形狀為 (batch_size, 2) 的張量
'''
# 因為LSTM需要將序列長度(seq_len)作為第一維,所以需要將輸入轉置
embeddings = self.embedding(inputs.permute(1, 0)) # (seq_len, batch_size, d)
# rnn.LSTM 返回輸出、隱藏狀態和記憶單元,格式如 outputs, (h, c)
outputs, _ = self.encoder(embeddings) # (seq_len, batch_size, 2*h)
encoding = torch.cat((outputs[0], outputs[-1]), -1) # (batch_size, 4*h)
outs = self.decoder(encoding) # (batch_size, 2)
return outs
embed_size, num_hiddens, num_layers = 100, 100, 2
net = BiRNN(vocab, embed_size, num_hiddens, num_layers)
加載預訓練的詞向量
由於預訓練詞向量的詞典及詞語索引與我們使用的數據集並不相同,所以需要根據目前的詞典及索引的順序來加載預訓練詞向量。
cache_dir = "/home/kesci/input/GloVe6B5429"
glove_vocab = Vocab.GloVe(name='6B', dim=100, cache=cache_dir)
def load_pretrained_embedding(words, pretrained_vocab):
'''
@params:
words: 需要加載詞向量的詞語列表,以 itos (index to string) 的詞典形式給出
pretrained_vocab: 預訓練詞向量
@return:
embed: 加載到的詞向量
'''
embed = torch.zeros(len(words), pretrained_vocab.vectors[0].shape[0]) # 初始化為0
oov_count = 0 # out of vocabulary
for i, word in enumerate(words):
try:
idx = pretrained_vocab.stoi[word]
embed[i, :] = pretrained_vocab.vectors[idx]
except KeyError:
oov_count += 1
if oov_count > 0:
print("There are %d oov words." % oov_count)
return embed
net.embedding.weight.data.copy_(load_pretrained_embedding(vocab.itos, glove_vocab))
net.embedding.weight.requires_grad = False # 直接加載預訓練好的, 所以不需要更新它
99%|█████████▉| 397764/400000 [00:15<00:00, 27536.08it/s]
There are 21202 oov words.
99%|█████████▉| 397764/400000 [00:30<00:00, 27536.08it/s]
訓練模型
訓練時可以調用之前編寫的 train
及 evaluate_accuracy
函數。
def evaluate_accuracy(data_iter, net, device=None):
if device is None and isinstance(net, torch.nn.Module):
device = list(net.parameters())[0].device
acc_sum, n = 0.0, 0
with torch.no_grad():
for X, y in data_iter:
if isinstance(net, torch.nn.Module):
net.eval()
acc_sum += (net(X.to(device)).argmax(dim=1) == y.to(device)).float().sum().cpu().item()
net.train()
else:
if('is_training' in net.__code__.co_varnames):
acc_sum += (net(X, is_training=False).argmax(dim=1) == y).float().sum().item()
else:
acc_sum += (net(X).argmax(dim=1) == y).float().sum().item()
n += y.shape[0]
return acc_sum / n
def train(train_iter, test_iter, net, loss, optimizer, device, num_epochs):
net = net.to(device)
print("training on ", device)
batch_count = 0
for epoch in range(num_epochs):
train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time()
for X, y in train_iter:
X = X.to(device)
y = y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
optimizer.zero_grad()
l.backward()
optimizer.step()
train_l_sum += l.cpu().item()
train_acc_sum += (y_hat.argmax(dim=1) == y).sum().cpu().item()
n += y.shape[0]
batch_count += 1
test_acc = evaluate_accuracy(test_iter, net)
print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, time %.1f sec'
% (epoch + 1, train_l_sum / batch_count, train_acc_sum / n, test_acc, time.time() - start))
由於嵌入層的參數是不需要在訓練過程中被更新的,所以我們利用 filter
函數和 lambda
表達式來過濾掉模型中不需要更新參數的部分。
lr, num_epochs = 0.01, 5
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr)
loss = nn.CrossEntropyLoss()
train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
training on cpu
epoch 1, loss 0.9336, train acc 0.658, test acc 0.788, time 361.3 sec
epoch 2, loss 0.2986, train acc 0.811, test acc 0.744, time 364.4 sec
epoch 3, loss 0.1692, train acc 0.879, test acc 0.791, time 353.2 sec
epoch 4, loss 0.1331, train acc 0.910, test acc 0.782, time 361.1 sec
epoch 5, loss 0.1177, train acc 0.918, test acc 0.771, time 366.7 sec
training on cuda
100%|█████████▉| 398892/400000 [00:40<00:00, 18148.73it/s]
epoch 1, loss 0.6206, train acc 0.631, test acc 0.798, time 41.7 sec
epoch 2, loss 0.2079, train acc 0.813, test acc 0.819, time 42.1 sec
epoch 3, loss 0.1186, train acc 0.843, test acc 0.847, time 40.8 sec
epoch 4, loss 0.0777, train acc 0.869, test acc 0.854, time 41.2 sec
epoch 5, loss 0.0544, train acc 0.887, test acc 0.861, time 41.8 sec
注:由於本地CPU上訓練時間過長,故只截取了運行的結果,后同。大家可以自行在網站上訓練。
評價模型
def predict_sentiment(net, vocab, sentence):
'''
@params:
net: 訓練好的模型
vocab: 在該數據集上創建的詞典,用於將給定的單詞序轉換為單詞下標的序列,從而輸入模型
sentence: 需要分析情感的文本,以單詞序列的形式給出
@return: 預測的結果,positive 為正面情緒文本,negative 為負面情緒文本
'''
device = list(net.parameters())[0].device # 讀取模型所在的環境
sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device)
label = torch.argmax(net(sentence.view((1, -1))), dim=1)
return 'positive' if label.item() == 1 else 'negative'
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great'])
'positive'
'positive'
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
'positive'
'negative'
使用卷積神經網絡
一維卷積層
在介紹模型前我們先來解釋一維卷積層的工作原理。與二維卷積層一樣,一維卷積層使用一維的互相關運算。在一維互相關運算中,卷積窗口從輸入數組的最左方開始,按從左往右的順序,依次在輸入數組上滑動。當卷積窗口滑動到某一位置時,窗口中的輸入子數組與核數組按元素相乘並求和,得到輸出數組中相應位置的元素。如圖所示,輸入是一個寬為 7 的一維數組,核數組的寬為 2。可以看到輸出的寬度為 7−2+1=6,且第一個元素是由輸入的最左邊的寬為 2 的子數組與核數組按元素相乘后再相加得到的:0×1+1×2=2。
def corr1d(X, K):
'''
@params:
X: 輸入,形狀為 (seq_len,) 的張量
K: 卷積核,形狀為 (w,) 的張量
@return:
Y: 輸出,形狀為 (seq_len - w + 1,) 的張量
'''
w = K.shape[0] # 卷積窗口寬度
Y = torch.zeros((X.shape[0] - w + 1))
for i in range(Y.shape[0]): # 滑動窗口
Y[i] = (X[i: i + w] * K).sum()
return Y
X, K = torch.tensor([0, 1, 2, 3, 4, 5, 6]), torch.tensor([1, 2])
print(corr1d(X, K))
tensor([ 2., 5., 8., 11., 14., 17.])
多輸入通道的一維互相關運算也與多輸入通道的二維互相關運算類似:在每個通道上,將核與相應的輸入做一維互相關運算,並將通道之間的結果相加得到輸出結果。下圖展示了含 3 個輸入通道的一維互相關運算,其中陰影部分為第一個輸出元素及其計算所使用的輸入和核數組元素:0×1+1×2+1×3+2×4+2×(−1)+3×(−3)=2。
def corr1d_multi_in(X, K):
# 首先沿着X和K的通道維遍歷並計算一維互相關結果。然后將所有結果堆疊起來沿第0維累加
return torch.stack([corr1d(x, k) for x, k in zip(X, K)]).sum(dim=0)
# [corr1d(X[i], K[i]) for i in range(X.shape[0])]
X = torch.tensor([[0, 1, 2, 3, 4, 5, 6],
[1, 2, 3, 4, 5, 6, 7],
[2, 3, 4, 5, 6, 7, 8]])
K = torch.tensor([[1, 2], [3, 4], [-1, -3]])
print(corr1d_multi_in(X, K))
tensor([ 2., 8., 14., 20., 26., 32.])
由二維互相關運算的定義可知,多輸入通道的一維互相關運算可以看作單輸入通道的二維互相關運算。如圖所示,我們也可以將圖中多輸入通道的一維互相關運算以等價的單輸入通道的二維互相關運算呈現。這里核的高等於輸入的高。圖中的陰影部分為第一個輸出元素及其計算所使用的輸入和核數組元素:2×(−1)+3×(−3)+1×3+2×4+0×1+1×2=2。
注:反之僅當二維卷積核的高度等於輸入的高度時才成立。
之前的例子中輸出都只有一個通道。我們在“多輸入通道和多輸出通道”一節中介紹了如何在二維卷積層中指定多個輸出通道。類似地,我們也可以在一維卷積層指定多個輸出通道,從而拓展卷積層中的模型參數。
時序最大池化層
類似地,我們有一維池化層。TextCNN 中使用的時序最大池化(max-over-time pooling)層實際上對應一維全局最大池化層:假設輸入包含多個通道,各通道由不同時間步上的數值組成,各通道的輸出即該通道所有時間步中最大的數值。因此,時序最大池化層的輸入在各個通道上的時間步數可以不同。
注:自然語言中還有一些其他的池化操作,可參考這篇博文。
為提升計算性能,我們常常將不同長度的時序樣本組成一個小批量,並通過在較短序列后附加特殊字符(如0)令批量中各時序樣本長度相同。這些人為添加的特殊字符當然是無意義的。由於時序最大池化的主要目的是抓取時序中最重要的特征,它通常能使模型不受人為添加字符的影響。
class GlobalMaxPool1d(nn.Module):
def __init__(self):
super(GlobalMaxPool1d, self).__init__()
def forward(self, x):
'''
@params:
x: 輸入,形狀為 (batch_size, n_channels, seq_len) 的張量
@return: 時序最大池化后的結果,形狀為 (batch_size, n_channels, 1) 的張量
'''
return F.max_pool1d(x, kernel_size=x.shape[2]) # kenerl_size=seq_len
TextCNN 模型
TextCNN 模型主要使用了一維卷積層和時序最大池化層。假設輸入的文本序列由 \(n\) 個詞組成,每個詞用 \(d\) 維的詞向量表示。那么輸入樣本的寬為 \(n\),輸入通道數為 \(d\)。TextCNN 的計算主要分為以下幾步。
- 定義多個一維卷積核,並使用這些卷積核對輸入分別做卷積計算。寬度不同的卷積核可能會捕捉到不同個數的相鄰詞的相關性。
- 對輸出的所有通道分別做時序最大池化,再將這些通道的池化輸出值連結為向量。
- 通過全連接層將連結后的向量變換為有關各類別的輸出。這一步可以使用丟棄層應對過擬合。
下圖用一個例子解釋了 TextCNN 的設計。這里的輸入是一個有 11 個詞的句子,每個詞用 6 維詞向量表示。因此輸入序列的寬為 11,輸入通道數為 6。給定 2 個一維卷積核,核寬分別為 2 和 4,輸出通道數分別設為 4 和 5。因此,一維卷積計算后,4 個輸出通道的寬為 11−2+1=10,而其他 5 個通道的寬為 11−4+1=8。盡管每個通道的寬不同,我們依然可以對各個通道做時序最大池化,並將 9 個通道的池化輸出連結成一個 9 維向量。最終,使用全連接將 9 維向量變換為 2 維輸出,即正面情感和負面情感的預測。
下面我們來實現 TextCNN 模型。與上一節相比,除了用一維卷積層替換循環神經網絡外,這里我們還使用了兩個嵌入層,一個的權重固定,另一個則參與訓練。
class TextCNN(nn.Module):
def __init__(self, vocab, embed_size, kernel_sizes, num_channels):
'''
@params:
vocab: 在數據集上創建的詞典,用於獲取詞典大小
embed_size: 嵌入維度大小
kernel_sizes: 卷積核大小列表
num_channels: 卷積通道數列表
'''
super(TextCNN, self).__init__()
self.embedding = nn.Embedding(len(vocab), embed_size) # 參與訓練的嵌入層
self.constant_embedding = nn.Embedding(len(vocab), embed_size) # 不參與訓練的嵌入層
self.pool = GlobalMaxPool1d() # 時序最大池化層沒有權重,所以可以共用一個實例
self.convs = nn.ModuleList() # 創建多個一維卷積層
for c, k in zip(num_channels, kernel_sizes):
self.convs.append(nn.Conv1d(in_channels = 2*embed_size,
out_channels = c,
kernel_size = k))
self.decoder = nn.Linear(sum(num_channels), 2)
self.dropout = nn.Dropout(0.5) # 丟棄層用於防止過擬合
def forward(self, inputs):
'''
@params:
inputs: 詞語下標序列,形狀為 (batch_size, seq_len) 的整數張量
@return:
outputs: 對文本情感的預測,形狀為 (batch_size, 2) 的張量
'''
embeddings = torch.cat((
self.embedding(inputs),
self.constant_embedding(inputs)), dim=2) # (batch_size, seq_len, 2*embed_size)
# 根據一維卷積層要求的輸入格式,需要將張量進行轉置
embeddings = embeddings.permute(0, 2, 1) # (batch_size, 2*embed_size, seq_len)
encoding = torch.cat([
self.pool(F.relu(conv(embeddings))).squeeze(-1) for conv in self.convs], dim=1)
# encoding = []
# for conv in self.convs:
# out = conv(embeddings) # (batch_size, out_channels, seq_len-kernel_size+1)
# out = self.pool(F.relu(out)) # (batch_size, out_channels, 1)
# encoding.append(out.squeeze(-1)) # (batch_size, out_channels)
# encoding = torch.cat(encoding) # (batch_size, out_channels_sum)
# 應用丟棄法后使用全連接層得到輸出
outputs = self.decoder(self.dropout(encoding))
return outputs
embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
net = TextCNN(vocab, embed_size, kernel_sizes, nums_channels)
訓練並評價模型
lr, num_epochs = 0.001, 5
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr)
loss = nn.CrossEntropyLoss()
train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
training on cpu
epoch 1, loss 0.2317, train acc 0.956, test acc 0.782, time 374.0 sec
epoch 2, loss 0.0527, train acc 0.973, test acc 0.780, time 372.5 sec
epoch 3, loss 0.0211, train acc 0.981, test acc 0.783, time 375.3 sec
epoch 4, loss 0.0119, train acc 0.985, test acc 0.788, time 370.7 sec
epoch 5, loss 0.0078, train acc 0.989, test acc 0.791, time 370.8 sec
training on cuda
epoch 1, loss 0.6314, train acc 0.666, test acc 0.803, time 15.9 sec
epoch 2, loss 0.2416, train acc 0.766, test acc 0.807, time 15.9 sec
epoch 3, loss 0.1330, train acc 0.821, test acc 0.849, time 15.9 sec
epoch 4, loss 0.0825, train acc 0.858, test acc 0.860, time 16.0 sec
epoch 5, loss 0.0494, train acc 0.898, test acc 0.865, time 15.9 sec
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great'])
'positive'
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
'negative'