在上一阶段使用基础的RNN模型完成了简单的情感分析,这一节将在上一节的基础作出以下优化:
- packed padded sequences
- pre-trained word embeddings
- different RNN architecture
- bidirectional RNN
- multi-layer RNN
- regularization
- a different optimizer
使得准确率提升到84%
准备数据
就像之前一样,使用Fields获得数据的处理方式
我们将使用packed padded sequences,这将使我们的RNN仅处理序列的非填充元素,对于任何填充元素,output将为零张量。
要使用packed padded sequences,我们必须告诉RNN实际序列有多长。 为此,我们为TEXT字段设置include_lengths = True。 这将使batch.text现在成为一个元组,第一个元素是我们的句子(已填充的数字化张量),第二个元素是我们句子的实际长度。
import torch
from torchtext.legacy import data
SEED = 1234
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic = True
TEXT = data.Field(tokenize = 'spacy',
tokenizer_language = 'en_core_web_sm',
include_lengths = True)
LABEL = data.LabelField(dtype = torch.float)
加载数据集,划分训练集、验证集
from torchtext.legacy import datasets
train_data, test_data = datasets.IMDB.splits(TEXT, LABEL)
import random
train_data, valid_data = train_data.split(random_state = random.seed(SEED))
接下来引入预训练的word embedding。我们不再使用随机初始化Word embedding,而是使用经过预训练的向量进行初始化。
只需要指定预训练的向量并将其作为参数传递给build_vocab即可获得这些向量。 TorchText负责下载矢量并将他们与我们词汇表中正确单词相关联。
在这里,我们将使用“ glove.6B.100d”向量。Gloves是用于计算向量的算法,在这里可以查看更多信息。6B表示这些向量是在60亿个token上训练的,而100d表示这些向量是100维。
从理论上讲,这些经过预训练的向量在向量空间中已经具有彼此接近的语义相似的单词,例如 “可怕”,“可怕”,“可怕”就在附近。 这为我们的嵌入层提供了很好的初始化方法,因为它不必从头开始学习这些关系。
默认情况下,TorchText会将词汇表中的单词初始化,但不会将pre-trained embeddings初始化为零。 我们不希望这样,而是通过将unk_init设置为torch.Tensor.normal_来随机初始化它们。 现在,这将通过高斯分布来初始化这些单词。
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)
和以前一样,我们创建迭代器,将张量放置在GPU上(如果可用)。
packed padded sequences的另一件事是,batch所有张量都需要按其长度排序。 通过设置sort_within_batch = True,可以在迭代器中进行处理。
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,
sort_within_batch = True,
device = device)
创建模型
LSTM
传统的RNN饱受梯度消失的困扰。
LSTM通过一种叫门的结构来解决这个问题,使模型具有删除信息或将信息添加到单元状态的能力。我们可以简单的将LSTM看做是用
x
t
,
h
t
,
c
t
x_t,h_t,c_t
xt,ht,ct代替了之前的
x
t
,
h
t
x_t,h_t
xt,ht
(
h
t
,
c
t
)
=
L
S
T
M
(
x
t
,
h
t
,
c
t
)
(h_t,c_t) = LSTM(x_t,h_t,c_t)
(ht,ct)=LSTM(xt,ht,ct)
其模型结构看起来如下图:
像初始隐藏状态
h
0
h_0
h0一样,初始单元状态
c
0
c_0
c0被初始化为全零的张量。最后情感预测使仍使用最终的隐藏状态
h
T
h_T
hT而不是最终的单元状态
c
T
c_T
cT进行,即
y
h
a
t
=
f
(
h
T
)
y_hat = f(h_T)
yhat=f(hT)
Bidirectional RNN
双向RNN背后的原理很简单。前向RNN就是从头到尾的处理单词,后向RNN就是从尾到头的处理单词。在时间步 t t t,前向RNN处理的是Word x t x_t xt,后向RNN处理的是第 x T − T + 1 x_T-T+1 xT−T+1个词。
在pytorch中,前向和后向RNN返回的隐藏状态(LSTM就还有单元状态)的张量堆叠在单个张量中。
我们将前向的最后一个隐藏状态和后向的最后一个隐藏状态连接起来进行情感预测。
下图显示了双向RNN,前向RNN为橙色,后向RNN为绿色,线性层为银色。
多层RNN
多层RNN特可以叫deep RNNs也是一个很简单的概念 。就是在单层RNN上再叠加几层RNN。每一个时间步中第一层RNN隐藏状态的输出将作为下一层RNN的输入 .然后根据最高层的RNN的最终隐藏状态进行预测。
每一层都有自己的初始隐藏状态 h 0 L h_{0}^{L} h0L
正则化 Regularization
虽然已经对模型进行了改进,但是模型的参数越多,越容易过拟合。过拟合具体表现在:模型在训练数据上损失函数较小,预测准确率较高;但是在测试数据上损失函数比较大,预测准确率较低。
为了解决这个问题,我们使用dropout正则化方法。dropout说的简单一点就是我们在前向传播的时候,让某个神经元的激活值以一定的概率p停止工作(值为0),这样可以使模型泛化性更强,因为它不会太依赖某些局部的特征。如下图所示:
更详细的解释参考这里
实现细节
我们将不会学习<pad>的嵌入,我们需要告诉模型,填充标记与确定句子的情感无关。为此,我们将<pad> 的索引作为padding_idx参数传递给nn.Embedding层,将填充的嵌入保留为初始的样子。
使用LSTM代替RNN。LSTM返回:输出和最终隐藏状态以及最终单元状态的元祖;RNN仅返回:输出和最终隐藏状态。
由于LSTM的最终隐藏状态同时具有前向和后向分量,它们将被串联在一起,因此nn.Linear层的输入大小是隐藏维大小的两倍。
通过传递num_layers的值和LSTM的bidirection来实现双向性以及添加其它层。
Dropout通过初始化nn.Dropout层(参数是删除每个神经元的概率)实现,然后在forward方法中实现。需要注意的是,不要在输入或输出层使用dropout,而应该在中间层使用。LSTM有一个dropout参数,该参数在一层的隐藏状态和下一层中的隐藏状态之间的连接上添加了dropout.
为了能够使用 packed padded sequences,在forward中传递参数时必须传入text_lengths
在将embedding传入LSTM之前,需要使用
nn.utils.rnn.packed_padded_sequence. 对其进行packed.这样我们的模型就只需要处理序列中未进行填充的元素。然后模型返回packed_output(打包序列),hidden,cell状态(均为张量)。如果没有packed_padded_sequence,那么返回最后一个元素的隐藏状态和单元状态,其中很有可能包含<pad>.当我们使用
packed_padded_sequence,他们都来自序列中最后一个非填充元素。请注意,packed_padded_sequence的lengths参数必须是CPU张量,因此我们可以使用.to(‘cpu’)明确地将其设为1。
我们再使用 nn.utils.rnn.pad_packed_sequence
将压缩序列转换为张量。填充令牌的输出元素将为零张量(每个元素为零的张量)。 通常,只有在以后在模型中使用输出时,才需要解压缩输出。 尽管我们不是在这种情况下,但是我们仍然将序列拆包只是为了展示它是如何完成的。
最后的隐藏状态
h
T
h_T
hT的维度为[num_layers*num directions,batch size,hid dim]
他们将按照顺序排列[forward_layer_0, backward_layer_0, forward_layer_1, backward_layer 1, ..., forward_layer_n, backward_layer n]
当我们想要最后(最顶层)的前向和后向的隐藏状态时,我们从以一个维度获取了最上面两个隐藏层,即hidden[-2,:,:]
和hidden[-1,:,:]
,并将他们连接起来(在输入线性层之前,应用dropout之后)
import torch.nn as nn
class RNN(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers,
bidirectional, dropout, pad_idx):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
self.rnn = nn.LSTM(embedding_dim,
hidden_dim,
num_layers=n_layers,
bidirectional=bidirectional,
dropout=dropout)
self.fc = nn.Linear(hidden_dim * 2, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, text, text_lengths):
#text = [sent len, batch size]
embedded = self.dropout(self.embedding(text))
#embedded = [sent len, batch size, emb dim]
#pack sequence
# lengths need to be on CPU!
packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, text_lengths.to('cpu'))
packed_output, (hidden, cell) = self.rnn(packed_embedded)
#unpack sequence
output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output)
#output = [sent len, batch size, hid dim * num directions]
#output over padding tokens are zero tensors
#hidden = [num layers * num directions, batch size, hid dim]
#cell = [num layers * num directions, batch size, hid dim]
#concat the final forward (hidden[-2,:,:]) and backward (hidden[-1,:,:]) hidden layers
#and apply dropout
hidden = self.dropout(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1))
#hidden = [batch size, hid dim * num directions]
return self.fc(hidden)
像以前一样,我们将创建RNN类的实例。
为了确保可以将预先训练的向量加载到模型中,EMBEDDING_DIM必须等于之前加载的预先训练的GloVe向量维度(100)。 我们从词汇表中获取<pad>索引,从字段的pad_token属性(默认为)中获取表示填充令牌的实际字符串。
INPUT_DIM = len(TEXT.vocab)
EMBEDDING_DIM = 100
HIDDEN_DIM = 256
OUTPUT_DIM = 1
N_LAYERS = 2
BIDIRECTIONAL = True
DROPOUT = 0.5
PAD_IDX = TEXT.vocab.stoi[TEXT.pad_token]
model = RNN(INPUT_DIM,
EMBEDDING_DIM,
HIDDEN_DIM,
OUTPUT_DIM,
N_LAYERS,
BIDIRECTIONAL,
DROPOUT,
PAD_IDX)
我们将打印出模型中的参数数量。 请注意,我们的参数几乎是以前的两倍
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 4,810,857 trainable parameters
最后将我们之前加载的预训练词嵌入复制到模型的嵌入层中。 我们从字段的vocab中检索嵌入,并检查它们的大小是否正确,[vocab size,embedding dim]
pretrained_embeddings = TEXT.vocab.vectors
print(pretrained_embeddings.shape)
torch.Size([25002, 100])
然后,我们用预训练的嵌入替换嵌入层的初始权重。
注意:这应该始终在weight.data上执行,而不是在weight上!
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]])
'''
<unk> <pad>不包括在预训练的词想两种,所以把存在两种token的地方都初始化为维度与embedding_dim相同的全零矩阵,这样相当于告诉我们的模型他们最初和确定情绪无关
注意:就像初始化嵌入一样,这应该在weight.data而不是weight上完成!
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)
print(model.embedding.weight.data)
'''
tensor([[ 0.0000, 0.0000, 0.0000, ..., 0.0000, 0.0000, 0.0000],
[ 0.0000, 0.0000, 0.0000, ..., 0.0000, 0.0000, 0.0000],
[-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]])
'''
我们可以看到embedding权重矩阵的前两行置为0,<pad>在训练过程中将全程保持为0,<unk>将会被训练
训练模型
在训练过程的做的唯一优化就是将optimizer从SGD换成Adam.
SGD使用相同的学习速率来更新所有参数,在选择这个学习率的时候会很棘手。
Adam的学习率会随着训练变化,他会调整每个参数的学习率,从而使更新频率高的参数具有较低的学习率,而更新频率较低的参数具有较高的学习率。
要将SGD更改为Adam,我们只需将optim.SGD更改为optim.Adam,还要注意,由于PyTorch指定了合理的默认初始学习率,因此我们不必为Adam提供初始学习率。
import torch.optim as optim
optimizer = optim.Adam(model.parameters())
训练模型的其余步骤保持不变。 我们定义标准并将模型和标准放置在GPU上(如果可用)…
criterion = nn.BCEWithLogitsLoss()
model = model.to(device)
criterion = criterion.to(device)
计算准确率
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
当我们设置include_lengths = True
时,我们的batch.text
现在是一个元组,第一个元素是数字化的张量,第二个元素是每个序列的实际长度。 在将它们传递给模型之前,我们将它们分为各自的变量text
和text_lengths
。
注意:由于我们现在正在使用Dropout,因此必须记住使用model.train()
,这个模式下dropout才被“打开”。
def train(model, iterator, optimizer, criterion):
epoch_loss = 0
epoch_acc = 0
model.train()
for batch in iterator:
optimizer.zero_grad()
text, text_lengths = batch.text
predictions = model(text, text_lengths).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)
验证模式
def evaluate(model, iterator, criterion):
epoch_loss = 0
epoch_acc = 0
model.eval()
with torch.no_grad():
for batch in iterator:
text, text_lengths = batch.text
predictions = model(text, text_lengths).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)
计算训练时间
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
训练模型
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(), 'tut2-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}%')
'''
Epoch: 01 | Epoch Time: 0m 36s
Train Loss: 0.673 | Train Acc: 58.05%
Val. Loss: 0.619 | Val. Acc: 64.97%
Epoch: 02 | Epoch Time: 0m 36s
Train Loss: 0.611 | Train Acc: 66.33%
Val. Loss: 0.510 | Val. Acc: 74.32%
Epoch: 03 | Epoch Time: 0m 37s
Train Loss: 0.484 | Train Acc: 77.04%
Val. Loss: 0.397 | Val. Acc: 82.95%
Epoch: 04 | Epoch Time: 0m 37s
Train Loss: 0.384 | Train Acc: 83.57%
Val. Loss: 0.407 | Val. Acc: 83.23%
Epoch: 05 | Epoch Time: 0m 37s
Train Loss: 0.314 | Train Acc: 86.98%
Val. Loss: 0.314 | Val. Acc: 86.36%
'''
测试
model.load_state_dict(torch.load('tut2-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.334 | Test Acc: 85.28%
'''
用户输入测试
现在我们可以使用模型来预测一些句子的情感了,由于训练时使用的是电影方面的数据,因此提供的句子也应该是电影评论。
在使用模型进行推理(inference),应该始终处于evaluation模式 。
我们的predict_sentiment
功能做了以下的步骤 :
- 设置model为evaluation模式
- 分词
- 通过词汇表找到文本对应ID,以整数的形式来表示token
- 获得序列的长度
- 将列表转换为pytorch 张量
- 通过
unsqueese
添加batch尺寸 -
将句子长度转换为张量
- 使用sigmod函数将输出变换为0-1之间的实数
- 使用item()方法将持有单个值的张量转换为整数
我们期望带有负面情绪的评论返回接近0的值,而正面评价则返回接近1的值。
import spacy
nlp = spacy.load('en_core_web_sm')
def predict_sentiment(model, sentence):
model.eval()
tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
indexed = [TEXT.vocab.stoi[t] for t in tokenized]
length = [len(indexed)]
tensor = torch.LongTensor(indexed).to(device)
tensor = tensor.unsqueeze(1)
length_tensor = torch.LongTensor(length)
prediction = torch.sigmoid(model(tensor, length_tensor))
return prediction.item()
predict_sentiment(model, "This film is terrible")
'''
0.05380420759320259
'''
predict_sentiment(model, "This film is great")
'''
0.94941645860672
'''
下一步
我们现在为电影评论建立了一个简单的情感分析模型! 在下一个笔记本中,我们将实现一个模型,该模型可以以更少的参数获得可比的精度,并且训练速度快得多。