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

自然语言处理中的舆情分析、情感分析有很多种方法,但是基于模型的方法对语料的质量要求高,如果不能弄到高质量的语料,很多时候并不准确。如果需要预测的样本量很小,通常到最后还是使用最原始的方法--基于词库匹配。虽然词库匹配效果一定不好,效率还低,但是它非常容易实现,随随便便就能弄一个出来试试效果,如果在样本很小领域很单一的情况,基于这种方法就能凑合着用用了。
如果训练样本量足够大,且样本属于单一领域,用机器学习的方法也会有不错的效果。最简单的方法是使用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或您最喜欢的语言轻松完成这一任务。这一步很简单。
其结果是有五个文件:

  • “test-neg。txt:来自测试数据的12500个负面电影评论。
  • “test-pos。txt:来自测试数据的12500个正面电影评论。
  • “train-neg。txt:来自培训数据的12500个负面电影评论。
  • “train-pos。txt:来自tr的12500个正面电影评论。
  • train-unsup。txt: 50000没有标签的电影评论。

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


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

为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文档。或者,这里提供一个简单的纲要:

  • min_count:忽略所有频率低于这个的单词。您必须将其设置为1,因为句标签只出现一次。把它设置成高于1就会漏掉句子。
  • window: 句子中当前单词和预测单词之间的最大距离。Word2Vec使用skip-gram模型,而这只是skip -gram模型的窗口大小。英文可以设置小一点例如10。(注意:在中文中,两到四个字才能组成一个词语,可以设置大一点15-25左右。
  • size: 特征向量在输出中的维数。100是个好数字。如果你是需要极端情况,这个值可以达到400左右。大的size需要更多的训练数据,但是效果会更好. 推荐值为几十到几百。
  • sample: 配置高频词的阈值。#高频词汇的随机降采样的配置阈值,默认为1e-3,官网给的解释 1e-5效果比较好。设置为0时是词最少的时候!不进行降采样,结果词少,当设置1e-5,相应的词展现更丰富!
  • workers: 使用这许多工作线程来训练模型。
    * 新版本中迭代的轮数也在这里确定,用(iter)或者(epochs)参数来确定,epochs是最新版本用法
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,所以也不需要做什么分词或者去除停用词之类的预处理,但是需要去除标点符号之类的非中文的部分。另外,想要模型准确,需要一个比较完善的语料库。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容