情感分析
学习任务
文本情感分析(Sentiment Analysis)是自然语言处理(NLP)方法中常见的应用,也是一个有趣的基本任务,尤其是以提炼文本情绪内容为目的的分类,它是对带有情感色彩的主观性文本进行分析、处理、归纳和推理的过程。本章介绍情感分析中的情感极性(倾向)分析。所谓情感极性分析,指的是对文本进行褒义、贬义、中性的判断。在大多应用场景下,只分为两类。例如对于“喜爱”和“厌恶”这两个词,就属于不同的情感倾向。
知识点
自然语言处理、RNN/LSTM模型
1.问题描述
本项目实现微博评论数据的探查,重点掌握pandas数据分析工具的使用。对数据可视化,重点掌握seaborn,pyecharts 可视化工具的实用。掌握卷积神经网络RNN/LSTM模型原理,掌握PyTorch 中关于自然语言处理torchtext库使用,特别是torchtext 中重点工具BucketIterator使用,torch.nn模型使用。通过本项目的实践,希望学员们可以掌握情感分析RNN/LSTM模型构建和训练的方法,并使用评论情感分析模型预测文本所反映的情感。
2.数据描述
NLP&CC 2013: 该数据集是xml格式,有大概3w条数据,17w条数据作为测试样本,分为none、like、disgust、anger、happiness、fear、sadness、surprise等几个类别,其中none又占绝大多数。
2.1 加载数据
在使用python处理数据的过程中,经常需要做一些数据读取和写入的工作,比较常用的数据格式是csv,csv文件是一种以逗号分割字符的文件形式。读写csv文件常用的有两种方式,一种是使用csv,一种是使用pandas。
import pandas as pd
import jieba
train = pd.read_csv('data/train.csv')
val = pd.read_csv('data/val.csv')
train.head()
数据预处理和数据封装 TorchText 的数据预处理流程为:
定义样本的处理操作。—> torchtext.data.Field
- 加载 corpus (都是 string)—> torchtext.data.Datasets
- 在Datasets 中,torchtext 将 corpus 处理成一个个的 torchtext.data.Example 实例
- 创建 torchtext.data.Example 的时候,会调用 field.preprocess 方法
- 创建词汇表, 用来将 string token 转成 index —> field.build_vocab()
- 词汇表负责:string token —> index, index —> string token ,string token —> word vector
- 将处理后的数据 进行 batch 操作。—> torchtext.data.Iterator
- 将 Datasets 中的数据 batch 化
- 其中会包含一些 pad 操作,保证一个 batch 中的 example 长度一致 在这里将 string token 转化成index。
tokenization,vocab, numericalize, embedding lookup 和 TorchText 数据预处理阶段的对应关系是:
- tokenization —> Dataset 的构造函数中,由 Field 的 tokenize 操作
- vocab —> field.build_vocab 时,由 Field 保存 映射关系
- numericalize —> 发生在 iterator 准备 batch 的时候,由 Field 执行 numericalize 操作
- embedding lookup —> 由 pytorch Embedding Layer 提供此功能。
# 导入torch库
import torch
# 导入torchtext库,主要自然语言处理预处理
from torchtext.data import Field, TabularDataset
# 加载外部词向量
from torchtext.vocab import Vectors
# 引入自定义配置
from configs import BasicConfigs
# 引入工具类
from utils import chi_tokenizer
config = BasicConfigs()
# 定义字段 (TEXT/LABEL)
## include_lengths=True 为了方便后续使用torch pack_padded_sequence
## chi_tokenizer 分词器,主要对我们的每个句子进行切分
TEXT = Field(tokenize=chi_tokenizer, include_lengths=True)
LABEL = Field(eos_token=None, pad_token=None, unk_token=None)
## torchtext中于文件配对关系
fields = [('data', TEXT), ('label', LABEL)]
# 加载数据
## 注意skip_header = True
train_data, val_data = TabularDataset.splits(path='data',
train='train.csv',
validation='val.csv',
format='csv',
fields=fields,
skip_header=True)
## 数据记录数统计
print('train total_count = ', len(train_data.examples))
print('val total_count = ', len(val_data.examples))
from torchtext.data import BucketIterator
train_iter = BucketIterator(train_data,
batch_size=config.batch_size,
sort_key=lambda x:len(x.data),
sort_within_batch=True,
shuffle=True,
device=config.device)
val_iter = BucketIterator(val_data,
batch_size=config.batch_size,
sort_key=lambda x:len(x.data),
sort_within_batch=True,
shuffle=False,
device=config.device)
最后一步数据的准备是创建iterators。每个itartion都会返回一个batch的examples。 我们会使用BucketIterator。BucketIterator会把长度差不多的句子放到同一个batch中,确保每个batch中不出现太多的padding。 严格来说,我们这份notebook中的模型代码都有一个问题,也就是我们把也当做了模型的输入进行训练。更好的做法是在模型中把由产生的输出给消除掉。 如果我们有GPU,还可以指定每个iteration返回的tensor都在GPU上。
使用以下Python脚本,可以统计出数据集中的情感分布以及评论句子长度分布。
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import font_manager
from itertools import accumulate
# 设置matplotlib绘图时的字体
my_font = font_manager.FontProperties(fname="C:\Windows\Fonts\Songti.ttc")
# 统计句子长度及长度出现的频数
df = pd.read_csv('./corpus.csv')
print(df.groupby('label')['label'].count())
df['length'] = df['evaluation'].apply(lambda x: len(x))
len_df = df.groupby('length').count()
sent_length = len_df.index.tolist()
sent_freq = len_df['evaluation'].tolist()
# 绘制句子长度及出现频数统计图
plt.bar(sent_length, sent_freq)
plt.title("句子长度及出现频数统计图", fontproperties=my_font)
plt.xlabel("句子长度", fontproperties=my_font)
plt.ylabel("句子长度出现的频数", fontproperties=my_font)
plt.savefig("./句子长度及出现频数统计图.png")
plt.close()
# 绘制句子长度累积分布函数(CDF)
sent_pentage_list = [(count/sum(sent_freq)) for count in accumulate(sent_freq)]
# 绘制CDF
plt.plot(sent_length, sent_pentage_list)
# 寻找分位点为quantile的句子长度
quantile = 0.91
#print(list(sent_pentage_list))
for length, per in zip(sent_length, sent_pentage_list):
if round(per, 2) == quantile:
index = length
break
print("\n分位点为%s的句子长度:%d." % (quantile, index))
# 绘制句子长度累积分布函数图
plt.plot(sent_length, sent_pentage_list)
plt.hlines(quantile, 0, index, colors="c", linestyles="dashed")
plt.vlines(index, 0, quantile, colors="c", linestyles="dashed")
plt.text(0, quantile, str(quantile))
plt.text(index, 0, str(index))
plt.title("句子长度累积分布函数图", fontproperties=my_font)
plt.xlabel("句子长度", fontproperties=my_font)
plt.ylabel("句子长度累积频率", fontproperties=my_font)
plt.savefig("./句子长度累积分布函数图.png")
plt.close()
句子长度累积分布函数图如下:
可以看到,大多数样本的句子长度集中在1-200之间,句子长度累计频率取0.91分位点,则长度为183左右。
2.2 分词模块jieba
“结巴”分词是一个Python 中文分词组件,参见https://github.com/fxsjy/jieba, 可以对中文文本进行分词、词性标注、关键词抽取等功能,并且支持自定义词典。 jieba的主要功能有:
- 分词
- 添加自定义词典
- 关键字提取
- 词性标注
我们主要使用它的分词功能,HMM模型原理和viterbi算法原理可参阅结巴网页。
jieba支持三种分词模式:
- 精确模式,适合文本分析。
- 全模式,把所有可以成词的词语都扫描出来,不能解决歧义。
- 搜索引擎模式,在精确模式的基础上对长词再次切分,适合搜索引擎分词。 分词功能调用方法 jieba.cut 接受三个输入参数: 需要分词的字符串;cut_all 参数用来控制是否采用全模式;HMM 参数用来控制是否使用 HMM 模型
jieba.cut_for_search 接受两个参数:需要分词的字符串;是否使用 HMM 模型。该方法适合用于搜索引擎构建倒排索引的分词,粒度比较细
jieba.cut 以及 jieba.cut_for_search 返回的结构都是一个可迭代的 generator,可以使用 for 循环来获得分词后得到的每一个词语(unicode),或者用
jieba.lcut 以及 jieba.lcut_for_search 直接返回 list
jieba.Tokenizer(dictionary=DEFAULT_DICT) 新建自定义分词器,可用于同时使用不同词典。jieba.dt 为默认分词器,所有全局分词相关函数都是该分词器的映射。
代码示例:
# encoding=utf-8
import jieba
seg_list = jieba.cut("我来到北京清华大学", cut_all=True)
print("Full Mode: " + "/ ".join(seg_list)) # 全模式
seg_list = jieba.cut("我来到北京清华大学", cut_all=False)
print("Default Mode: " + "/ ".join(seg_list)) # 精确模式
seg_list = jieba.cut("他来到了网易杭研大厦") # 默认是精确模式
print(", ".join(seg_list))
seg_list = jieba.cut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造") # 搜索引擎模式
print(", ".join(seg_list))
输出:
【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学
【精确模式】: 我/ 来到/ 北京/ 清华大学
【新词识别】:他, 来到, 了, 网易, 杭研, 大厦 (此处,“杭研”并没有在词典中,但是也被Viterbi算法识别出来了)
【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造
3.主要网络模型介绍
3.1标准RNN 模型
下面我们尝试把模型换成一个recurrent neural network (RNN)。RNN经常会被用来encode一个sequence
embedding layer:对每个word 进行one-hot 编码处理,spare vector-> dense vector . [sentence length, batch size, embedding dim] RNN:获取dense vector and hidden state(prev layers) a linear layer: 最后的hidden state ,通过一个FC,获取最终维度的数据
3.2 LSTM(Long Short-Term Memory)
LSTM出现背景:由于RNN存在梯度消失的问题,很难处理长序列的数据。为了解决RNN存在问题,后续人们对RNN做了改进,得到了RNN的特例LSTM,它可以避免常规RNN的梯度消失,因此在工业界得到了广泛的应用。 LSTM模型是RNN的变体,它能够学习长期依赖,允许信息长期存在。
举个例子来讲:比如人们读文章的时候,人们会根据已经阅读过的内容来对后面的内容进行理解,不会把之前的东西都丢掉从头进行思考,对内容的理解是贯穿的。 传统的神经网络即RNN做不到这一点,LSTM是具有循环的网络,解决了信息无法长期存在的问题,在工业界普遍使用有良好的效果。
LSTM的核心在于单元(细胞)中的状态,也就是上图中最上面的那根线。但是如果只有上面那一条线,那么没有办法实现信息的增加或者删除,所以在LSTM是通过一个叫做门的结构实现,门可以选择让信息通过或者不通过。
在理解LSTM时,我们需要理解遗忘门,输入门以及输出门的相关概念以及公式的说明和推导。
LSTM 和 RNN 区别 LSTM(Long Short-Item Memory) : 相比普通的RNN 效果为好。普通RNN 会出现梯度消失问题,而LSTM 主要为了解决梯度消息和梯度爆炸问题。LSTM 能够在更长的序列中有更好的表现。
RNN 只有一个传递状态 h t h_th t
LSTM有两个传递状态 c t c_tc t(cell state) 和 h^t (hidden state),其中: c t c_tc t改变很慢,h t h_th t在不通节点上改变很大
LSTM内部主要有三个阶段:
忘记阶段。这个阶段主要是对上一个节点传进来的输入进行选择性忘记。简单来说就是会 “忘记不重要的,记住重要的”。
选择记忆阶段。这个阶段将这个阶段的输入有选择性地进行“记忆”。
输出阶段。通过门控状态来控制传输状态,记住需要长时间记忆的,忘记不重要的信息;而不像普通的RNN那样只能够“呆萌”地仅有一种记忆叠加方式。对很多需要“长期记忆”的任务来说,尤其好用。
LSTM相比RNN也存在问题:因为引入了很多内容,导致参数变多,也使得训练难度加大了很多。因此很多时候我们往往会使用效果和LSTM相当但参数更少的GRU来构建大训练量的模型.
3.3 正则化
为了提升model效果,我们往往会增加大量的参数。结果,带来的问题是:过拟合(在训练数据集上的错误率很 低,但在验证/测试集上错误率高)。
解决方法是采用正则化(regularization),例如:直接丢弃dropout-在前向传递的时候,使得某些神经元权重0,每个前向传递生成一个弱模型。对这些弱模型进行集成,理论上可以解决过拟合问题。
当我们将样本输入到模型中时,将调用forward方法。
每批文本都是一个大小为[sentence length,batch size]的张量。这是一批句子,每个句子都将每个单词转换成一个one-hot向量。
由于one-hot向量,这个张量应该有另一个维度,然而PyTorch很方便地将一个one-hot向量存储为其索引值,即表示一个句子的张量只是该句子中每个标记的索引的张量。将tokens列表转换为索引列表的行为通常称为数字化。
然后,输入批处理(input batch)通过嵌入层得到嵌入值,从而为我们的句子提供密集的向量表示。embedded是一个大小为[sentence length,batch size,hidden dim]的张量。
embedded然后馈送到RNN。在一些框架中,必须将初始隐藏状态 h 0 h_0 h0输入RNN,但是在PyTorch中,如果没有将初始隐藏状态作为参数传递,则默认为初始是一个全为0的张量。
RNN返回2个张量,输出(output)的大小为[sentence length,batch size,hidden dim]和隐藏(hidden)的大小为[1,batch size,hidden dim]。输出(output)是每个时间步骤的隐藏状态的连接,而隐藏(hidden)只是最终的隐藏状态。
最后,通过线性层fc输入最后一个隐藏状态,hidden,来产生一个预测。
4.实现过程
4.1 pytorch中的LSTM
先来看一下pytorch提供的LSTM的实现 torch.nn.LSTM(args, kwargs)*
官方API: https://pytorch.org/docs/stable/nn.html?highlight=lstm#torch.nn.LSTM
参数
输入
- input (seq_len, batch, input_size)
- h_0 (num_layers * num_directions, batch, hidden_size)
- c_0 (num_layers * num_directions, batch, hidden_size)
输出
- output (seq_len, batch, num_directions * hidden_size)
- h_n (num_layers * num_directions, batch, hidden_size)
- c_n (num_layers * num_directions, batch, hidden_size)
4.2网络模型定义
在这里,我们将先使用Pytorch的原生API,搭建一个BiLSTM。处理序列的基本步骤如下:
- 准备torch.Tensor格式的data=x,label=y,length=L,等等
- 数据根据length排序,由函数sort_batch完成
- pack_padded_sequence操作
- 输入到lstm中进行训练
# 导入定义bilstm库
import torch
from torch import nn
class BiLSTM(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, pad_idx,
unk_idx, pre_trained_embedding=None):
super(BiLSTM, self).__init__()
# lookup table that stores embeddings of fixed dictionary and size
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_idx)
# 加载 预处理embeddding
if pre_trained_embedding is not None:
self.embedding.weight.data.copy_(pre_trained_embedding)
# 对于pre_trained vocab 没有对应的vector
self.embedding.weight.data[unk_idx] = torch.zeros(embedding_dim)
self.embedding.weight.data[pad_idx] = torch.zeros(embedding_dim)
# 定义encoder
self.encoder = nn.LSTM(input_size=embedding_dim,
hidden_size=hidden_size,
num_layers=num_layers,
bidirectional=True)
# 定义decoder
self.decoder = nn.Linear(2 * hidden_size, 2)
# 定义dropout
## 减少过拟合,增加我们模型的泛化能力
## Dropout 在深度网络训练中,以一定随机概率临时丢失一部分神经元的节点
self.dropout = nn.Dropout(0.5)
def forward(self, inputs, text_lengths):
"""
:param inputs: 每个batch text 内容
:param text_lengths: words 的长度,词汇长度
:return:
"""
# 提取词特征(词数,批量大小)
......
# pad sequence <pad> 设置
......
# encoder
......
# decoder
......
return outs
4.3准确率指标
分别有两个指标一个是topk的AverageMeter,另一个是使用混淆矩阵。混淆矩阵的实现的时候先转为(pred, label)的二元对,然后相应的填充到表中。
class AvgrageMeter(object):
def __init__(self):
self.reset()
def reset(self):
self.avg = 0
self.sum = 0
self.cnt = 0
def update(self, val, n=1):
self.sum += val * n
self.cnt += n
self.avg = self.sum / self.cnt
#混淆矩阵指标
class ConfuseMeter(object):
def __init__(self):
self.reset()
def reset(self):
# 标签的分类:0 pos 1 neg
self.confuse_mat = torch.zeros(2,2)
self.tp = self.confuse_mat[0,0]
self.fp = self.confuse_mat[0,1]
self.tn = self.confuse_mat[1,1]
self.fn = self.confuse_mat[1,0]
self.acc = 0
self.pre = 0
self.rec = 0
self.F1 = 0
def update(self, output, label):
pred = output.argmax(dim = 1)
for l, p in zip(label.view(-1),pred.view(-1)):
self.confuse_mat[p.long(), l.long()] += 1 # 对应的格子加1
self.tp = self.confuse_mat[0,0]
self.fp = self.confuse_mat[0,1]
self.tn = self.confuse_mat[1,1]
self.fn = self.confuse_mat[1,0]
self.acc = (self.tp+self.tn) / self.confuse_mat.sum()
self.pre = self.tp / (self.tp + self.fp)
self.rec = self.tp / (self.tp + self.fn)
self.F1 = 2 * self.pre*self.rec / (self.pre + self.rec)
## topk的准确率计算
def accuracy(output, label, topk=(1,)):
maxk = max(topk)
batch_size = label.size(0)
# 获取前K的索引
_, pred = output.topk(maxk, 1, True, True) #使用topk来获得前k个的索引
pred = pred.t() # 进行转置
# eq按照对应元素进行比较 view(1,-1) 自动转换到行为1,的形状, expand_as(pred) 扩展到pred的shape
# expand_as 执行按行复制来扩展,要保证列相等
correct = pred.eq(label.view(1, -1).expand_as(pred)) # 与正确标签序列形成的矩阵相比,生成True/False矩阵
# print(correct)
rtn = []
for k in topk:
correct_k = correct[:k].view(-1).float().sum(0) # 前k行的数据 然后平整到1维度,来计算true的总个数
rtn.append(correct_k.mul_(100.0 / batch_size)) # mul_() ternsor 的乘法 正确的数目/总的数目 乘以100 变成百分比
return rtn
4.4训练函数
#一个epoch的训练逻辑
def train(epoch,epochs, train_loader, device, model, criterion, optimizer,scheduler,tensorboard_path):
model.train()
top1 = AvgrageMeter()
model = model.to(device)
train_loss = 0.0
for i, data in enumerate(train_loader, 0): # 0是下标起始位置默认为0
inputs, labels, batch_seq_len = data[0].to(device), data[1].to(device), data[2]
# 初始为0,清除上个batch的梯度信息
optimizer.zero_grad()
outputs,hidden = model(inputs,batch_seq_len)
loss = criterion(outputs,labels)
loss.backward()
optimizer.step()
_,pred = outputs.topk(1)
prec1, prec2= accuracy(outputs, labels, topk=(1,2))
n = inputs.size(0)
top1.update(prec1.item(), n)
train_loss += loss.item()
postfix = {'train_loss': '%.6f' % (train_loss / (i + 1)), 'train_acc': '%.6f' % top1.avg}
train_loader.set_postfix(log=postfix)
# ternsorboard 曲线绘制
if os.path.exists(tensorboard_path) == False:
os.mkdir(tensorboard_path)
writer = SummaryWriter(tensorboard_path)
writer.add_scalar('Train/Loss', loss.item(), epoch)
writer.add_scalar('Train/Accuracy', top1.avg, epoch)
writer.flush()
scheduler.step()
# print('Finished Training')
4.5验证函数
def validate(epoch,validate_loader, device, model, criterion, tensorboard_path):
val_acc = 0.0
model = model.to(device)
model.eval()
with torch.no_grad(): # 进行评测的时候网络不更新梯度
val_top1 = AvgrageMeter()
validate_loader = tqdm(validate_loader)
validate_loss = 0.0
for i, data in enumerate(validate_loader, 0): # 0是下标起始位置默认为0
inputs, labels, batch_seq_len = data[0].to(device), data[1].to(device), data[2]
# inputs,labels = data[0],data[1]
outputs,_ = model(inputs, batch_seq_len)
loss = criterion(outputs, labels)
prec1, prec2 = accuracy(outputs, labels, topk=(1, 2))
n = inputs.size(0)
val_top1.update(prec1.item(), n)
validate_loss += loss.item()
postfix = {'validate_loss': '%.6f' % (validate_loss / (i + 1)), 'validate_acc': '%.6f' % val_top1.avg}
validate_loader.set_postfix(log=postfix)
# tensorboard 曲线绘制
if os.path.exists(tensorboard_path) == False:
os.mkdir(tensorboard_path)
writer = SummaryWriter(tensorboard_path)
writer.add_scalar('Validate/Loss', loss.item(), epoch)
writer.add_scalar('Validate/Accuracy', val_top1.avg, epoch)
writer.flush()
val_acc = val_top1.avg
return val_acc
4.6测试函数
def test(validate_loader, device, model, criterion):
val_acc = 0.0
model = model.to(device)
model.eval()
confuse_meter = ConfuseMeter()
with torch.no_grad(): # 进行评测的时候网络不更新梯度
val_top1 = AvgrageMeter()
validate_loader = tqdm(validate_loader)
validate_loss = 0.0
for i, data in enumerate(validate_loader, 0): # 0是下标起始位置默认为0
inputs, labels, batch_seq_len = data[0].to(device), data[1].to(device), data[2]
# inputs,labels = data[0],data[1]
outputs,_ = model(inputs, batch_seq_len)
# loss = criterion(outputs, labels)
prec1, prec2 = accuracy(outputs, labels, topk=(1, 2))
n = inputs.size(0)
val_top1.update(prec1.item(), n)
confuse_meter.update(outputs, labels)
# validate_loss += loss.item()
postfix = { 'test_acc': '%.6f' % val_top1.avg,
'confuse_acc': '%.6f' % confuse_meter.acc}
validate_loader.set_postfix(log=postfix)
val_acc = val_top1.avg
return confuse_meter
最后我们在测试集上评估模型并打印出评分和准确率.
# evaluate
score, acc = model.evaluate(Xtest, ytest, batch_size=BATCH_SIZE)
print("Test score: %.3f, accuracy: %.3f" % (score, acc))
for i in range(5):
idx = np.random.randint(len(Xtest))
xtest = Xtest[idx].reshape(1,40)
ylabel = ytest[idx]
ypred = model.predict(xtest)[0][0]
sent = " ".join([index2word[x] for x in xtest[0].tolist() if x != 0])
print("%.0f\t%d\t%s" % (ypred, ylabel, sent))
通过改变不同参数,可以得到类似下图的结果:
5.小结
本项目使用深度神经网络实现对互联网上微博短文本的情感分析。首先将训练的词向量作为原始特征向量,在RNN循环神经网络和LSTM长短期记忆神经网络的基础上,对微博文本情感进行分析,通过在数据集上的相关实验,结果表明LSTM神经网络特殊的门控制单元能够记忆更多有用的历史特征信息,对文本的分类产生重要影响。下一步,可以将训练好的模型应用于微博的舆情监测、用户投诉反馈分析等场景上,通过加载训练好的模型,可以完成对收集到的评论文本进行情感分析及舆论导向的统计。





No Comments