本系列是在作者学习《机器学习系统设计》([美] WilliRichert)过程中的思考与实践,全书通过Python从数据处理,到特征工程,再到模型选择,把机器学习解决问题的过程一一呈现。书中设计的源代码和数据集已上传到我的资源:http://download.csdn.net/detail/solomon1558/8971649
第3章通过词袋模型+K均值聚类实现相关文本的匹配。本文主要讲解文本预处理部分内容,涉及切分文本、数据清洗、计算TF-IDF值等内容。
相关链接:《机器学习系统设计》之应用scikit-learn做文本分类(下)
使用一个简单的数据集进行实验,它包括5个文档:
01. txt This is a toy post about machine learning.Actually, it contains not much interesting stuff.
02. txt Imaging databases provide storagecapabilities.
03. txt Most imaging databases safe imagespermanently.
04. txt Imaging databases store data.
05. txt Imaging databases store data. Imagingdatabases store data. Imaging databases store data.
在这个文档数据集中,我们想要找到和文档”imaging database”最相近的文档。为了将原始文本转换成聚类算法可以使用的特征数据,首先需要使用词袋(bag-of-word)方法来衡量文本间相似性,最终生成每个文本的特征向量。
词袋方法基于简单的词频统计;统计每一个帖子中的词频,表示成一个向量,即向量化。Scikit-learn的CountVectorizer可以高效地完成统计词语的工作,Scikit的函数和类可以通过sklearn包引入进来:
posts = [open(os.path.join(DIR, f)).read() for f in os.listdir(DIR)]
vectorizer = CountVectorizer(min_df=1, stop_words="english")
X_train = vectorizer.fit_transform(posts)
假设待训练的文本存放在目录DIR下,我们将数据集传给CountVectorizer。参数min_df决定了CounterVectorizer如何处理那些不经常使用的词语(最小文档词频)。当min_df为一个整数时,所有出现次数小于这个值的词语都将被扔掉;当它是一个比例时,将整个数据集中出现比例小于这个值的词语都将被丢弃。
我们需要告诉这个想量化处理器整个数据集的信息,使它可以预先知道都有哪些词语:
X_train = vectorizer.fit_transform(posts)
num_samples, num_features = X_train.shape
print ("#sample: %d, #feature: %d" % (num_samples, num_features))
print(vectorizer.get_feature_names())
程序的输出如下,5个文档中包含了25个词语
#sample: 5, #feature: 25
[u'about', u'actually', u'capabilities', u'contains',u'data', u'databases', u'images', u'imaging', u'interesting', u'is', u'it',u'learning', u'machine', u'most', u'much', u'not', u'permanently', u'post',u'provide', u'safe', u'storage', u'store', u'stuff', u'this', u'toy']
对新文档进行向量化:
#a new post new_post = "imaging databases" new_post_vec = vectorizer.transform([new_post])
把每个样本的词频数组当做向量进行相似度计算,需要使用数组的全部元素[使用成员函数toarray()]。通过norm()函数计算新文档与所有训练文档向量的欧几里得范数(最小距离),从而衡量它们之间的相似度。
#------- calculate raw distances betwee new and old posts and record the shortest one------------------------- def dist_raw(v1, v2):
delta = v1 - v2
return sp.linalg.norm(delta.toarray())
best_doc = None best_dist = sys.maxint
best_i = None for i in range(0, num_samples):
post = posts[i]
if post == new_post:
continue post_vec = X_train.getrow(i)
d = dist_raw(post_vec, new_post_vec)
print "=== Post %i with dist = %.2f: %s" % (i, d, post)
if d<best_dist:
best_dist = d
best_i = i
print("Best post is %i with dist=%.2f" % (best_i, best_dist))
=== Post 0 with dist = 4.00:This is a toy post about machine learning. Actually, it contains not muchinteresting stuff.
=== Post 1 with dist =1.73:Imaging databases provide storage capabilities.
=== Post 2 with dist =2.00:Most imaging databases safe images permanently.
=== Post 3 with dist =1.41:Imaging databases store data.
=== Post 4 with dist =5.10:Imaging databases store data. Imaging databases store data. Imaging databasesstore data.
Best post is 3 with dist=1.41
结果显示文档3与新文档最为相似。然而文档4和文档3的内容一样,但重复了3遍。所以,它和新文档的相似度应该与文档3是一样的。
#-------case study: why post 4 and post 5 different ?----------- print(X_train.getrow(3).toarray())
print(X_train.getrow(4).toarray())
[[0 0 0 0 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0]]
[[0 0 0 0 3 3 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0]]
对第2节中的dist_raw函数进行扩展,在归一化的向量上(向量各分量除以其模长)计算向量间的距离。
def dist_norm(v1, v2):
v1_normalized = v1 / sp.linalg.norm(v1.toarray())
v2_normalized = v2 / sp.linalg.norm(v2.toarray())
delta = v1_normalized - v2_normalized
return sp.linalg.norm(delta.toarray())
=== Post 0 with dist = 1.41: This is a toy post aboutmachine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist = 0.86: Imaging databases providestorage capabilities.
=== Post 2 with dist = 0.92: Most imaging databasessafe images permanently.
=== Post 3 with dist = 0.77:Imagingdatabases store data.
=== Post 4 with dist = 0.77:Imaging databases store data. Imaging databases store data. Imaging databasesstore data.
Best post is 3 with dist=0.77
词频向量归一化之后,文档3和文档4与新文档具有了相同的相似度。从词频统计的角度来说,这样处理更为正确。
文本中类似”the”、”of”这些单词经常出现在各种不同的文本中,被称为停用词。由于停用词对于区分文本没有多大帮助,因此删除停用词是文本处理中的一个常见步骤。CountVectorizer中有一个简单的参数stop_words()可以完成该任务:
vectorizer = CountVectorizer(min_df=1, stop_words='english')
为了将语义类似但形式不同的词语放在一起统计,我们需要一个函数将词语归约到特定的词干形式。自然语言处理工具包(NLTK)提供了一个非常容易嵌入到CountVectorizer的词干处理器。
把文档传入CountVectorizer之前,我们需要对它们进行词干处理。该类提供了几种钩子,可以用它们定制预处理和词语切分阶段的操作。预处理器和词语切分器可以当作参数传入构造函数。我们并不想把词干处理器放入它们任何一个当中,因为那样的话,之后还需要亲自对词语进行切分和归一化。相反,我们可以通过改写build_analyzer方法来实现:
import nltk.stem
english_stemmer = nltk.stem.SnowballStemmer('english')
class StemmedCountVectorizer(CountVectorizer):
def build_analyzer(self):
analyzer = super(StemmedCountVectorizer, self).build_analyzer()
return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))
vectorizer = StemmedCountVectorizer(min_df=1, stop_words='english')
按照如下步骤对每个帖子进行处理:
(1) 在预处理阶段将原始文档变成小写字母形式(这在父类中完成);
(2) 在词语切分阶段提取所有单词;
(3) 将每个词语转换成词干形式。
至此,我们采用统计词语的方式,从充满噪声的文本中提取了紧凑的特征向量。这些特征的值就是相应词语在所有训练文本中出现的次数,我们默认较大的特征值意味着合格词语对文本更为重要。但是在训练文本中,不同的词语对文本的可区分性贡献更大。
这需要通过统计每个文本的词频,并且对出现在多个文本中的词语在权重上打折来解决。即当某个词语经常出现在一些特定的文本中,而在其他地方很少出现时,应该赋予该词语更大的权值。
这正是词频-反转文档频率(TF-IDF)所要做的:TF代表统计部分,而IDF把权重折扣考虑了进去。一个简单的实现如下:
import scipy as sp
def tfidf(t, d, D):
tf = float(d.count(t)) / sum(d.count(w) for w in set(d))
idf = sp.log(float(len(D)) / (len([doc for doc in D if t in doc])))
return tf * idf
在实际应用过程中,scikit-learn已经将该算法封装进了TfidfVectorizer(继承自CountVectorizer)中。进行这些操作后,我们得到的文档向量不会再包含词语拥挤值,而是每个词语的TF-IDF值。
import os import sys import scipy as sp from sklearn.feature_extraction.text import CountVectorizer DIR = r"../data/toy" posts = [open(os.path.join(DIR, f)).read() for f in os.listdir(DIR)] new_post = "imaging databases" import nltk.stem english_stemmer = nltk.stem.SnowballStemmer('english') class StemmedCountVectorizer(CountVectorizer): def build_analyzer(self): analyzer = super(StemmedCountVectorizer, self).build_analyzer() return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc)) #vectorizer = StemmedCountVectorizer(min_df=1, stop_words='english') from sklearn.feature_extraction.text import TfidfVectorizer class StemmedTfidfVectorizer(TfidfVectorizer): def build_analyzer(self): analyzer = super(StemmedTfidfVectorizer, self).build_analyzer() return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc)) vectorizer = StemmedTfidfVectorizer(min_df=1, stop_words='english') print(vectorizer) X_train = vectorizer.fit_transform(posts) num_samples, num_features = X_train.shape print("#samples: %d, #features: %d" % (num_samples, num_features)) new_post_vec = vectorizer.transform([new_post]) print(new_post_vec, type(new_post_vec)) print(new_post_vec.toarray()) print(vectorizer.get_feature_names()) def dist_raw(v1, v2): delta = v1 - v2 return sp.linalg.norm(delta.toarray()) def dist_norm(v1, v2): v1_normalized = v1 / sp.linalg.norm(v1.toarray()) v2_normalized = v2 / sp.linalg.norm(v2.toarray()) delta = v1_normalized - v2_normalized return sp.linalg.norm(delta.toarray()) dist = dist_norm best_dist = sys.maxsize best_i = None for i in range(0, num_samples): post = posts[i] if post == new_post: continue post_vec = X_train.getrow(i) d = dist(post_vec, new_post_vec) print("=== Post %i with dist=%.2f: %s" % (i, d, post)) if d < best_dist: best_dist = d best_i = i print("Best post is %i with dist=%.2f" % (best_i, best_dist))
文本预处理过程包含的步骤总结如下:
(1) 切分文本;
(2) 扔掉出现过于频繁,而又对匹配相关文档没有帮助的词语;
(3) 扔掉出现频率很低,只有很小可能出现在未来帖子中的词语;
(4) 统计剩余的词语;
(5) 考虑整个预料集合,从词频统计中计算TF-IDF值。
通过这一过程,我们将一堆充满噪声的文本转换成了一个简明的特征表示。然而,虽然词袋模型及其扩展简单有效,但仍然有一些缺点需要注意:
(1) 它并不涵盖词语之间的关联关系。采用之前的向量化方法,文本”Car hits wall”和”Wall hits car”会有相同的特征向量。
(2) 它没法捕捉否定关系。例如”I will eat ice cream”和”I will not eat ice cream”,尽管它们意思截然相反,但从特征向量来看它们非常相似。这个问题其实很容易解决,只需要既统计单个词语(又叫unigrams),又考虑成队的词语(bigrams)或者trigrams(一行中的三个词语)即可。
(3) 对于拼写错误的词语会处理失败。