7.1.3、使用飞桨实现基于LSTM的情感分析模型
3.2 网络定义
在讲解卷积神经网络的的章节,我们详细列出了每一种神经网络使用基础算子拼装的详细网络配置,但实际上对于一些常用的网络结构,飞桨框架提供了现成的中高层函数支持。下面用于情感分析的长短时记忆模型就使用paddle.nn.LSTMAPI实现。如果读者对使用基础算子拼装LSTM的内容感兴趣,可以查阅paddle.nn.LSTM类的源代码。
3.2.1 seq2vec的文本表示
在讲解情感分析网络搭建之前,我们先介绍一下seq2vec
句子情感分析的关键技术是如何将文本表示成一个携带语义的文本向量。随着深度学习技术的快速发展,目前常用的文本表示技术有LSTM,GRU,RNN等方法。 PaddleNLP提供了一系列的文本表示技术,集成在seq2vec
模块中。
paddlenlp.seq2vec模块的作用是将输入的序列文本,表示成一个语义向量。
图10:paddlenlp.seq2vec示意图
本模块主要使用了seq2vec的LSTMEncoder部分,其核心结构是使用的LSTM,LSTMEncoder的核心是实现下面的内容:
- LSTMEncoder:
- get_input_dim : encoder 的输入的维度
- get_output_dim : encoder 的输出维度
- forward : 前向传播的逻辑
LSTMEncoder核心实现代码如下:
class LSTMEncoder(nn.Layer): def __init__(self, input_size, hidden_size, num_layers=1, direction="forward", dropout=0.0, pooling_type=None, **kwargs): super().__init__() self._input_size = input_size self._hidden_size = hidden_size self._direction = direction self._pooling_type = pooling_type # LSTM层 self.lstm_layer = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, direction=direction, dropout=dropout, **kwargs) def get_input_dim(self): # 获取LSTM的输入的维度 return self._input_size def get_output_dim(self): # 获取LSTM的输出的维度 if self._direction == "bidirect": return self._hidden_size * 2 else: return self._hidden_size def forward(self, inputs, sequence_length): encoded_text, (last_hidden, last_cell) = self.lstm_layer( inputs, sequence_length=sequence_length) # 如果是双向的LSTM,则输出最后一个时刻 if self._direction != 'bidirect': # 输出最后一层的向量 output = last_hidden[-1, :, :] else: # 单向的LSTM的实现,把倒数第一层和倒数第二层的向量拼接后输出 output = paddle.concat( (last_hidden[-2, :, :], last_hidden[-1, :, :]), axis=1) return output
3.2.2 情感分析网络
图11:情感分析网络
上图显示的是情感分析网络,分为文本向量化,序列学习,特征学习,分类四部分:
1.文本向量化:这部分的作用就是把文本id的形式转换成稠密的向量的形式;
2.序列学习:序列学习是seq2vec层,具体使用的是双向LSTM用来学习文本序列的相关关系;
3.特征学习:特征学习使用了全连接层,主要是对前面的序列特征进一步学习,得到全局的特征信息;
4.分类:分类是输出层,本质上是一个全连接层,主要是把特征转化成情感类别概率输出。
情感分析网络代码实现如下:
import paddlenlp as ppnlp class LSTMModel(nn.Layer): def __init__(self, vocab_size, num_classes, emb_dim=128, padding_idx=0, lstm_hidden_size=198, direction='forward', lstm_layers=1, dropout_rate=0.0, pooling_type=None, fc_hidden_size=96): super().__init__() # 文本向量化 # 首先将输入word id 查表后映射成 word embedding self.embedder = nn.Embedding( num_embeddings=vocab_size, embedding_dim=emb_dim, padding_idx=padding_idx) # 序列学习 # 将word embedding经过LSTMEncoder变换到文本语义表征空间中 self.lstm_encoder = ppnlp.seq2vec.LSTMEncoder( emb_dim, lstm_hidden_size, num_layers=lstm_layers, direction=direction, dropout=dropout_rate, pooling_type=pooling_type) # 特征学习 # LSTMEncoder.get_output_dim()方法可以获取经过encoder之后的文本表示hidden_size self.fc = nn.Linear(self.lstm_encoder.get_output_dim(), fc_hidden_size) # 输出层 # 最后的分类器 self.output_layer = nn.Linear(fc_hidden_size, num_classes) # forwad函数即为模型前向计算的函数,它有两个输入,分别为: # input为输入的训练文本,其shape为[batch_size, max_seq_len] # seq_len训练文本对应的真实长度,其shape维[batch_size] def forward(self, text, seq_len): # 输入的文本的维度(batch_size, num_tokens, embedding_dim) embedded_text = self.embedder(text) # lstm的输出的维度: (batch_size, num_tokens, num_directions*lstm_hidden_size) # 如果lstm是双向的,则num_directions = 2,如果是单向的则num_directions的维度是1 text_repr = self.lstm_encoder(embedded_text, sequence_length=seq_len) # 全连接层的的维度是(batch_size, fc_hidden_size) fc_out = paddle.tanh(self.fc(text_repr)) # 输出层的维度(batch_size, num_classes) logits = self.output_layer(fc_out) # probs 分类概率值 probs = F.softmax(logits, axis=-1) return probs
3.3 模型训练
在完成模型定义之后,我们就可以开始训练模型了。当训练结束以后,我们可以使用测试集合评估一下当前模型的效果,代码如下:
# 定义训练参数 epoch_num = 4 batch_size = 128 learning_rate = 5e-5 dropout_rate = 0.2 num_layers = 1 hidden_size = 256 embedding_size = 256 vocab_size=len(vocab) print(vocab_size) # 实例化LSTM模型 model= LSTMModel( vocab_size, num_classes=2, emb_dim=embedding_size, lstm_layers=num_layers, direction='bidirectional', padding_idx=vocab['[PAD]']) # 指定优化策略,更新模型参数 optimizer = paddle.optimizer.Adam(learning_rate=learning_rate, beta1=0.9, beta2=0.999, parameters= model.parameters())
W0402 11:50:48.497135 8808 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0402 11:50:48.502187 8808 device_context.cc:465] device: 0, cuDNN Version: 7.6.
paddle.seed(0) random.seed(0) np.random.seed(0) # 定义训练函数 # 记录训练过程中的损失变化情况,可用于后续画图查看训练情况 losses = [] steps = [] def train(model): # 开启模型训练模式 model.train() global_step = 0 for epoch in range(epoch_num): for step, (sentences,valid_length, labels) in enumerate(train_loader): # 前向计算,将数据feed进模型,并得到预测的情感标签和损失 logits = model(sentences,valid_length) # 计算损失 loss = F.cross_entropy(input=logits, label=labels, soft_label=False) loss = paddle.mean(loss) # 后向传播 loss.backward() # 更新参数 optimizer.step() # 清除梯度 optimizer.clear_grad() global_step+=1 if global_step % 100 == 0: # 记录当前步骤的loss变化情况 losses.append(loss.numpy()[0]) steps.append(step) # 打印当前loss数值 print("epoch %d, step %d, loss %.3f" % (epoch,global_step, loss.numpy()[0])) #训练模型 train(model) # 保存模型,包含两部分:模型参数和优化器参数 model_name = "sentiment_classifier" # 保存训练好的模型参数 paddle.save(model.state_dict(), "checkpoint/{}.pdparams".format(model_name)) # 保存优化器参数,方便后续模型继续训练 paddle.save(optimizer.state_dict(), "checkpoint/{}.pdopt".format(model_name))
epoch 0, step 100, loss 0.690
epoch 1, step 200, loss 0.680
epoch 1, step 300, loss 0.560
epoch 2, step 400, loss 0.468
从上述的loss变化过程可以看到,loss是不断在降低的。
tips
使用GPU训练,设置epoch为20,训练2分钟左右,loss降低到0.313,大概1100个step之后就可以达到90.50%的准确率。训练的epoch越多,精度可能会更高,但不是绝对的会升高,会出现过拟合现象(训练集的loss降低,验证集的精度反而升高的现象)。
3.4 模型评估
在模型训练阶段,保存了训练完成的模型参数。因此在模型评估阶段,首先需要加载保存到磁盘的模型参数,在获得完整的模型之后,利用相应的测试集开始进行模型评估。
@paddle.no_grad() def evaluate(model): # 开启模型测试模式,在该模式下,网络不会进行梯度更新 model.eval() # 定义以上几个统计指标 tp, tn, fp, fn = 0, 0, 0, 0 for sentences,valid_lens, labels in test_loader: # 获取模型对当前batch的输出结果 logits = model(sentences,valid_lens) # 使用softmax进行归一化 probs = F.softmax(logits) # 把输出结果转换为numpy array数组,比较预测结果和对应label之间的关系,并更新tp,tn,fp和fn probs = probs.numpy() for i in range(len(probs)): # 当样本是的真实标签是正例 if labels[i][0] == 1: # 模型预测是正例 if probs[i][1] > probs[i][0]: tp += 1 # 模型预测是负例 else: fn += 1 # 当样本的真实标签是负例 else: # 模型预测是正例 if probs[i][1] > probs[i][0]: fp += 1 # 模型预测是负例 else: tn += 1 # 整体准确率 accuracy = (tp + tn) / (tp + tn + fp + fn) # 输出最终评估的模型效果 print("TP: {}\nFP: {}\nTN: {}\nFN: {}\n".format(tp, fp, tn, fn)) print("Accuracy: %.4f" % accuracy) # 加载训练好的模型进行预测,重新实例化一个模型,然后将训练好的模型参数加载到新模型里面 state_dict=paddle.load('checkpoint/sentiment_classifier.pdparams') model.load_dict(state_dict) # 评估模型 evaluate(model)
TP: 512
FP: 99
TN: 508
FN: 81
Accuracy: 0.8500
从输出结果可以看到,模型的准确率达到了86%。TP数量是481,FP的数量是56,TN的数量是551,FN的数量是112.(每次运行结果稍有差异)
3.5 模型预测
模型预测部分就是把文本转换成Tensor形式的ID行时候,利用模型预测得到输出,然后选取概率最大值的索引就行了。
label_map = {0: 'negative', 1: 'positive'} text=test_ds[0]['text'] # 文本转换成ID的形式 input_ids=tokenizer.encode(text) valid_lens=len(input_ids) # 转换成Tensor的形式 input_ids=paddle.to_tensor([input_ids]) valid_lens=paddle.to_tensor([valid_lens]) # 模型预测 probs=model(input_ids,valid_lens) # 取概率最大值的ID idx = np.argmax(probs, axis=-1) idx = idx.tolist() # 得到预测标签 labels = [label_map[i] for i in idx] # 看看预测样例分类结果 print('Data: {} \t Label: {}'.format(text, labels[0]))
Data: 这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般 Label: negative
四、思考一下
[1] 情感分析任务对你有什么启发?
[2] 除了LSTM,你还能想到那些其他方法,构造一个句子的向量表示?
[3] 对一个句子生成一个单一的向量表示有什么缺点,你还知道其他方式吗?