重新写了之前的新词发现算法:更快更好的新词发现
By 苏剑林 | 2019-09-09 | 95945位读者 |新词发现是NLP的基础任务之一,主要是希望通过无监督发掘一些语言特征(主要是统计特征),来判断一批语料中哪些字符片段可能是一个新词。本站也多次围绕“新词发现”这个话题写过文章,比如:
在这些文章之中,笔者觉得理论最漂亮的是《基于语言模型的无监督分词》,而作为新词发现算法来说综合性能比较好的应该是《更好的新词发现算法》,本文就是复现这篇文章的新词发现算法。
背景 #
当时写《【中文分词系列】 8. 更好的新词发现算法》的时候,笔者就已经给出过一个基本的实现并且做过验证了。然而,当初的版本是纯Python写的,并且当时只是想着快速验证效果,所以写得相当随意,会存在比较严重的效率问题。最近把这事想起来了,觉得不想浪费这个算法,所以就重写了一遍,利用了一些工具和技巧,把速度提上去了。
顺便说一下,“新词发现”是一个比较通俗的叫法,更准确的叫法应该是“无监督构建词库”,因为原则上它能完整地构建一个词库出来,而不仅仅是“新词”。当然,你可以将它跟常用词库进行对比,删掉常见词,就可以得到新词了。
细节 #
主要改动如下:
1、使用了语言模型工具kenlm的count_ngrams程序来统计ngram。由于kenlm是用C++写的,速度有保证,并且它还做了优化,所以对内存很友好。
2、在第二次遍历词库以得到候选词的时候,使用了Trie树结构来加速搜索字符串是否出现过某个ngram。Trie树或者其变种基本上是所有基于词典的分词工具的标配,就是因为它可以加快搜索字符串中是否出现过词典中的词。
使用 #
本次开源地址位于:
注意这个脚本应该只能在Linux系统下使用。如果你想要在Windows下使用,应该需要做些修改,具体做哪些修改,我也不知道,请自行解决。注意算法本身理论上能适用于任意语言,但本文的实现原则上只适用于以“字”为基本单位的语言。
在决定使用该库之前,还望读者能花点时间读一下《【中文分词系列】 8. 更好的新词发现算法》,对算法步骤有个基本了解,以便更好地弄懂下述使用步骤。
Github中核心的脚本是word_discovery.py,它包含了完整的实现和使用例子。下面我们简单梳理一下这个例子。
首先,写一个语料的生成器,逐句返回语料:
import re
import glob
# 语料生成器,并且初步预处理语料
# 这个生成器例子的具体含义不重要,只需要知道它就是逐句地把文本yield出来就行了
def text_generator():
txts = glob.glob('/root/thuctc/THUCNews/*/*.txt')
for txt in txts:
d = open(txt).read()
d = d.decode('utf-8').replace(u'\u3000', ' ').strip()
yield re.sub(u'[^\u4e00-\u9fa50-9a-zA-Z ]+', '\n', d)
读者不需要看懂我这个生成器究竟在做什么,只需要知道这个生成器就是在逐句地把原始语料给yield
出来就行了。如果你还不懂生成器怎么写,请自己去学。请不要在此文章内讨论“语料格式应该是怎样的”、“我要怎么改才能适用我的语料”这样的问题,谢谢。
顺便提一下,因为是无监督训练,语料一般都是越大越好,几百M到几个G都可以,但其实如果你只要几M的语料(比如一部小说),也可以直接测试,也能看到基本的效果(但可能要修改下面的参数)。
有了生成器之后,配置一些参数,然后就可以逐个步骤执行了:
min_count = 32
order = 4
corpus_file = 'thucnews.corpus' # 语料保存的文件名
vocab_file = 'thucnews.chars' # 字符集保存的文件名
ngram_file = 'thucnews.ngrams' # ngram集保存的文件名
output_file = 'thucnews.vocab' # 最后导出的词表文件名
write_corpus(text_generator(), corpus_file) # 将语料转存为文本
count_ngrams(corpus_file, order, vocab_file, ngram_file) # 用Kenlm统计ngram
ngrams = KenlmNgrams(vocab_file, ngram_file, order, min_count) # 加载ngram
ngrams = filter_ngrams(ngrams.ngrams, ngrams.total, [0, 2, 4, 6]) # 过滤ngram
注意,kenlm需要一个以空格分词的、纯文本格式的语料作为输入,而write_corpus
函数就是帮我们做这件事情的,然后count_ngrams
就是调用kenlm的count_ngrams程序来统计ngram。所以,你需要自行编译好kenlm,并把它的count_ngrams放到跟word_discovery.py同一目录下。如果有Linux环境,那kenlm的编译相当简单,笔者之前在这里也讨论过kenlm,可以参考。count_ngrams
执行完毕后,结果会保存在一个二进制文件中,而KenlmNgrams
就是读取这个文件的,如果你输入的语料比较大,那么这一步会需要比较大的内存。最后filter_ngrams
就是过滤ngram了,[0, 2, 4, 6]是互信息的阈值,其中第一个0无意义,仅填充用,而2, 4, 6分别是2gram、3gram、4gram的互信息阈值,基本上单调递增比较好。
至此,我们完成了所有的“准备工作”,现在可以着手构建词库了。首先构建一个ngram的Trie树,然后用这个Trie树就可以做一个基本的“预分词”:
ngtrie = SimpleTrie() # 构建ngram的Trie树
for w in Progress(ngrams, 100000, desc=u'build ngram trie'):
_ = ngtrie.add_word(w)
candidates = {} # 得到候选词
for t in Progress(text_generator(), 1000, desc='discovering words'):
for w in ngtrie.tokenize(t): # 预分词
candidates[w] = candidates.get(w, 0) + 1
这个预分词的过程在《【中文分词系列】 8. 更好的新词发现算法》就介绍过了,总之有点像最大匹配,由ngram片段连接成尽可能长的候选词。
最后,再把候选词过滤一下,就可以把词库保存下来了:
# 频数过滤
candidates = {i: j for i, j in candidates.items() if j >= min_count}
# 互信息过滤(回溯)
candidates = filter_vocab(candidates, ngrams, order)
# 输出结果文件
with open(output_file, 'w') as f:
for i, j in sorted(candidates.items(), key=lambda s: -s[1]):
s = '%s %s\n' % (i.encode('utf-8'), j)
f.write(s)
评测 #
读者之前老说我写的这些算法没有标准评测,这次我就做了一个简单的评测,评测脚本是evaluate.py。
具体来说,以THUCNews为基础语料,就用上述脚本构建一个词库(总用时约40分钟),只保留前5万个词,用结巴分词加载这个5万词的词库(不用它自带的词库,并且关闭新词发现功能),这就构成了一个基于无监督词库的分词工具,然后用这个分词工具去分bakeoff 2005提供的测试集,并且还是用它的测试脚本评测,最终在PKU这个测试集上得分是:
$$\begin{array}{c|c|c}
\hline
\text{RECALL} & \text{PRECISION} & \text{F1}\\
\hline
0.777 & 0.711 & 0.742\\
\hline\end{array}$$
也就是说能做到0.742的F1。这是个什么水平呢?ICLR 2019有一篇文章叫做《Unsupervised Word Discovery with Segmental Neural Language Models》,里边提到了它在同一个测试集上的结果为F1=0.731,照这样看这个算法的结果还不差于顶会的最优结果呢~读者可以自行下载THUCNews语料来完整地复现上述结果。
另外,更多的语料可以实现更好的效果。这是我从500万篇微信公众号文章(保存为文本之后是20多G)提取出来的词库:wx.vocab.zip,供读者有需使用。保留这个词库的前5万词,然后进行同样的评测,F1明显超过了顶会的结果:
$$\begin{array}{c|c|c}
\hline
\text{RECALL} & \text{PRECISION} & \text{F1}\\
\hline
0.799 & 0.734 & 0.765\\
\hline\end{array}$$
(注:这里是为了给效果提供一个直观感知,比较可能是不公平的,因为我不确定这篇论文中的训练集用了哪些语料。但我感觉在相同时间内本文算法会优于论文的算法,因为直觉论文的算法训练起来会很慢。作者也没有开源,所以有不少不确定之处,如有错谬,请读者指正。)
总结 #
本文复现了笔者之前提出了新词发现(词库构建)算法,主要是做了速度上的优化,然后做了做简单的效果评测。但具体效果读者还是得在使用中慢慢调试了。
祝大家使用愉快~Enjoy it!
转载到请包括本文地址:https://kexue.fm/archives/6920
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Sep. 09, 2019). 《重新写了之前的新词发现算法:更快更好的新词发现 》[Blog post]. Retrieved from https://kexue.fm/archives/6920
@online{kexuefm-6920,
title={重新写了之前的新词发现算法:更快更好的新词发现},
author={苏剑林},
year={2019},
month={Sep},
url={\url{https://kexue.fm/archives/6920}},
}
October 18th, 2019
这个结果可以发论文了呀
November 13th, 2019
您好,我在复现这个代码的过程,发现得到的词库都是一字词的词库,有点不懂是什么意思,以及文中的关闭新词发现是什么意思呀
你这样说我也不懂是什么意思。
至于关闭新词发现,你用过结巴分词并看过它的api介绍的话就知道了。
你好,我做了得了同样的结果,都是一字词,请问现在有解决吗,同时尝试了更换PMI针对不同长度的取值,甚至都调成了0,还是得到一字词,是不是哪里有错误呢?同时也尝试了python2的代码,再使用kenlm进行读取read_grams的时候出现了下标越界,这个再python3下是正常运行,但是得到的都是一字词,请问有解决吗?
那你就想办法跑通python2的。
因为我这里非常正常,所以也就没有“解决”、“修复”的说法了。
刚更新了python3支持。目前在python2.7和python3.5下均测试通过。
November 14th, 2019
不好意思,我是个萌新,所以理解的不是很透彻。
就是thucnews.vocab 生成得到的都是
他 6271
家 6089
以 6084
...这样的一字的词,不知道是不是我漏掉了什么东西
对于您说的结巴分词,我去了解一下,多谢指导
望解答,嘻嘻嘻
我不管你是什么,请把 https://kexue.fm/archives/4256 和本文读完并理解后再用本代码。
如果单字词比例过大,可以考虑将下面的[0, 2, 4, 6]都调小一点,或者增加语料。
ngrams = filter_ngrams(ngrams.ngrams, ngrams.total, [0, 2, 4, 6]) # 过滤ngram
June 11th, 2020
ICLR 2019那篇论文的被喷的很惨,倒是有个类似工作来自 Unsupervised Neural Word Segmentation for Chinese via Segmental Language Modeling. EMNLP 2018, 有代码实现,可以参考。
倒是速度肯定是个问题。
我去,这命名也太像了,搞糊涂了...感谢分享和澄清哈。
June 12th, 2020
undefined symbol: _ZN5boost15program_options3argB5cxx11E
出现这种问题应该怎么解决?
自己编译ngram_count
October 8th, 2021
你好,使用 kenlm 是不是没办法计算左右熵?
本文的方法不需要计算左右熵。
如果是使用kenlm的话,可以方便地计算右熵,但是左熵确实不大方便计算。不过我这里也没有直接用kenlm,而只是用了kenlm的ngram_count帮忙统计ngram,统计ngram后可以自己读取结果来计算左右熵。
March 11th, 2022
本篇文章计算出来的ngrams跟《【中文分词系列】 8. 更好的新词发现算法》计算出来的ngrams好像不一致,词频也不同
不同的统计代码,可能细节处理上不大一样。
March 21st, 2022
请教一下,为什么ngrams统计出来的词频跟ctrl+f查找计数不同呢?因为我只想计算凝固度,已经把min_count设置为1了,但仍有很多片段没有加入到ngrams
这个我也不知道,没读过ngrams的源码~
October 20th, 2022
苏神,kenlm count_ngrams统计词频,order为n,得到结果的文件是n-gram,其中也会存在部分1-gram,2-gram,...,(n-1)gram的情况。要准确计算1-gram,2-gram,...,n-gram要通过设置order=1,..,n去统计。
因此代码中循环统计n-gram来获得1-gram,..,n-gram的次数应该是不对的。第一重复计算1-gram,2-gram。第二而且也少了其它片段。这一点我不同gram的结果文档来确认。
从应用的角度来看,不需要那么准确的计算~
April 10th, 2023
请教:评测脚本 evaluate.py 中调用了当前目录下名为 score 的程序,程序显然用于计算 F1;但它并未出现在 GitHub 仓库里,也不属于 kenlm。
没事了,它属于 SIGHAN Bakeoff 2005,不好意思。