该文档为datawhale情感分析组队学习的笔记
Github地址:team-learning-nlp/Emotional_Analysis at master · datawhalechina/team-learning-nlp (github.com)
- 本文为情感分析baseline的改进优化
- 涉及到如何使用压缩填充序列、加载和使用预训练词向量、采用不同的优化器、选择不同的RNN体系结构(包括双向RNN、多层RNN)和正则化。
1. 准备数据
- 在准备数据的时候需要注意到,由于 RNN 只能处理序列中的非 padded 元素(即非0数据),对于任何 padded 元素输出都是 0 。所以注意到我们在准备数据的时候将
include_length设置为True
,以获得句子的实际长度,后续需要使用。
import torch
import random
from torchtext.legacy import data,datasets
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)
train_data, test_data = datasets.IMDB.splits(TEXT, LABEL)
train_data, valid_data = train_data.split(random_state = random.seed(SEED))
2. 词向量
- 获取词向量是通过指定参数传递给 build_vocab 得到的
- 此处选取的是GloVe词向量(global vectors for word representation)
GloVe模型的基本思想是利用词向量对“词-上下文”共现矩阵进行预测,从而实现隐式的矩阵分解。
- TEXT.build_vocab表示从预训练的词向量中,将当前训练数据中的词汇的词向量抽取出来,构成当前训练集的 Vocab(词汇表)。对于当前词向量语料库中没有出现的单词(记为UNK,unknown),通过高斯分布随机初始化(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)
3. 构建模型
3.1 LSTM
LSTM是标准RNN的一种变体,它增加了一种携带信息跨越多个时间步的方法,一定程度上克服了标准的RNN存在梯度消失的问题。 具体来说,LSTM加入记忆单元 c t c_t ct,其可看作是LSTM的“内存”,存储了时刻 t t t时LSTM的记忆,可以认为其中保存了从过去到时刻 t t t的所有必要信息,同时使用多个门控制信息流入和流出内存,具体地看此处。 因此我们可以将LSTM看作是 x t x_t xt、 h t h_t ht和 c t c_t ct的函数,而不仅仅是 x t x_t xt和 h t h_t ht。
( h t , c t ) = LSTM ( x t , h t , c t ) (h_t, c_t) = \text{LSTM}(x_t, h_t, c_t) (ht,ct)=LSTM(xt,ht,ct)
与初始隐藏状态一样,初始记忆状态 c 0 c_0 c0初始化为全零张量。需要注意的是,情感预测只使用最终隐藏状态,而不是最终记忆单元状态,即 y ^ = f ( h T ) \hat{y}=f(h_T) y^=f(hT)。
3.2 双向RNN
-
两条RNN完全独立:不共享参数,也不共享状态
-
各自输出状态向量,然后把他们拼接起来
-
如果有多层RNN,就把输入的状态向量作为上面一层的输入
-
如果只有一层,就把中间的状态向量丢掉,只保留最后的
3.3 多层RNN
多层RNN(也称为深层RNN):在初始标准RNN上多加几层RNN。第一个(底部)RNN在时间步 t t t时输出的隐藏状态将是在时间步 t t t时其上方RNN的输入,然后根据最终(最高)层的最终隐藏状态进行预测。
3.4 正则化(dropout)
- dropout:随即失活(weight=0)
- 避免过度依赖某个神经元,实现减轻过拟合
3.5 Implementation Details
-
针对模型训练过程中的一点补充:在模型训练过程中,对于每个样本中补齐后加上的pad token,模型是不应该对其进行训练的,也就是并不会学习“<pad>”标记的嵌入。因为padding token跟句子的情感是无关的。这就意味着pad token的嵌入层(词向量)会一直保持初始化的状态(初始化为全零)。具体而言,我们是通过往nn.Embedding 层传入pad token 的index索引,作为padding_idx参数。
-
因为实验中使用的双向LSTM的包含了前向传播和后向传播过程,所以最后的隐藏状态向量包含了前向和后向的隐藏状态,所以在下一层nn.Linear层中的输入的形状就是隐藏层维度形状的两倍。
-
在将embeddings(词向量)输入RNN前,我们需要借助
nn.utils.rnn.packed_padded_sequence
将它们‘打包’,以此来保证RNN只会处理不是pad的token。我们得到的输出包括packed_output
(a packed sequence)以及hidden sate
和cell state
。如果没有进行‘打包’操作,那么输出的hidden state
和cell state
大概率是来自句子的pad token。如果使用packed padded sentences,输出的就会是最后一个非padded元素的hidden state
和cell state
。
pack:压紧,把一个填充过的变长序列压紧
- 之后我们借助
nn.utils.rnn.pad_packed_sequence
将输出的句子‘解压’转换成一个tensor张量。需要注意的是来自padding tokens的输出是零张量,通常情况下,我们只有在后续的模型中使用输出时才需要‘解压’。虽然在本案例中下不需要,这里只是为展示其步骤。 - final hidden sate:也就是hidden,其形状是[num layers * num directions, batch size, hid dim]。因为我们只要最后的前向和后向传播的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
# 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中的 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)
4. 实例化模型+传入参数
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
# 检查词向量形状 [vocab size, embedding acdim]
print(pretrained_embeddings.shape)
# torch.Size([25002, 100])
# 用预训练的embedding词向量替换原始模型初始化的权重参数
model.embedding.weight.data.copy_(pretrained_embeddings)
- 因为我们的
<unk>
和<pad>
token不在预训练词表里,它们已经在构建我们自己的词表时,使用unk_init
(an N ( 0 , 1 ) \mathcal{N}(0,1) N(0,1) distribution)初始化了。所以,最好显式地告诉模型,将它们初始化变为0,它们与情感无关。
我们是通过手动设置他们的词向量权重为0的。
注意:与初始化嵌入一样,这应该在“weight.data”而不是“weight”上完成!
#将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)
5. 训练模型
5.1 优化器
- Adam:Adam会调整每个参数的学习率,给出更新频率更高的参数,以及更新频率更低的参数和更新频率不高的参数
import torch.optim as optim
optimizer = optim.Adam(model.parameters())
5.2 设置损失函数和GPU
criterion = nn.BCEWithLogitsLoss() # 损失函数. criterion 在本task中时损失函数的意思
model = model.to(device)
criterion = criterion.to(device)
5.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
5.4 train函数
- batch.text为一个元组
- 第一个元素是数字张量
- 第二个元素是每个序列的实际长度
- 在将它们传递给模型之前,我们将它们分成各自的变量“text”和“text_length”。
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)
5.5 evaluate函数
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)
5.6 epoch_time
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. 正式训练
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 45s
Train Loss: 0.670 | Train Acc: 58.93%
Val. Loss: 0.658 | Val. Acc: 62.54%
Epoch: 02 | Epoch Time: 0m 41s
Train Loss: 0.616 | Train Acc: 65.61%
Val. Loss: 0.544 | Val. Acc: 72.67%
Epoch: 03 | Epoch Time: 0m 42s
Train Loss: 0.528 | Train Acc: 74.19%
Val. Loss: 0.457 | Val. Acc: 79.56%
Epoch: 04 | Epoch Time: 0m 41s
Train Loss: 0.396 | Train Acc: 82.66%
Val. Loss: 0.383 | Val. Acc: 83.49%
Epoch: 05 | Epoch Time: 0m 42s
Train Loss: 0.316 | Train Acc: 86.80%
Val. Loss: 0.294 | Val. Acc: 87.64%
7. 测试结果
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.304 | Test Acc: 87.12%
8. 模型验证
我们现在可以使用这个模型来预测我们给出的任何句子的情感了,注意需要提供的句子是电影评论方面的。
当使用模型进行实际预测时,模型要始终在evaluation mode评估模式。
“predict_sentiment”函数的作用如下:
- 将模型切换为evaluate模式
- 对句子进行分词操作
- 将分词后的每个词,对应着词汇表,转换成对应的index索引,
- 获取句子的长度
- 将indexes,从list转化成tensor
- 通过unsqueezing 添加一个batch维度
- 将length转化成张量tensor
- 用sigmoid函数将预测值压缩到0-1之间
- 用item()方法,将只有一个值的张量tensor转化成整数
负面评论返回接近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.012154742144048214