task2 Updated情感分析

该文档为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

task2 Updated情感分析

多层RNN(也称为深层RNN):在初始标准RNN上多加几层RNN。第一个(底部)RNN在时间步 t t t时输出的隐藏状态将是在时间步 t t t时其上方RNN的输入,然后根据最终(最高)层的最终隐藏状态进行预测。

3.4 正则化(dropout)

  • dropout:随即失活(weight=0)
  • 避免过度依赖某个神经元,实现减轻过拟合

3.5 Implementation Details

  1. 针对模型训练过程中的一点补充:在模型训练过程中,对于每个样本中补齐后加上的pad token,模型是不应该对其进行训练的,也就是并不会学习“<pad>”标记的嵌入。因为padding token跟句子的情感是无关的。这就意味着pad token的嵌入层(词向量)会一直保持初始化的状态(初始化为全零)。具体而言,我们是通过往nn.Embedding 层传入pad token 的index索引,作为padding_idx参数。

  2. 因为实验中使用的双向LSTM的包含了前向传播和后向传播过程,所以最后的隐藏状态向量包含了前向和后向的隐藏状态,所以在下一层nn.Linear层中的输入的形状就是隐藏层维度形状的两倍。

  3. 在将embeddings(词向量)输入RNN前,我们需要借助nn.utils.rnn.packed_padded_sequence将它们‘打包’,以此来保证RNN只会处理不是pad的token。我们得到的输出包括packed_output (a packed sequence)以及hidden satecell state。如果没有进行‘打包’操作,那么输出的hidden statecell state大概率是来自句子的pad token。如果使用packed padded sentences,输出的就会是最后一个非padded元素的hidden statecell state

pack:压紧,把一个填充过的变长序列压紧

  1. 之后我们借助nn.utils.rnn.pad_packed_sequence 将输出的句子‘解压’转换成一个tensor张量。需要注意的是来自padding tokens的输出是零张量,通常情况下,我们只有在后续的模型中使用输出时才需要‘解压’。虽然在本案例中下不需要,这里只是为展示其步骤。
  2. 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
上一篇:linux常用命令(二)


下一篇:vue生命周期执行顺序?(vue)