返回介绍

3.2 预处理:用相近的公共词语个数来衡量相似性

发布于 2024-01-30 22:34:09 字数 13681 浏览 0 评论 0 收藏 0

正像我们之前看到的那样,词袋方法既快捷又稳健。然而,它也不是没有问题,让我们深入看一下。

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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文