【中文分词系列】 8. 更好的新词发现算法
By 苏剑林 | 2017-03-11 | 225198位读者 |如果依次阅读该系列文章的读者,就会发现这个系列共提供了两种从0到1的无监督分词方案,第一种就是《【中文分词系列】 2. 基于切分的新词发现》,利用相邻字凝固度(互信息)来做构建词库(有了词库,就可以用词典法分词);另外一种是《【中文分词系列】 5. 基于语言模型的无监督分词》,后者基本上可以说是提供了一种完整的独立于其它文献的无监督分词方法。
但总的来看,总感觉前面一种很快很爽,却又显得粗糙;后面一种很好很强大,却又显得太过复杂(viterbi是瓶颈之一)。有没有可能在两者之间折中一下?这就导致了本文的结果,达到了速度与效果的平衡。至于为什么说“更好”?因为笔者研究词库构建也有一段时间了,以往构建的词库总不能让人(让自己)满意,生成的词库一眼看上去,都能够扫到不少不合理的地方,真的要用得需要经过较多的人工筛选。而这一次,一次性生成的词库,一眼扫过去,不合理的地方少了很多,如果不细看,可能就发现不了了。
分词的目的 #
分词一般作为文本挖掘的第一步,仿佛是很自然的,但事实上也应该问个为什么:为什么要分词?人本来就是按照字来书写和理解的呀?
当模型的记忆和拟合能力足够强(或者简单点,足够智能)的时候,我们完全可以不用分词的,直接基于字的模型就可以做,比如基于字的文本分类、问答系统等,早已有人在研究。但是,即便这些模型能够成功,也会因为模型复杂而导致效率下降,因此,很多时候(尤其是生产环境中),我们会寻求更简单、更高效的方案。
什么方案最高效?以文本分类为例,估计最简单高效的方案就是“朴素贝叶斯分类器”了,类似的,比较现代的是FastText,它可以看作是“朴素贝叶斯”的“神经网络版”。要注意,朴素贝叶斯基于一个朴素的假设:特征之间相互独立。这个假设越成立,朴素贝叶斯的效果就越好。然而,对于文本来说,显然上下文紧密联系,这个假设还成立吗?
注意到,当特征之间明显不独立的时候,可以考虑将特征组合之后,使得特征之间的相关性减弱,再用朴素贝叶斯。比如,对于文本,如果以字为特征,则朴素假设显然不成立,如“我喜欢数学”中的“喜”和“欢”、“数”和“学”都明显相关,这时候我们可以考虑将特征进行组合,得到“我/喜欢/数学”,这样三个片段之间的相关性就没有那么强了,因此可以考虑用上述结果。可以发现,这个过程很像分词,或者反过来说,分词的主要目的之一,就是将句子分为若干个相关性比较弱的部分,便于进一步处理。从这个角度来看,分的可能不一定是“词”,也可能是短语、常用搭配等。
说白了,分词就是为了削弱相关性,降低对词序的依赖,这一点,哪怕在深度学习模型中,都是相当重要的。有些模型,不分词但是用CNN,也就是把若干个字组合作为特征来看,这也是通过字的组合来减弱特征间的相关性的体现。
算法大意 #
既然分词是为了削弱相关性,那么我们分词,就是在相关性弱的地方切断了。文章《【中文分词系列】 2. 基于切分的新词发现》其实就是这个意思,只是那里认为,文本的相关性仅由相邻两字(2grams)来决定,这在很多时候都是不合理的,比如“林心如”中的“心如”、“共和国”中的“和国”,凝固度(相关性)都不是很强,容易错切。因此,本文就是在前文的基础上改进,那里只考虑了相邻字的凝固度,这里同时考虑多字的内部的凝固度(ngrams),比如,定义三字的字符串内部凝固度为:
$$\min\left\{\frac{P(abc)}{P(ab)P(c)},\frac{P(abc)}{P(a)P(bc)}\right\}$$
这个定义其实也就是说,要枚举所有可能的切法,因为一个词应该是处处都很“结实”的,4字或以上的字符串凝固度类似定义。一般地,我们只需要考虑到4字(4grams)就好(但是注意,我们依旧是可以切出4字以上的词来的)。
考虑了多字后,我们可以设置比较高的凝固度阈值,同时防止诸如“共和国”之类的词不会被切错,因为考虑三字凝固度,“共和国”就显得相当结实了,所以,这一步就是“宁放过,勿切错”的原则。
但是,“各项”和“项目”这两个词,它们的内部凝固度都很大,因为前面一步是“宁放过,勿切错”,因此这样会导致“各项目”也成词,类似的例子还有“支撑着”、“球队员”、“珠海港”等很多例子。但这些案例在3grams中来看,凝固度是很低的,所以,我们要有一个“回溯”的过程,在前述步骤得到词表后,再过滤一遍词表,过滤的规则就是,如果里边的n字词,不在原来的高凝固度的ngrams中,那么就得“出局”。
所以,考虑ngrams的好处就是,可以较大的互信息阈值情况下,不错切词,同时又排除模凌两可的词。就比如“共和国”,三字互信息很强,两字就很弱了(主要还是因为“和国”不够结实),但是又能保证像“的情况”这种不会被切出来,因为阈值大一点,“的情”和“的情况”都不结实了。
详细的算法 #
完整的算法步骤如下:
第一步,统计:选取某个固定的$n$,统计2grams、3grams、…、ngrams,计算它们的内部凝固度,只保留高于某个阈值的片段,构成一个集合$G$;这一步,可以为2grams、3grams、…、ngrams设置不同的阈值,不一定要相同,因为字数越大,一般来说统计就越不充分,越有可能偏高,所以字数越大,阈值要越高;
第二步,切分:用上述grams对语料进行切分(粗糙的分词),并统计频率。切分的规则是,只要一个片段出现在前一步得到的集合$G$中,这个片段就不切分,比如“各项目”,只要“各项”和“项目”都在$G$中,这时候就算“各项目”不在$G$中,那么“各项目”还是不切分,保留下来;
第三步,回溯:经过第二步,“各项目”会被切出来(因为第二步保证宁放过,不切错)。回溯就是检查,如果它是一个小于等于$n$字的词,那么检测它在不在$G$中,不在就出局;如果它是一个大于$n$字的词,那个检测它每个$n$字片段是不是在$G$中,只要有一个片段不在,就出局。还是以“各项目”为例,回溯就是看看,“各项目”在不在3gram中,不在的话,就得出局。
每一步的补充说明:
1、使用较高的凝固度,但综合考虑多字,是为了更准,比如两字的“共和”不会出现在高凝固度集合中,所以会切开(比如“我一共和三个人去玩”,“共和”就切开了),但三字“共和国”出现在高凝固度集合中,所以“中华人民共和国”的“共和”不会切开;
2、第二步就是根据第一步筛选出来的集合,对句子进行切分(你可以理解为粗糙的分词),然后把“粗糙的分词结果”做统计,注意现在是统计分词结果,跟第一步的凝固度集合筛选没有交集,我们认为虽然这样的分词比较粗糙,但高频的部分还是靠谱的,所以筛选出高频部分;
3、第三步,例如因为“各项”和“项目”都出现高凝固度的片段中,所以第二步我们也不会把“各项目”切开,但我们不希望“各项目”成词,因为“各”跟“项目”的凝固度不高(“各”跟“项”的凝固度高,不代表“各”跟“项目”的凝固度高),所以通过回溯,把“各项目”移除(只需要看一下“各项目”在不在原来统计的高凝固度集合中即可,所以这步计算量是很小的)
代码实现 #
下面给出一个参考的代码实现。首先,为了节约内存,写一个迭代器来逐篇输出文章:
import re
import pymongo
from tqdm import tqdm
import hashlib
db = pymongo.MongoClient().weixin.text_articles
md5 = lambda s: hashlib.md5(s).hexdigest()
def texts():
texts_set = set()
for a in tqdm(db.find(no_cursor_timeout=True).limit(3000000)):
if md5(a['text'].encode('utf-8')) in texts_set:
continue
else:
texts_set.add(md5(a['text'].encode('utf-8')))
for t in re.split(u'[^\u4e00-\u9fa50-9a-zA-Z]+', a['text']):
if t:
yield t
print u'最终计算了%s篇文章' % len(texts_set)
需要解释的是:我的文章存在mongodb中,所以用pymongo读,如果文章存在文件中,做法是类似的。当然,内存足够,而文章不多的话,直接把文章以列表的形式加载到内存中也没有问题;引入hashlib是为了对文章去重;引入正则表达式re是为了预先去掉无意义字符(非中文、非英文、非数字);引入tqdm是为了显示进度。
接着,直接计数:
from collections import defaultdict
import numpy as np
n = 4
min_count = 128
ngrams = defaultdict(int)
for t in texts():
for i in range(len(t)):
for j in range(1, n+1):
if i+j <= len(t):
ngrams[t[i:i+j]] += 1
ngrams = {i:j for i,j in ngrams.iteritems() if j >= min_count}
total = 1.*sum([j for i,j in ngrams.iteritems() if len(i) == 1])
这里的$n$就是需要考虑的最长片段的字数(前面说的ngrams),建议至少设为3,min_count看需求而设。接着就是凝固度的筛选了:
min_proba = {2:5, 3:25, 4:125}
def is_keep(s, min_proba):
if len(s) >= 2:
score = min([total*ngrams[s]/(ngrams[s[:i+1]]*ngrams[s[i+1:]]) for i in range(len(s)-1)])
if score > min_proba[len(s)]:
return True
else:
return False
ngrams_ = set(i for i,j in ngrams.iteritems() if is_keep(i, min_proba))
前文已经说了,可以为不同长度的gram设置不同的阈值,因此用了个字典来制定阈值。个人感觉,阈值成5倍的等比数列比较好,当然,这个还有看数据大小。接着,定义切分函数,并进行切分统计:
def cut(s):
r = np.array([0]*(len(s)-1))
for i in range(len(s)-1):
for j in range(2, n+1):
if s[i:i+j] in ngrams_:
r[i:i+j-1] += 1
w = [s[0]]
for i in range(1, len(s)):
if r[i-1] > 0:
w[-1] += s[i]
else:
w.append(s[i])
return w
words = defaultdict(int)
for t in texts():
for i in cut(t):
words[i] += 1
words = {i:j for i,j in words.iteritems() if j >= min_count}
最后,回溯:
def is_real(s):
if len(s) >= 3:
for i in range(3, n+1):
for j in range(len(s)-i+1):
if s[j:j+i] not in ngrams_:
return False
return True
else:
return True
w = {i:j for i,j in words.iteritems() if is_real(i)}
算法的主要时间花在ngrams的统计,以及最后文本的切分上。
词典共享 #
最后分享从300万微信文章中,用上述算法构建的词库,没有经过任何人工后期处理,既分享给广大网友使用(里边包含了一些微信相关的词语,很多流行的分词工具并没有,还是挺有价值的),也供网友验证效果。
资源下载:300w微信文章做新词发现的词库.zip
转载到请包括本文地址:https://kexue.fm/archives/4256
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Mar. 11, 2017). 《【中文分词系列】 8. 更好的新词发现算法 》[Blog post]. Retrieved from https://kexue.fm/archives/4256
@online{kexuefm-4256,
title={【中文分词系列】 8. 更好的新词发现算法},
author={苏剑林},
year={2017},
month={Mar},
url={\url{https://kexue.fm/archives/4256}},
}
March 12th, 2017
[...]苏剑林博客:http://kexue.fm/archives/4256/[...]
May 12th, 2017
o no ~没想到这篇是我读的最久的一篇,也不知道自己理解没有 感觉def is_real(s): 这个函数的就是再说 如果 别切割的长词中 包含的短词 不在高凝结度的ngrams_中 这个词我们认为不是一个词 因为词间凝结度不够。 但是有个疑问 假设这个词 我们认为是一个词 那么其实 构成他的短词 在意义上 不是词 而只是这个词的组分,应该让他们从ngrams_中pop掉,不知道我的想法对不对,这篇我真的理解了好久好久啊,嗯
嗯,事实上这个步骤对备选的k字词,k不大于n时,是很好解释的~因为我们本来就希望“词”的每一处都很“坚固”,而ngrams_本来就是这样筛选出来的,所以只需要判断它在不在ngrams_中。当然,对于大于n的k字词,这个步骤是值得商榷的,但不过怎样,我这样做也是一种备选方案吧,实测效果相对较好。
July 11th, 2017
您好,在def is_keep()中为什么要乘以total,是为了放大内部的凝固度吗
原始公式是
$$\frac{P(ab)}{P(a)P(b)}$$
而
$$P(ab)=\frac{n(ab)}{N}, P(a)=\frac{n(a)}{N}, P(b)=\frac{n(b)}{N}$$
代入得到
$$\frac{P(ab)}{P(a)P(b)}=\frac{N \times n(ab)}{n(a) n(b)}$$
July 12th, 2017
为了读懂你那段代码,我不下看了十遍你的
文字描述,但仍然没太明白,而py什么语言
语法太难懂,焦虑中啊
请问哪个语法比python更容易懂?
September 11th, 2017
total = 1.*sum([j for i,j in ngrams.iteritems() if len(i) == 1])
请问这一行为何是
len(i) == 1
这岂不是所有1-gram的counts?
1gram 2gram 3gram的总数几乎都相等的呀
为什么def is_real里,len(s) >= 3: 不是说大于等于n字的词吗?
哪里说了只对大于等于n字的词进行操作?
October 11th, 2017
你好,像 金门大桥 为什么会被分为 门大桥 ?
具体点?
November 14th, 2017
博主你好, 这个思路挺不错的。我用小说的数据集测试了一下。效果不错。但是有一个问题。内存爆炸。dict 那里,10g 的数据集 内存已经去到 60g 了。如果用 based on disk的
比如 shelf 和 SqliteDict 来替换 dict,速度又很慢。相差有200倍左右的速度。 用 redis 做 backend 速度也很慢。
这种方法内存是硬伤,我还没有好的思路解决,编程功底略弱~
November 16th, 2017
为什么阈值成5倍的等比数列比较好?
实验结果,暂无理论解释。
December 11th, 2017
楼主您好,请问total代表什么意思呢?为什么是词长为1的词的总出现次数?
December 15th, 2017
楼主你好,怎样确保能切除长度大于n(比如你设置的n=4)的词
“切除长度大于n的词”是什么意思?