该文档为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
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__()
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):
embedded = self.dropout(self.embedding(text))
packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, text_lengths.to('cpu'))
packed_output, (hidden, cell) = self.rnn(packed_embedded)
output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output)
hidden = self.dropout(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1))
return self.fc(hidden)
4. 实例化模型+传入参数
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')
- 把前面加载好的预训练词向量复制进我们模型中的embedding嵌入层,用预训练的embeddings词向量替换掉原来模型初始化的权重参数
pretrained_embeddings = TEXT.vocab.vectors
print(pretrained_embeddings.shape)
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”上完成!
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()
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
"""
rounded_preds = torch.round(torch.sigmoid(preds))
correct = (rounded_preds == y).float()
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
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
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")
|