文章目录
Updated Sentiment Analysis
在前一章,我们做了基础的情感分析,在这一章,我们会得到一个更好的分类结果
我们会使用
packed padded sequences
pre-trained word embeddings
different RNN architecture
bidirectional RNN
multi-layer RNN
regularization
a different optimizer
1.准备数据
我们会使用 packed padded sequences
,这会使得我们的RNN只会处理那些非padded的元素,对于那些padded(截长)的部分,都会变成0.为了将每个样本截长补短,需要设置include_lengths = True
import torch
from torchtext import data
from torchtext import datasets
SEED = 1234
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic = True
TEXT = data.Field(tokenize = 'spacy', include_lengths = True) # include_lengths = True,为了之后对每个句子设置固定长度
LABEL = data.LabelField(dtype = torch.float)
加载数据
from torchtext 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))
2.词向量
我们使用预先训练好的词向量,我们获得这些词向量是通过指定将参数传进build_vocab
TorchText会自动下载,并将词与词向量联系起来
这里我们使用的是"glove.6B.100d",glove是个算法,用来计算词向量的.6B意味着,词向量是在60亿tokens上训练出来的.100d意味着,词向量是100维的
还有其他的:
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)
charngram.100d
fasttext.en.300d
fasttext.simple.300d
glove.42B.300d
glove.840B.300d
glove.twitter.27B.25d
glove.twitter.27B.50d
glove.twitter.27B.100d
glove.twitter.27B.200d
glove.6B.50d
glove.6B.100d
glove.6B.200d
glove.6B.300d
训练好的词向量,语义相近的词在向量空间里靠的近.比如terrible和awful,dreadful就很近
这样就能给我们的嵌入层embedding layer一个好的初始化条件.就不需要从零开始学这些关系了
(注意,这个词向量有800多兆)
3.创建迭代器+使用GPU
BATCH_SIZE = 64
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 创建迭代器,将数据集一个batch一个batch往模型中送
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)
4. 构建模型
LSTM: 这里介绍另一种RNN结构,LSTM. 因为标准的RNN有梯度消失的问题,LSTM克服了这一点.通过加入一个recurrent state叫做cell state,存储的是远期记忆,hidden state存储的是近期记忆
Bidirectional RNN: 双向RNN:单向的RNN好理解,就是将分词后的词向量一个一个的往模型里面输入,而双向就是多加了一个从右到左的一条输入
Multi-layer RNN:多层RNN可以被视为深度RNN,就是在最开始的标准RNN上多加几层RNN,
正则化Regularization
前几个改进模型能提升分类器的性能.但是加了许多要训练的参数,避免过度你和.要使用正则化,惩罚项.
或是放弃某些节点,dropout.:前向传播的时候,随机删除一层里的部分神经元
实施细节
1.对于每个样本里补短后加上的pad token,模型是不应该对其进行训练的.我们要显式的告诉模型,padding token跟句子的情感是无关的.这就意味着pad token的嵌入层(词向量)会一直保持初始化的样子,都是0. 如何做到?是通过往nn.Embedding
层传入pad token 的index索引,作为padding_idx
参数
2.使用LSTM而不是RNN,我们用nn.LSTM
,要注意的是,LST吗返回的是output还有(final hidden state,final cell state)元组
cell state 就是LSTM特有的保留上下文记忆(长期记忆)用的
3.因为LSTM的final hidden state包含了前向和后向传播的部分,并且拼接在一起了.那么,下一层nn.Linear 层的输入的形状就是隐藏层维度形状的两倍.
4.在将embeddings(词向量)传入RNN前,我们需要将它们打包nn.utils.rnn.packed_padded_sequence
这样,我们的RNN就只会处理那些不是pad的token.然后RNN就会输出packed_output (一个被打包的句子)以及hidden sate 和 cell state. 如果没有packed padded sentences, 输出的hidden state和cell state是句子最后一个元素的,那样大概率就会使pad token,如果使用packed padded sentences,输出的就会是最后一个非padded元素的hidden state 和 cell state
5.然后我们将输出的句子解压,还是用的nn.utils.rnn.pad_packed_sequence
来讲其转换成一个tensor张量,padding tokens的输出就只会是元素是零的张量,通常我们会对输出解压只在模型之后需要它的时候.尽管,在我们这个案例中,不需要,我们任然会unpack句子来展示他的步骤
6.final hidden sate,也就是hidden,形状是 [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 states,我们只要最后2个hidden layers就行hidden[-2,:,:]
和hidden[-1,:,:]
然后将他们合并在一起,再传入线性连接层linear layer
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__()
# embedding,嵌入层,(词向量)
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
# RNN的改进型LSTM
self.rnn = nn.LSTM(embedding_dim, # input_size
hidden_dim, #output_size
num_layers=n_layers, # 几层
bidirectional=bidirectional, #是否双向
dropout=dropout) #随机去除神经元
# 线性连接层
self.fc = nn.Linear(hidden_dim * 2, output_dim) # 因为前向传播+后向传播有两个hidden sate,且合并在一起,所以乘以2
# 随机去除神经元
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
packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, text_lengths)
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中的 padding tokens是数值为0的张量
#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)
5. 实例化模型+传入参数
为了保证pre-trained 词向量可以加载到模型中,EMBEDDING_DIM 必须等于预训练的GloVe词向量的大小
INPUT_DIM = len(TEXT.vocab) # 250002: 之前设置的只取25000个最频繁的词,加上pad_token和unknown token
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] #指定参数,就这样定义pad_token的index索引值,才能让模型不管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
接下来就是将前面加载好的预训练词向量复制进我们模型中的embedding嵌入层,用预训练的embeddings词向量替换掉原来模型初始化的权重参数
pretrained_embeddings = TEXT.vocab.vectors
print(pretrained_embeddings.shape) #检查下词向量的形状 [vocab size, embedding dim]
# 用预训练的embeddings词向量替换掉原来模型初始化的权重参数
model.embedding.weight.data.copy_(pretrained_embeddings)
因为我们的<unk> 和<pad>
token不在预训练词表里,他们已经在构建我们自己的词表时,初始化了.所以最好显式的告诉模型,将他们变为0,它们与情感无关.
我们是通过手动设置他们的词向量权重为0的
#将unknown 和padding token设置为0
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.0614, -0.0516, -0.6159, …, -0.0354, 0.0379, -0.1809],
[ 0.1885, -0.1690, 0.1530, …, -0.2077, 0.5473, -0.4517],
[-0.1182, -0.4701, -0.0600, …, 0.7991, -0.0194, 0.4785]])
可以看到头两行,值都是0, pad token的词向量在模型训练过程中始终不会被学习,而unknown token的词向量是会被学习的
6.训练模型
我们将随机梯度下降SGD优化器改成Adam优化器.SGD优化器对所有的训练参数一视同仁,都采用我们设定好的学习率进行同步更新.
而Adam会为每个训练参数调整学习率,从而使更新频率搞得参数有较低的学习率,更新频率较低的参数有较高的学习率
6.1 设置优化器
import torch.optim as optim
optimizer = optim.Adam(model.parameters())
6.2 设置损失函数,和GPU
criterion = nn.BCEWithLogitsLoss() # 损失函数. criterion 在本例中时损失函数的意思
model = model.to(device)
criterion = criterion.to(device)
6.3 计算精确度
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
6.3 定义一个训练函数,用来训练模型
因为用到了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 #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)
6.4 定义一个测试函数
因为现在模型中用了dropouput,所以在评估模型时要用model.eval()来关闭dropout
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 #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)
再创建一个函数告诉我们epochs训练多长时间
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
6.5 正式训练模型
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 28s
Train Loss: 0.439 | Train Acc: 80.32%
Val. Loss: 0.368 | Val. Acc: 84.31%
Epoch: 02 | Epoch Time: 0m 27s
Train Loss: 0.335 | Train Acc: 86.26%
Val. Loss: 0.417 | Val. Acc: 81.54%
Epoch: 03 | Epoch Time: 0m 27s
Train Loss: 0.266 | Train Acc: 89.55%
Val. Loss: 0.366 | Val. Acc: 86.37%
Epoch: 04 | Epoch Time: 0m 27s
Train Loss: 0.229 | Train Acc: 91.12%
Val. Loss: 0.289 | Val. Acc: 88.57%
Epoch: 05 | Epoch Time: 0m 27s
Train Loss: 0.200 | Train Acc: 92.59%
Val. Loss: 0.261 | Val. Acc: 89.76%
6.6 最终测试结果
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.274 | Test Acc: 88.78%
7. 使用训练好的模型来预测
现在可以这个模型来给一些真实的电影评论来做分类了
当使用一个模型用来实际预测时,模型要始终在evaluation mode评估模式
7.1 创建predict_sentiment函数
这个函数做了如下些事:
- 设置模型到evaluation mode
- 将句子分词
- 将分词后的每个词,对应着词汇表,转换成对应的index索引,
- 获取句子的长度
- 将indexes,从list转化成tensor
- 通过unsqueezing 添加一个batch维度
- 将length转化成张量tensor
- 用sigmoid函数将预测值压缩到0-1之间
- 用item()方法,将只有一个值的张量tensor转化成整数
负面评论接近0,正面评论接近1
import spacy
nlp = spacy.load('en')
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.005683214403688908