3.2 预处理:用相近的公共词语个数来衡量相似性
正像我们之前看到的那样,词袋方法既快捷又稳健。然而,它也不是没有问题,让我们深入看一下。
3.2.1 将原始文本转化为词袋
我们根本无需编写特别的代码来统计词语个数,并把词频表示成向量。Scikit的CountVectorizer 可以很高效地做好这部分工作。它还有一个非常方便的接口。Scikit的函数和类可以通过sklearn 包引入进来,如下所示:
>>> from sklearn.feature_extraction.text import CountVectorizer >>> vectorizer = CountVectorizer(min_df=1)
参数min_df 决定了CountVectorizer 如何处理那些不经常使用的词语(最小文档频率)。如果将它设成一个整数,那么所有出现次数小于这个值的词语都将被扔掉。如果它是一个比例,那么所有在整个数据集中出现比例小于这个值的词语都将被扔掉。参数max_df 的功能与此类似。如果我们把实例的内容打印出来,我们会看到Scikit提供的其他参数及其默认值:
>>> print(vectorizer) CountVectorizer(analyzer=word, binary=False, charset=utf-8, charset_error=strict, dtype=<type 'long'>, input=content, lowercase=True, max_df=1.0, max_features=None, max_n=None, min_df=1, min_n=None, ngram_range=(1, 1), preprocessor=None, stop_words=None, strip_accents=None, token_pattern=(?u)\b\w\w+\b, tokenizer=None, vocabulary=None)
我们会看到,正如所预期的那样,词频统计是在词语粒度上完成的(analyzer=word ),而所有词语都是用正则表达模式token_pattern 确定下来的。例如,它会将“cross-validated”切分成“cross”和“validated”这两个词。我们现在先忽略其他参数。
>>> content = ["How tformat my hard disk", " Hard disk format problems "] >>> X = vectorizer.fit_transform(content) >>> vectorizer.get_feature_names() [u'disk', u'format', u'hard', u'how', u'my', u'problems', u'to']
这个向量化处理器检测到了7个词语,我们分别获取了它们的出现次数:
>>> print(X.toarray().transpose()) array([[1, 1], [1, 1], [1, 1], [1, 0], [1, 0], [0, 1], [1, 0]], dtype=int64)
这意味着,第一句中把除“problems”外的其他词语都包括进来了,而第二句中含有除“how”、“my”和“to”以外的其他词语。事实上,这跟前面表中对应列的内容一模一样。从X中我们可以提取出特征向量,来比较两个文档之间的相似度。
首先我们从一个朴素的方法开始,指出一些必须考虑的预处理特性。我们随便挑选了一个帖子,然后用它创建一个词频向量。我们比较它和其他所有词频向量的距离,然后获取距离最小的那个帖子。
3.2.2 统计词语
让我们对一个简单数据集进行实验,它包括下面这些帖子:
帖子文件名 | 帖子内容 |
01.txt | This is a toy post about machine learning. Actually, it contains not much interesting stuff. |
02.txt | Imaging databases can get huge. |
03.txt | Most imaging databases safe images permanently. |
04.txt | Imaging databases store images. |
05.txt | Imaging databases store images. Imaging databases store images. Imaging databases store images. |
在这个帖子数据集中,我们想要找到和短帖子“imaging database”最相近的帖子。
假设这些帖子存放在目录DIR下,我们可以按如下方法把它传给CountVectorizer :
>>> posts = [open(os.path.join(DIR, f)).read() for f in os.listdir(DIR)] >>> from sklearn.feature_extraction.text import CountVectorizer >>> vectorizer = CountVectorizer(min_df=1)
我们需要告诉这个向量化处理器整个数据集的信息,使它可以预先知道都有哪些词语,如下列代码所示:
>>> X_train = vectorizer.fit_transform(posts) >>> num_samples, num_features = X_train.shape >>> print("#samples: %d, #features: %d" % (num_samples, num_features)) #samples: 5, #features: 25
不出所料,5个帖子中总共包含了25个不同的词语。接下来,我们要对下面这些被切分出的词语:
>>> print(vectorizer.get_feature_names()) [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']
现在可以对新帖子进行向量化,如下所示:
>>> new_post = "imaging databases" >>> new_post_vec = vectorizer.transform([new_post])
注意,由transform 方法返回的词频向量是很稀疏的。这个是说,由于大多数统计值都是0(帖子不包含某个词),在每个向量中并没有为每个词语都存储一个统计值。相反,它使用了高效内存的实现方式coo_matrix (对应“COOrdinate”)。例如,我们的新帖子中只包含两个元素:
>>> print(new_post_vec) (0, 7)1 (0, 5)1
通过成员函数toarray() 可以访问到ndarray 的全部内容,如下所示:
>>> print(new_post_vec.toarray()) [[0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]
如果要把数组当做向量进行相似度计算,我们就需要使用数组的全部元素。通过相似度的衡量方法(朴素方法),我们计算新帖子和其他所有老帖子的词频向量之间的欧氏距离,如下所示:
>>> import scipy as sp >>> def dist_raw(v1, v2): >>> delta = v1-v2 >>> return sp.linalg.norm(delta.toarray())
norm() 函数用于计算欧几里得范数(最小距离)。只需要用dist_raw 遍历所有帖子,并记录最相近的一个:
>>> import sys >>> 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(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 much interesting 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 databases Best post is 3 with dist=1.41
恭喜你!我们有了第一个相似度衡量方法。帖子0是和新帖子最不相似的一个。这一点可以理解,因为它和新帖子没有任何公共词语。我们还可以看到帖子1和新帖子是非常相似的,但并不是最相似的,因为它比帖子3多包含了1个未出现在新帖子中的词语。
然而,看看帖子3和帖子4,情况并不是那么清晰。帖子4和帖子3的内容一样,但重复了3遍。所以,它与新帖子的相似度应该和帖子3是一样的。
通过打印出相应的特征向量来解释一下原因:
>>> print(X_train.getrow(3).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]] >>> print(X_train.getrow(4).toarray()) [[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]]
很明显,只使用原始词语的词频统计这种方式过于简单了。我们需要对它们进行归一化,得到单位长度为1的向量。
3.2.3 词语频次向量的归一化
我们需要对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 about machine learning. Actually, it contains not much interesting stuff. === Post 1 with dist=0.86: Imaging databases provide storage capabilities. === Post 2 with dist=0.92: Most imaging databases safe images permanently. === Post 3 with dist=0.77: Imaging databases store data. === Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data. Best post is 3 with dist=0.77
这样看起来好一些了。帖子3和帖子4具有了相同的相似度。有人可能会争论,重复过多是否依然能让读者感到愉快,但从词频统计的角度来说,这看起来是正确的。
3.2.4 删除不重要的词语
让我们再来看看帖子2。不包含在新帖中子的词语有“most”“safe”“images”和“permanently”。事实上它们在帖子中的重要性并不相同。像“most”这样的词语经常出现在各种不同的文本中,这种词叫做停用词。它们并未承载很多信息量,因此不应该给予像“images”这样不经常出现在各种文本中的词语一样的权重。最佳的选择是删除所有这样的高频词语,因为它们对于区分文本并没有多大帮助。
由于这是文本处理中的一个常见步骤,因此在CountVectorizer 中有一个简单的参数可以完成这个任务,如下所示:
>>> vectorizer = CountVectorizer(min_df=1, stop_words='english')
如果清楚地知道要删除什么类型的停用词,你还可以传入一个停用词列表。倘若设置stop_words 为“english”,那么将会使用一个包含318单词的英文停用词表。要弄清它们具体是哪些词语,你可以使用get_stop_words() 函数:
>>> sorted(vectorizer.get_stop_words())[0:20] ['a', 'about', 'above', 'across', 'after', 'afterwards', 'again', 'against', 'all', 'almost', 'alone', 'along', 'already', 'also', 'although', 'always', 'am', 'among', 'amongst', 'amoungst']
这样,新的词语列表就减少了7个词语:
[u'actually', u'capabilities', u'contains', u'data', u'databases', u'images', u'imaging', u'interesting', u'learning', u'machine', u'permanently', u'post', u'provide', u'safe', u'storage', u'store', u'stuff', u'toy']
在没有停用词的情况下,我们可以得到以下相似度测量值:
=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff. === Post 1 with dist=0.86: Imaging databases provide storage capabilities. === Post 2 with dist=0.86: Most imaging databases safe images permanently. === Post 3 with dist=0.77: Imaging databases store data. === Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data. Best post is 3 with dist=0.77
帖子2现在与帖子1旗鼓相当。综合来说,效果并没有明显的改变,这是因为出于演示的目的,我们的帖子都很短。但如果我们使用真实数据,那这将会变得非常重要。
3.2.5 词干处理
有一件事情需要注意,那就是,我们把语义类似但形式不同的词语当做了不同的词进行统计。例如,帖子2包含“imaging”和“images”。如果把它们放在一起统计,就会更有道理。毕竟,它们指向的是同一个概念。
我们需要一个函数将词语归约到特定的词干形式。Scikit并没有默认的词干处理器。我们可以通过自然语言处理工具包 (NLTK)下载一个免费的软件工具包。它提供了一个很容易嵌入CountVectorizer 的词干处理器。
1. 安装和使用NLTK
http://nltk.org/install.html 中详细介绍了如何在操作系统中安装NLTK。总的来说,你需要安装两个程序包NLTK和PyYAML。
如果要检查自己的安装是否成功,那么需要打开一个Python解释器并键入如下命令:
>>> import nltk
提示 你可以在Python Text Processing with NLTK 2.0 Cookbook 中找到一个很棒的NLTK教程。要想试验一下词干处理器,你可以访问本书的网址http://text-processing.com/demo/stem/ 。
NLTK中有各种不同的词干处理器。这是很必要的,因为每种语言都有一些不同的词干处理规则。对于英语,我们可以使用SnowballStemmer 。
>>> import nltk.stem >>> s= nltk.stem.SnowballStemmer('english') >>> s.stem("graphics") u'graphic' >>> s.stem("imaging") u'imag' >>> s.stem("image") u'imag' >>> s.stem("imagination")u'imagin' >>> s.stem("imagine")
注意 词干处理的结果并不一定是有效的英文单词。
它也可以处理动词,如下所示:
>>> s.stem("buys") u'buy' >>> s.stem("buying") u'buy' >>> s.stem("bought") u'bought'
2. 用NLTK词干处理器拓展词向量
在把帖子传入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. 将每个词语转换成词干形式。
在结果中减少了一个特征,这是因为“images”和“imaging”合并成了一个。特征名称集合如下所示:
[u'actual', u'capabl', u'contain', u'data', u'databas', u'imag', u'interest', u'learn', u'machin', u'perman', u'post', u'provid', u'safe', u'storag', u'store', u'stuff', u'toy']
我们运行这个经过词干处理的向量化处理器之后会发现,“imaging”与“images”的合并揭示出事实上帖子2是与新帖子最接近的,因为它们运用了两次概念“imag”:
=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff. === Post 1 with dist=0.86: Imaging databases provide storage capabilities. === Post 2 with dist=0.63: Most imaging databases safe images permanently. === Post 3 with dist=0.77: Imaging databases store data. === Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data. Best post is 2 with dist=0.63
3.2.6 停用词兴奋剂
现在我们有了一个合理的方式,从充满噪声的文本帖子中提取紧凑的向量。让我们回过头来考虑一下这些特征的具体含义是什么。
特征值就是词语在帖子中出现的次数。我们默默地假定较大的特征值意味着这个词语对帖子更为重要。但是诸如“subject”这样的在每个帖子中都出现的词语是怎么回事呢?好吧,我们可以通过max_df 参数让CountVectorizer 把它也删掉。例如,我们可以将它设为0.9,那么所有出现在超过90%的帖子中的词语将会被忽略掉。但是出现在89%的帖子中的词语怎么办呢?我们要把max_df 设得多低呢?这里的问题在于,虽然我们设置了一个参数,但总会遇到这样的问题:一些词语正好要比其他词语更具有区分性。
这只能通过统计每个帖子的词频,并且对出现在多个帖子中的词语在权重上打折扣来解决。换句话说,当某个词语经常出现在一些特定帖子中,而在其他地方很少出现的时候,我们会赋予该词语较高的权值。
这正是词频-反转文档频率 (TF-IDF)所要做的;TF代表统计部分,而IDF把权重折扣考虑了进去。一个简单的操作如下所示:
>>> import scipy as sp >>> def tfidf(term, doc, docset): ... tf = float(doc.count(term))/sum(doc.count(w) for w in docset) ... idf = math.log(float(len(docset))/(len([doc for doc in docset if term in doc]))) ... return tf * idf
在下列文档集合docset (包含三个文档并且已经进行过词语切分)中,我们可以看到这些词语已经被区别对待,尽管它们在每篇文档中都等频率出现。
>>> a, abb, abc = ["a"], ["a", "b", "b"], ["a", "b", "c"] >>> D = [a, abb, abc] >>> print(tfidf("a", a, D)) 0.0 >>> print(tfidf("b", abb, D)) 0.270310072072 >>> print(tfidf("a", abc, D)) 0.0 >>> print(tfidf("b", abc, D)) 0.135155036036 >>> print(tfidf("c", abc, D)) 0.366204096223
显而易见,a 几乎无处不在,所以它在任何文档中都没有什么实际意义。相对于文档abc ,b 对文档abb 更为重要,因为它在abb 中出现了两次。
在现实场景中,还有比上述例子更多的边角情况需要处理。感谢Scikit,我们并不需要考虑这些问题,因为它们已经很好地把它们封装在TfidfVectorizer (继承自CountVectorizer )里了。毫无疑问,我们不想错过我们的词干处理器。
>>> from sklearn.feature_extraction.text import TfidfVectorizer >>> class StemmedTfidfVectorizer(TfidfVectorizer): ... def build_analyzer(self): ... analyzer = super(TfidfVectorizer, self).build_analyzer() ... return lambda doc: ( english_stemmer.stem(w) for w in analyzer(doc)) >>> vectorizer = StemmedTfidfVectorizer(min_df=1, stop_words='english', charset_error='ignore')
进行这些操作后,我们得到的文档向量不会再包含词语统计值。相反,它会包含每个词语的TF-IDF值。
3.2.7 我们的成果和目标
我们现在的文本预处理过程包含以下步骤:
1. 切分文本;
2. 扔掉出现过于频繁,而又对检测相关帖子没有帮助的词语;
3. 扔掉出现频率很低,只有很小可能出现在未来帖子中的词语;
4. 统计剩余的词语;
5. 考虑整个语料集合,从词频统计中计算TF-IDF值。
再次恭喜自己。通过这个过程,我们将一堆充满噪声的文本转换成了一个简明的特征表示。
然而,虽然词袋模型及其扩展简单有效,但仍然有一些缺点需要我们注意。这些缺点如下所示。
它并不涵盖词语之间的关联关系。采用之前的向量化方法,文本“Car hits wall”和“Wall hits car”会具有相同的特征向量。
它没法正确捕捉否定关系。例如,文本“I will eat ice cream”和“I will not eat ice cream”,尽管它们的意思截然相反,但从特征向量来看它们非常相似。这个问题其实很容易解决,只需要既统计单个词语(又叫unigrams),又考虑bigrams(成对的词语)或者trigrams(一行中的三个词语)即可。
对于拼写错误的词语会处理失败。尽管读者能够很清楚地意识到“database”和“databas”传递了相同的意思,但是我们的方法却把它们当做完全不同的词语。
为简单起见,我们仍然使用现有的方法,由此现在可以高效地构建聚类簇了。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论