本章我们将深入研究自然语言处理(natural language processing,NLP)领域的一个分支—-情感分析(sentiment analysis),还将学习如何使用机器学习算法基于文档的情感倾向对文档进行分类。

获取IMDB电影评论数据集

情感分析,又是也称作是观点挖掘,是NLP领域一个非常流行的分支,它分析的是文档的情感倾向。本章中,我们将要使用的是互联网电影数据库中的大量电影评论数据。可以访问http://ai.stanford.edu/~amaas/data/sentiment/来下载电影评论。

在下载完成后对文档进行解压,接下来我们着手将从压缩文件中得到的各文本文档组合为一个CSV文件,在下面的代码中,我们把电影的评论读取到pandas的DataFrame对象中。同时使用PyPrid(Python Progress Indicator)包来预测剩余处理时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pyprind
import pandas as pd
import os

pbar = pyprind.ProgBar(50000)
labels = {'pos': 1, 'neg': 0}
df = pd.DataFrame()
for s in ('test', 'train'):
for l in ('pos', 'neg'):
path = './aclImdb/%s/%s' % (s, l)
for file in os.listdir(path):
with open(os.path.join(path, file), 'r') as infile:
txt = infile.read()
df = df.append([[txt, labels[l]]], ignore_index=True)
pbar.update()
df.columns = ['review', 'sentiment']

由于集成处理过后数据集中的对应类标是经过排序的,我们现在使用np.random子模块下的permutation函数对DataFrame对象进行重排,并且将其存储为CSV文件:

1
2
3
4
5
import numpy as np

np.random.seed(0)
df = df.reindex(np.random.permutation(df.index))
df.to_csv('./movie_data.csv', index=False)

现在读取前三个样本的摘要:

1
2
df = pd.read_csv('./movie_data.csv')
df.head(3)

1581062367489

词袋模型简介

本节中,我们介绍词袋模型,它将文本以数值特征向量的形式来表示。词袋模型的理念很简单,可描述如下:

  1. 我们在整个文档上为每个词汇创建了唯一的标记,如单词
  2. 我们为每个文档构建一个特征向量,其中包含每个单词在此文档中出现的次数

下面讲解创建简单词袋模型的过程。

将单词转换为特征向量

我们可以使用scikit-learn中的CountVector类来根据每个文档中的单词数量构建词袋模型:

1
2
3
4
5
6
7
8
9
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
count = CountVectorizer()
docs = np.array(['The sun is shining',
'The weather is sweet',
'The sun is shining and the weather is sweet'])
bag = count.fit_transform(docs)
print(count.vocabulary_)
>> {'the': 5, 'sun': 3, 'is': 1, 'shining': 2, 'weather': 6, 'sweet': 4, 'and': 0}

由上述命令的运行结果可见,词汇以Python字典的格式存储,将单个单词映射为一个整数索引。接下来看一下之前创建的特征向量:

1
2
3
4
print(bag.toarray())
>> [[0 1 1 1 0 1 0]
>> [0 1 0 0 1 1 1]
>> [1 2 1 1 1 2 1]]

出现在特征向量中的值也称作是原始词频:$ tf(t, d):=词汇t在文档d中出现的次数 $。

通过词频–逆文档频率计算单词关联度

当我们分析文档数据时,经常遇到的问题就是:一个单词出现在两种类型的多个文档中,这种频繁出现的单词通常不包含有用或具备辨识度的信息。本节中,我们将会学习词频–逆文档频率
$$
tf-idf(t, d) = tf(t, d) \times idf(t, d)
$$
其中,逆文档频率计算公式如下:
$$
idf(t, d) = log\frac{n_d}{1+df(d, t)}
$$
这里的$ n_d $问文档的总数,$ df(d, f) $为词汇t在文档d中的数量。分母中的1是为了防止分母为0;取对数是为了出现频率较低的词汇不会被赋予过大的权重。

scikit-learn中还实现了TfidfTransformer转换器:

1
2
3
4
5
6
7
8
from sklearn.feature_extraction.text import TfidfTransformer

tfidf = TfidfTransformer()
np.set_printoptions(precision=2)
print(tfidf.fit_transform(count.fit_transform(docs)).toarray())
>> [[0. 0.43 0.56 0.56 0. 0.43 0. ]
>> [0. 0.43 0. 0. 0.56 0.43 0.56]
>> [0.4 0.48 0.31 0.31 0.31 0.48 0.31]]

可以发现,is在第三个文档中具有较高的词频,但是在将特征向量转换为$ tf-idf $后,单词is在第三个文档中只得到了一个相对较小的$ tf-idf $。

scikit-learn中计算$ tf-idf $之前都会对原始词频进行归一化处理。

清洗文本数据

在构建词袋模型之前,最重要的一步就是去除所有不需要的字符对文本数据进行清洗。我们先展示一下经过重排后数据集中第一个文档的最后50个字符:

1
2
df.loc[0, 'review'][-50:]
>> 'is seven.<br /><br />Title (Brazil): Not Available'

接下来,我们将会去除标点符号和HTML标签:

1
2
3
4
5
import re
def preprocessor(text):
text = re.sub('<[^>]*>', '', text)
text = re.sub('[\W]+', ' ', text.lower())
return text

接下来我们看一下该函数是否能正常工作:

1
2
preprocessor(df.loc[0, 'review'][-50:])
>> 'is seven title brazil not available'

最后,我们在下一节中将会反复使用在此经过清洗的文本数据,现在通过preprocessor函数清洗所有的电影评论:

1
df['review'] = df['review'].apply(preprocessor)

标记文档

准备好电影评论数据集后,我们需要将文本语料拆分为单独的元素。标记(tokenize)文档的一个常用方法是通过文档的空白字符将其拆分为单独的单词:

1
2
3
4
def tokenizer(text):
return text.split()
tokenizer('runner likes running and thus he run')
>> ['runner', 'likes', 'running', 'and', 'thus', 'he', 'run']

在对文本标记的过程中,另外一种有用的技术就是词干提取(word stemming),这是一个提取单词原型的过程,这样,我们就能将一个单词映射到对应的词干上。Python的自然语言工具包(NLPK)实现了Porter Stemming算法:

1
2
3
4
5
6
from nltk.stem import PorterStemmer
porter = PorterStemmer()
def tokenizer_porter(text):
return [porter.stem(word) for word in text.split()]
tokenizer_porter('runner likes running and thus he run')
>> ['runner', 'like', 'run', 'and', 'thu', 'he', 'run']

可以发现,running被修改为run,但是thus被修改为不存在的单词thu。在实际应用中中,这种结果造成的影响不大。

另外,还有一种有用的技术:停用词移除(stop-word removal)。停用词在英文中太常见了,它们包含很少的有用信息,因此可以将他们删除:

1
2
3
4
5
6
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords')
stop = stopwords.words('english')
[w for w in tokenizer_porter('a runner likes running and runs a lot')[-10:] if w not in stop]
>> ['runner', 'like', 'run', 'and', 'thu', 'he', 'run']

训练用于文档分类的逻辑斯蒂回归模型

本节中,我们将会使用逻辑斯蒂回归模型将电影评论分为正面评价和负面评价。首先,我们将文本对象划分为测试数据和训练数据:

1
2
3
4
X_train = df.loc[:25000, 'review'].values
y_train = df.loc[:25000, 'sentiment'].values
X_test = df.loc[25000:, 'review'].values
y_test = df.loc[25000:, 'sentiment'].values

接着我们使用Grid Search CV对象,并且使用5折分层交叉验证找到最佳参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(strip_accents=None, lowercase=False, preprocessor=None)
param_grid = [{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [stop, None],
'vect__tokenizer': [tokenizer, tokenizer_porter],
'clf__penalty': ['l1', 'l2'],
'clf__C': [1.0, 10.0, 100.0]},
{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [stop, None],
'vect__tokenizer': [tokenizer, tokenizer_porter],
'vect__use_idf': [False],
'vect__norm': [None],
'clf__penalty': ['l1', 'l2'],
'clf__C': [1.0, 10.0, 100.0]}]
lr_tfidf = Pipeline([('vect', tfidf),
('clf', LogisticRegression(random_state=0))])
gs_lr_tfidf = GridSearchCV(lr_tfidf, param_grid, scoring='accuracy', cv=5, verbose=1, n_jobs=-1)
gs_lr_tfidf.fit(X_train, y_train)

在网格搜索结束后,我们可以输出最佳的参数集:

1
2
print('Best params: %s' % gs_lr_tfidf.best_params_)
>> Best params: {'clf__C': 10.0, 'clf__penalty': 'l2', 'vect__ngram_range': (1, 1), 'vect__stop_words': None, 'vect__tokenizer': <function tokenizer at 0x000002517B0C5F78>}

使用网格搜索得到的最佳模型,我们分别输出5折交叉验证的准确率得分,以及在测试数据集上的分类准确率:

1
2
3
4
5
print('CV acc: %s' % gs_lr_tfidf.best_score_)
>> CV acc: 0.8974041038358466
clf = gs_lr_tfidf.best_estimator_
print('Test acc: %s' % clf.score(X_test, y_test))
>> Test acc: 0.89844

结果表明,我们的机器学习模型针对电影评论是正面评论还是负面评论的分类准确率为90%。

使用大数据之在线算法与外存学习

在上一节中,使用网格搜索最佳参数的算法计算成本很高。回顾一下第2章中的随机梯度下降(stochastic gradient descent, SGD)概念,此优化算法每次使用一个样本来更新模型的权重信息。在本节中,我们将使用scikit-learn中SGDClassifier的partial_fit函数来读取本地存储设备,并且使用小型子批次(minibatches)文档来训练一个逻辑斯蒂回归模型。

首先,我们定义一个tokenizer函数来清理movie_data.csv文件中未经处理的文本数据:

1
2
3
4
5
6
7
8
9
import numpy as np
import re
from nltk.corpus import stopwords
stop = stopwords.words('english')
def tokenizer(text):
text = re.sub('<[^>]*>', '', text)
text = re.sub('[\W]+', ' ', text.lower())
tokenized = [w for w in text.split() if w not in stop]
return tokenized

接下来我们定义一个生成器函数:stream_docs,它每次读取且返回一个文档的内容:

1
2
3
4
5
6
def stream_docs(path):
with open(path, 'r') as scv:
next(csv)
for line in csv:
text, label = line[:-3], int(line[-2])
yield text, label

定义一个get_minibatch函数,它以stream_doc函数得到的文档数据流作为输入,并且通过size返回指定数量的文档内容:

1
2
3
4
5
6
7
8
9
10
def get_minibatch(doc_stream, size):
docs, y = [], []
try:
for _ in range(size):
text, label = next(doc_stream)
docs.append(text)
y.append(label)
except StopIteration:
return None, None
return docs, y

不幸的是,由于需要将所有的词汇加载到内存中,我们无法通过CountVectorizer来使用外存学习方法。另外,TfidfVectorizer需要将所有训练数据集中的特征向量加载到内存以计算逆文档频率。不过,scikit-learn提供了另外一个处理文本信息的向量处理器:HashingVectorizer。HashingVectorizer是独立数据的:

1
2
3
4
5
6
7
8
from sklearn.feature_extraction.text import HashingVectorizer
from sklearn.linear_model import SGDClassifier
vect = HashingVectorizer(decode_error='ignore',
n_features=2**21,
preprocessor=None,
tokenizer=tokenizer)
clf = SGDClassifier(loss='log', random_state=1, n_iter=1)
doc_stream = stream_docs(path='./movie_data.csv')

接下来我们可以通过下述代码使用外存学习:

1
2
3
4
5
6
7
8
9
10
import pyprind
pbar = pyprind.ProgBar(45)
classes = np.array([0, 1])
for _ in range(45):
X_train, y_train = get_minibatch(doc_stream, size=1000)
if not X_train:
break
X_train = vect.transform(X_train)
clf.partial_fit(X_train, y_train, classes=classes)
pbar.update()

完成增量学习后,我们将使用剩余的5000个文档来评估模型的性能:

1
2
3
4
X_test, y_test = get_minibatch(doc_stream, size=5000)
X_test = vect.transform(X_test)
print('Acc: %.3f' % clf.score(X_test, y_test))
>> Acc: 0.868

可以看到,模型的准确率约为87%,略微低于我们上一节我们使用网格搜索进行超参调优得到的模型。不过外存学习的存储效率高,只用了不到一分钟的实践就完成了。最后,我们可以通过剩下的5000个文档进行升级:

1
clf = clf.partial_fit(X_test, y_test)