情诗网 >情感文章 > 正文

【译】word2vec&doc2vec做文本情感分析

来源:情诗网    2021-01-07    分类:情感文章

自然语言处理中的舆情分析、情感分析有很多种方法,但是基于模型的方法对语料的质量要求高,如果不能弄到高质量的语料,很多时候并不准确。如果需要预测的样本量很小,通常到最后还是使用最原始的方法--基于词库匹配。虽然词库匹配效果一定不好,效率还低,但是它非常容易实现,随随便便就能弄一个出来试试效果,如果在样本很小领域很单一的情况,基于这种方法就能凑合着用用了。
如果训练样本量足够大,且样本属于单一领域,用机器学习的方法也会有不错的效果。最简单的方法是使用tf-idf把已标注的文本(通常是正负类或者中立)根据关键词向量化,然后用向量化的文本和他们对应的标签训练分类器,最后用保存好的分类器来预测新的文本。

最近看到一篇用word2vec/doc2vec做情感分析的文章,试了一下效果还行,于是把文档翻译出来,文档是15年的,有些部分已经过时,笔者进行了测试和改写,也会在文档中添加一些自己的心得。文中使用word2vec/doc2vec来代替sklearn的tf-idf把文本向量化,再用常用机器学习分类算法分类,大体思路和普通的机器学习情感分析一样,但是word2vec和doc2vec批量读取语料十分麻烦,输入数据需要给成嵌套列表的形式,例如:[[words],[],[],……],每个句子是一个word列表,这些句子列表组合成一个完整的语料库。word2vec中使用from gensim.models.word2vec import LineSentence把txt文件直接转化成正确的形式,例如:model = Word2Vec(LineSentence('体育.txt'), size=300,window=5,min_count=1, workers=2)
而该文主要贡献是为doc2vec情感分析提供了一种批量读取训练语料的预处理方法。

使用Doc2Vec做情感分析

Word2Vec是个蠢货。简而言之,它吃进去语料,为每一个词生成向量。你会问,这些向量有什么特别之处?嗯,相似的词的向量彼此相近。此外,这些向量表示我们如何使用这些单词。例如,v_man - v_woman近似等于v_king - v_queen,说明“男人对女人如国王对女王”的关系。这个过程,在NLP领域中,被称为词嵌入。这种表征方法已得到广泛应用。看了Doc2Vec的介绍会觉得更棒,它不仅代表单词,还代表完整的句子和文档。想象一下,用一个固定长度的向量来表示一个完整的句子,然后运行所有的标准分类算法。这简直太神奇了不是吗?

然而,Word2Vec文档是垃圾。c代码几乎是不可读的(700行高度优化的代码,有时是古怪的优化代码)。我个人花费了大量的时间去整理Doc2Vec,并且由于执行错误而导致了50%的错误。本教程旨在帮助其他用户使用Word2Vec进行自己的研究。我们使用Word2Vec进行情绪分析,试着将康奈尔IMDB电影评论集合分类。

模块

我们使用gensim,因为gensim的Word2Vec(和Doc2Vec)更易于阅读。祝福那些家伙。我们也使用numpy来进行数组操作,使用sklearn来训练逻辑回归分类器。

# gensim modules
from gensim import utils
from gensim.models.doc2vec import LabeledSentence
#LabeledSentence新版本由TaggedDocument替代
#from gensim.models.doc2vec import TaggedDocument

from gensim.models import Doc2Vec

# numpy
import numpy

# classifier
from sklearn.linear_model import LogisticRegression

# random
import random

输入格式

我们不能输入来自康奈尔电影评论数据库的原始评论。相反,我们通过将所有内容转换为小写并删除标点来清洗它们。我是通过bash完成的,你可以通过Python、JS或您最喜欢的语言轻松完成这一任务。这一步很简单。
其结果是有五个文件:

每一篇评论的格式都应该是这样的:


上面的示例包含两个电影评论,每个都占据了一整行。是的,每个文件应该在一行上,被新行隔开。这是非常重要的,因为我们的解析器依赖于这个来识别句子。

为Doc2Vec提供数据

Doc2Vec(Doc2Vec算法gensim部分的实现。)在词嵌入中做的很好,但是在读取文件中做的很差。它只包含了“LabeledLineSentence”类,一个用来生成“LabeledSentence”的类,一个基于gensim.models.doc2vec并用来表示简单句子的类。
为什么使用“Labeled”这个词?这就是Doc2Vec与Word2Vec的不同之处。

Word2Vec只是将一个单词转换成一个向量。
Doc2Vec不止这些,他还将一个句子中的所有单词聚合成一个向量。为了做到这一点,它只是把一个句子的标签当作一个特殊的词,并且在这个特殊的词上做了一些巫术。因此,这个特殊的单词是一个句子的标签。

所以我们必须把句子格式化成如下格式
[['word1', 'word2', 'word3', 'lastword'], ['label1']]

LabeledSentence只是一种完成此项工作更简洁的方式。它包含一个单词列表和一个句子的标签。我们并不需要关心标签的工作原理,我们只需要知道它存储了两种东西——一个单词列表和一个标签。

但是,我们需要一种方法来将我们的新行分隔的语料库转换成一个LabeledSentences(已经有标签的句子)的集合。
Doc2Vec中默认的LabeledLineSentence类的默认构造函数可以对单个文本文件执行此操作,但对于多个文件不能这样做。但是在分类任务中,我们通常需要处理多个文档(test, training, positive, negative等)。只能单个处理,不讨厌吗?
所以我们写我们自己的LabeledLineSentence类。构造函数接受一个字典,该字典定义要读的文件,而标签前缀则是来自该文档的句子。然后,Doc2Vec可以直接通过迭代器读取集合,或者我们可以直接访问数组。
我们还需要一个函数来返回一个经过修改的LabeledSentences数组。
我们稍后会看到原因。

class LabeledLineSentence(object):
    def __init__(self, sources):
        self.sources = sources
        
        flipped = {}
        
        # make sure that keys are unique
        for key, value in sources.items():
            if value not in flipped:
                flipped[value] = [key]
            else:
                raise Exception('Non-unique prefix encountered')
    
    def __iter__(self):
        for source, prefix in self.sources.items():
            with utils.smart_open(source) as fin:
                for item_no, line in enumerate(fin):
                    yield LabeledSentence(utils.to_unicode(line).split(), [prefix + '_%s' % item_no])
    
    def to_array(self):
        self.sentences = []
        for source, prefix in self.sources.items():
            with utils.smart_open(source) as fin:
                for item_no, line in enumerate(fin):
                    self.sentences.append(LabeledSentence(utils.to_unicode(line).split(), [prefix + '_%s' % item_no]))
        return self.sentences
    
    def sentences_perm(self):
        shuffled = list(self.sentences)
        random.shuffle(shuffled)
        return shuffled

现在我们可以将数据文件提供给LabeledLineSentence。
正如我们前面提到的,LabeledLineSentence简单地使用一个带键的字典作为文件名,并且句子值的特殊前缀是来自文档。前缀必须是唯一的,因此不同文档的句子没有含糊不清的地方。
前缀将会有一个计数器,用于在documetns中标记单个句子。

sources = {'test-neg.txt':'TEST_NEG', 'test-pos.txt':'TEST_POS', 'train-neg.txt':'TRAIN_NEG', 'train-pos.txt':'TRAIN_POS', 'train-unsup.txt':'TRAIN_UNS'}

sentences = LabeledLineSentence(sources)

Model

建立词汇表

Doc2Vec要求我们构建词汇表(只需简单地消化所有单词并过滤出唯一的单词,然后用他们做一些基本的计算)。所以我们给它提供了句子数组。model.build_vocab接受了LabeledLineSentence数组, 因此,在LabeledLineSentences类中我们需要使用to_array函数。
如果您对参数感兴趣,请阅读Word2Vec文档。或者,这里提供一个简单的纲要:

model = Doc2Vec(min_count=1, window=10, size=100, sample=1e-4, negative=5, workers=7)
##新版本size改成了vector_size
#model = Doc2Vec(min_count=1, window=10, vector_size=100, sample=1e-4, negative=5, workers=7)
model.build_vocab(sentences.to_array())

训练 Doc2Vec

现在我们训练模型。如果在每一个训练阶段,模型的训练都比较好,那么喂给模型的句子顺序是随机的。这一点很重要:错过这个步骤会给你带来非常糟糕的结果。这就是我们为什么我们“LabeledLineSentences”类中有“sentences_perm”。
我们训练它10轮。如果我有更多的时间,我就会做20个。
这个过程大约需要10分钟,所以去喝杯咖啡。

for epoch in range(10):
    model.train(sentences.sentences_perm(),epochs=model.iter)

注意:建立词汇表和训练Doc2Vec的部分使用了旧版本的写法
可能会有以下错误
ValueError: You must specify either total_examples or total_words, for proper alpha and progress calculations. The usual value is total_examples=model.corpus_count.
而且epochs=model.iter写法也已经过时,需要修改为model.epochs
新版本写法如下:

model = Doc2Vec(vector_size=100, window=10, min_count=1, workers=7,sample=1e-4,epochs=10)
model.build_vocab(sentences.to_array())
model.train(sentences.to_array(), epochs=model.epochs, total_examples=model.corpus_count)

检查模型

让我们看看我们的模型给出了什么。它似乎有点理解good这个词,因为最相似的词是glamorous, spectacular, astounding 等等。
这真的很棒(而且很重要),因为我们正在做情感分析。

我们看看那和‘good’最相似的词,以及相似度。
model.most_similar('good')


注意:此方法已经过时,新版本:
model.wv.most_similar('good')
充分训练后的结果:


注意: 实际上word2vec学习的向量和真正语义还有差距,更多学到的是具备相似上下文的词,比如“good”“bad”相似度也很高,反而是文本分类任务输入有监督的语义能够学到更好的语义表示。

我们还可以查看模型实际包含的内容。这是模型中单词和句子的每个向量。我们可以使用模型访问它们。 syn0(对于你们中的极客来说,syn0只是浅层神经网络的输出层。)
但是,我们不想使用整个syn0,因为它包含了单词的向量,我们只对句子感兴趣。
以下是对负面评论的训练中第一句话的样本向量:
model['TRAIN_NEG_0']

保存和加载模型

为了避免再次训练模型,我们可以保存它。
model.save('./imdb.d2v')
And load it.
model = Doc2Vec.load('./imdb.d2v')

情感分类

训练向量
现在让我们用这些向量来训练分类器。首先,我们必须提取训练向量。请记住,我们总共有25000个训练样本,有相同数量的正例和反例(12500正,12500个负数)。因此,我们创建了一个numpy数组(因为我们使用的分类器只接受numpy数组)。有两个平行数组,一个包含向量(train_array),另一个包含标签(train_label)。
我们只是把正数放在数组的前半部分,负的放在下半部分。

train_arrays = numpy.zeros((25000, 100))
train_labels = numpy.zeros(25000)

for i in range(12500):
    prefix_train_pos = 'TRAIN_POS_' + str(i)
    prefix_train_neg = 'TRAIN_NEG_' + str(i)
    train_arrays[i] = model[prefix_train_pos]
    train_arrays[12500 + i] = model[prefix_train_neg]
    train_labels[i] = 1
    train_labels[12500 + i] = 0

训练数组看起来是这样的:表示每个句子向量的行和行构成了数组。
print (train_arrays)


标签只是句子向量的类别标签——1代表正,0代表负。
print (train_labels)
[ 1. 1. 1. ..., 0. 0. 0.]

测试向量

对于测试数据,我们也做同样的事情——在我们使用训练数据训练它之后,我们要给分类器提供数据。
这使我们能够评估我们的结果。这个过程和提取训练数据的结果差不多。

test_arrays = numpy.zeros((25000, 100))
test_labels = numpy.zeros(25000)

for i in range(12500):
    prefix_test_pos = 'TEST_POS_' + str(i)
    prefix_test_neg = 'TEST_NEG_' + str(i)
    test_arrays[i] = model[prefix_test_pos]
    test_arrays[12500 + i] = model[prefix_test_neg]
    test_labels[i] = 1
    test_labels[12500 + i] = 0

分类

现在我们使用训练数据训练逻辑回归分类器。

classifier = LogisticRegression()
classifier.fit(train_arrays, train_labels)

接着发现我们在情绪分析方面的有着接近87%的准确度。这是相当难以置信的,因为我们只使用一个线性支持向量机(代码中明明是逻辑回归……)和一个非常浅的神经网络。
classifier.score(test_arrays, test_labels)
0.86968000000000001
这不是很棒吗?希望以上内容能为你节省一些时间!

References


上面内容翻译自原作者的github。
使用gensim包训练Word2Vec并不复杂,麻烦的是把数据整理成Word2Vec和Doc2vec的所支持的格式,虽然官方文档中提供了很多例子,但是并不能批量处理。以上作者给出了批量处理的方案,并使用常用的机器学习分类算法来分类。(听说word2vec生成的词向量搭配深度学习会更好,也可以考虑用RNN之类的深度学习来做分类)
改成中文只需要把读取的那几个txt文件修改成中文即可,每一行代表一个句子或者说一篇文章,因为是doc2vec,所以也不需要做什么分词或者去除停用词之类的预处理,但是需要去除标点符号之类的非中文的部分。另外,想要模型准确,需要一个比较完善的语料库。

热门文章