【中文分词系列】 2. 基于切分的新词发现
By 苏剑林 | 2016-08-18 | 123037位读者 |上一篇文章讲的是基于词典和AC自动机的快速分词。基于词典的分词有一个明显的优点,就是便于维护,容易适应领域。如果迁移到新的领域,那么只需要添加对应的领域新词,就可以实现较好地分词。当然,好的、适应领域的词典是否容易获得,这还得具体情况具体分析。本文要讨论的就是新词发现这一部分的内容。
这部分内容在去年的文章《新词发现的信息熵方法与实现》已经讨论过了,算法是来源于matrix67的文章《互联网时代的社会语言学:基于SNS的文本数据挖掘》。在那篇文章中,主要利用了三个指标——频数、凝固度(取对数之后就是我们所说的互信息熵)、自由度(边界熵)——来判断一个片段是否成词。如果真的动手去实现过这个算法的话,那么会发现有一系列的难度。首先,为了得到$n$字词,就需要找出$1\sim n$字的切片,然后分别做计算,这对于$n$比较大时,是件痛苦的时间;其次,最最痛苦的事情是边界熵的计算,边界熵要对每一个片段就行分组统计,然后再计算,这个工作量的很大的。本文提供了一种方案,可以使得新词发现的计算量大大降低。
算法 #
回顾matrix67的算法做新词发现的过程,应该可以认识到,新词发现做的事情,就是根据语料判断给定片段是不是真的成词了,而所谓成词,就是它相对独立,不可切分。那为什么不反过来呢?为什么我们不去找一下哪些片段不能成词呢?根据前面的说法,我们说片段的凝固度大于一定程度时,片段可能成词(接下来要去考虑它的边界熵)。那这不就是说,如果片段的凝固度低于一定程度时,这个片段就不可能成词了吗?那么我们就可以在原来的语料中把它断开了。
我们可以做适当的简化,如果$a,b$是语料中相邻两字,那么可以统计$(a,b)$成对出现的次数$\#(a,b)$,继而估计它的频率$P(a,b)$,然后我们分别统计$a,b$出现的次数$\#a,\#b$,然后估计它们的频率$P(a),P(b)$,如果
$$\frac{P(a,b)}{P(a)P(b)} < \alpha \quad (\alpha\text{是给定的大于1的阈值})$$
那么就应该在原来的语料中把这两个字断开。这个操作本质上就是——我们根据这个指标,对原始语料进行初步的分词!在完成初步分词后,我们就可以统计词频了,然后根据词频来筛选。
对比matrix67文章中的三个指标,我们现在只用了两个:频数和凝固度,去掉了计算量最大的边界熵,而且,在计算凝固度时,我们只需要计算二字片段的凝固度,省掉了更多字片段的凝固度计算,但是,由于我们是基于切分的方式做的,因此我们少了很多计算量,但理论上却能够得任意长度的词语!
实现 #
看上去很完美——计算量少了,功能更强了。实际效果如何呢?跟matrix67文章中的算法的结果有多少出入?这个还得真的自己试过才能说了算。不过,我用了30万篇微信公众号的文章(约1GB)进行实验,发现效果是可以让人满意的,用时10分钟左右。下面给出实现代码,很短,纯Python,并且不用第三方库的支持,而且内存非常友好,这里的texts可以是一个列表,也可以是一个迭代器(每次返回一篇文章),配合tqdm库,可以方便地显示进度。最后,在统计时,用到了加$\gamma$平滑法,以缓解出现不合理的词。以前做这些统计计算的时候,不用想就用Pandas了,最近尝试了一下Python原生的一些库,发现也相当好用呢~
import pymongo
db = pymongo.MongoClient().baike.items
def texts():
for a in db.find(no_cursor_timeout=True).limit(1000000):
yield a['content']
from collections import defaultdict #defaultdict是经过封装的dict,它能够让我们设定默认值
from tqdm import tqdm #tqdm是一个非常易用的用来显示进度的库
from math import log
import re
class Find_Words:
def __init__(self, min_count=10, min_pmi=0):
self.min_count = min_count
self.min_pmi = min_pmi
self.chars, self.pairs = defaultdict(int), defaultdict(int) #如果键不存在,那么就用int函数
#初始化一个值,int()的默认结果为0
self.total = 0.
def text_filter(self, texts): #预切断句子,以免得到太多无意义(不是中文、英文、数字)的字符串
for a in tqdm(texts):
for t in re.split(u'[^\u4e00-\u9fa50-9a-zA-Z]+', a): #这个正则表达式匹配的是任意非中文、
#非英文、非数字,因此它的意思就是用任
#意非中文、非英文、非数字的字符断开句子
if t:
yield t
def count(self, texts): #计数函数,计算单字出现频数、相邻两字出现的频数
for text in self.text_filter(texts):
self.chars[text[0]] += 1
for i in range(len(text)-1):
self.chars[text[i+1]] += 1
self.pairs[text[i:i+2]] += 1
self.total += 1
self.chars = {i:j for i,j in self.chars.items() if j >= self.min_count} #最少频数过滤
self.pairs = {i:j for i,j in self.pairs.items() if j >= self.min_count} #最少频数过滤
self.strong_segments = set()
for i,j in self.pairs.items(): #根据互信息找出比较“密切”的邻字
_ = log(self.total*j/(self.chars[i[0]]*self.chars[i[1]]))
if _ >= self.min_pmi:
self.strong_segments.add(i)
def find_words(self, texts): #根据前述结果来找词语
self.words = defaultdict(int)
for text in self.text_filter(texts):
s = text[0]
for i in range(len(text)-1):
if text[i:i+2] in self.strong_segments: #如果比较“密切”则不断开
s += text[i+1]
else:
self.words[s] += 1 #否则断开,前述片段作为一个词来统计
s = text[i+1]
self.words[s] += 1 #最后一个“词”
self.words = {i:j for i,j in self.words.items() if j >= self.min_count} #最后再次根据频数过滤
fw = Find_Words(16, 1)
fw.count(texts())
fw.find_words(texts())
import pandas as pd
words = pd.Series(fw.words).sort_values(ascending=False)
Python流式读取SQL数据的参考代码:
from sqlalchemy import *
def sql_data_generator():
db = create_engine('mysql+pymysql://user:password@123.456.789.123/yourdatabase?charset=utf8')
result = db.execution_options(stream_results=True).execute(text('select content from articles'))
for t in result:
yield t[0]
分析 #
当然,这个算法不能说完全没有缺点,还是有些问题值得探讨的。一般情况下,为了得到更细粒度的词语(避免分出太多无效的长词),我们可以选择较大的$\alpha$,比如$\alpha=10$,但是这带来一个问题:一个词语中相邻两个字的凝固度不一定很大。一个典型的例子是“共和国”,“和”跟“国”都是很频繁的字,“和国”两个字的凝固度并不高(在微信文本中大概为3左右),如果$\alpha$太大就会导致切错了这个词语(事实上,是“共和”跟“国”的凝固度高),这些例子还有很多,比如“林心如”的“心如”凝固度就不大(当然,如果语料来源于娱乐圈,那又另当别论)。而如果设置$\alpha=1$,则需要更大的语料库才能使得词库完备起来。这是在使用本算法时需要仔细考虑的。
微信词典 #
最后分享一份我从最近的30万微信公众号文章(1G左右, 3亿多字)中提取的一份词表,设置了最小凝固度为1,最小频数为100。从表中也可以发现,跟微信具有明显联系的词语都已经被提取出来,并且,这是最新的公众号文章,因此,最近的热点——奥运、王宝强——相关的词语也被提取出来了。
微信词典:dict.txt
参考链接 #
《非主流自然语言处理——遗忘算法系列(二):大规模语料词库生成》:http://www.52nlp.cn/forgetnlp2
转载到请包括本文地址:https://kexue.fm/archives/3913
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Aug. 18, 2016). 《【中文分词系列】 2. 基于切分的新词发现 》[Blog post]. Retrieved from https://kexue.fm/archives/3913
@online{kexuefm-3913,
title={【中文分词系列】 2. 基于切分的新词发现},
author={苏剑林},
year={2016},
month={Aug},
url={\url{https://kexue.fm/archives/3913}},
}
September 24th, 2016
proba = {i:1.0*pairs_count[i]*len(s)/(chars_count[i[0]]*chars_count[i[0]]) for i in pairs_count.iterkeys()}
这里分母是“chars_count[i[0]]*chars_count[i[1]] ”吧?
是的,感谢指出,已修正。
May 9th, 2017
发现在def count 里面 self.total += 1 这里的total是两个字词的总数 而在其他篇 博主定义的是 单个词的总数 不过不影响,再算self.min_proba = min_proba 相应提高就好了
另外,self.strong_segments = {i: self.total*j/(self.chars[i[0]]*self.chars[i[1]]) for i,j in self.pairs.iteritems() if j >= self.min_count} 后面的 if j >= self.min_proba 应该不用了吧?前面那个已经过滤掉 不符合条件的item了
你是对的,已经去掉冗余部分。谢谢~
May 9th, 2017
和 那篇左右信息熵以及频数和凝固度分词 一起读的话 觉得博主思路好开阔~~~正向逆向求解~
这思路主要来源于憨叔,也就是文末的链接。
June 9th, 2017
连接mysql数据库的时候,怎么写迭代器啊?作者能不能给个例子?我想用pyhon写一个迭代器每次返回数据库特定字段的一条内容。比如:mysql字段content下的数据一条一条返回。刚学python不久,希望作者给个实例。
用sqlalchemy会很方便,大概是这样
from sqlalchemy import *
def sql_data_generator():
db = create_engine('mysql+pymysql://user:password@123.456.789.123/yourdatabase?charset=utf8')
result = db.execution_options(stream_results=True).execute(text('select content from articles'))
for t in result:
yield t[0]
已经放到正文中。
December 7th, 2017
请问作者是怎么运行这个代码的
May 17th, 2018
你好,我遇到一个问题:我是把若干篇文章放在一个list里,但得到的词全是些字符数字的,希望指教;
结果如下:
1 100639
2 63176
0 52702
3 25464
5 22115
4 18482
7 17308
6 17304
8 13912
9 11412
P 10746
E 7444
......
代码如下:
text_list = []
for index, row in df.iterrows():
# orgnamedisc = row['orgnamedisc']
# title = row['title']
conclusion = row['conclusion']
text_list.append(conclusion)
fw = Find_Words(16, 10)
fw.count(text_list)
fw.find_words(text_list)
确认你的text_list是没问题的了吗?有没有打印过前面几个出来看看?
July 18th, 2018
有一個想法,跟博主分享:在用n-gram算法(該系列第八篇)時,發現內存不夠(我的電腦是垃圾哈哈哈),所以想到能否用一個介於本文和第八篇之間的方法:當發現AB凝聚度高,應該連在一起,而CD不應連在一起之後,計算所有A+B+Y 和X+A+B的3-gram內部凝聚度,而所有X+C+D 和 C+D+Y 的3-gram就跳過。
這樣似乎可以節省空間,而且似乎比本文的算法更合理。(畢竟AB,BC的凝聚度都很高,並不代表ABC應當成詞,這一算法也許precision會比本文高)。
缺點就是對於長詞的recall可能低於第八篇的n-gram算法:比如‘買不起’中,‘買不’和‘不起’的凝聚度大概都不會很高,但‘買不/起’和‘買/不起’的凝聚度會高出很多。但在這一算法中,‘買不’和‘不起’從一開始就不會連起來,‘買不起’不會被當成一個可能的3-gram來計算。
博主意下如何?
你这种操作是不合理的。根据CD不能确定CD绝对应该断开。比如“心如”的互信息其实很低,一般情况下确实可以断开,但是“林心如”的互信息就很高,这时候不能断开。
你构思的这种算法,实际上跟本文的算法效果类似,而第8篇文章,就是为了解决本文算法的不足,是为了避免错切。
我的意思是,”不切开“的阈值低于”成词“的threshold。比如:假设成词的凝聚度必须要2.5,而不切开只需要达到1.5。假设”心如“的凝聚度1.8,那么它不会被切开,然后继续找左右临字,发现”林/心如“的凝聚度凝聚度2.6, 可以成词。 但是假如”心如“和左右临字都没有构成足够解释的词语,那它会被filter掉,因为达不到成词的凝聚度。
我的这个想法是因为在实现第八篇算法的时候内存不足,不得已的一种做法。请问博主有什么方法可以减少第八篇的内存问题呢?
另外,对于第八篇我有一个疑问:在’回溯‘的时候,假如一个长词ABCDE被过滤掉了,那么是不是它的substring,比如AB, BCD都不再考虑成词的可能性?这样会不会低recall?
谢谢博主
March 31st, 2019
dict.txt文件打开后出现乱码,能重新共享一份吗
请学会处理编码问题再做文本处理。
July 15th, 2020
请问baike是PyMongo自带的吗?还是您自己导进去的?我没有装MongoDB,请问可以在哪里可以找到这个数据集?
1、不是pymongo自带的;是自己爬的百度百科,不公开;
2、网上有很多开源的通用语料,随便搜索一下就可以下载很多了。
October 10th, 2020
请问$\gamma$平滑法是体现在哪里?是过滤 self.min_count 吗?
参考代码好像没有$\gamma$平滑,也许忘记加了。