新词发现的信息熵方法与实现
By 苏剑林 | 2015-10-26 | 107620位读者 |在本博客的前面文章中,已经简单提到过中文文本处理与挖掘的问题了,中文数据挖掘与英语同类问题中最大的差别是,中文没有空格,如果要较好地完成语言任务,首先得分词。目前流行的分词方法都是基于词库的,然而重要的问题就来了:词库哪里来?人工可以把一些常用的词语收集到词库中,然而这却应付不了层出不穷的新词,尤其是网络新词等——而这往往是语言任务的关键地方。因此,中文语言处理很核心的一个任务就是完善新词发现算法。
新词发现说的就是不加入任何先验素材,直接从大规模的语料库中,自动发现可能成词的语言片段。前两天我去小虾的公司膜拜,并且试着加入了他们的一个开发项目中,主要任务就是网络文章处理。因此,补习了一下新词发现的算法知识,参考了Matrix67.com的文章《互联网时代的社会语言学:基于SNS的文本数据挖掘》,尤其是里边的信息熵思想,并且根据他的思路,用Python写了个简单的脚本。
程序的算法完全来自于Matrix67.com的文章,感兴趣的读者可以移步到他的博客仔细阅读,相信会获益匪浅的。在此主要简单讨论一下代码的实现思路。具体程序请见文末。为了处理更大的文本,尽量不用Python内置的循环,尽可能用到第三方库所带的函数,如Numpy、Pandas。由于涉及到词语数量较多,要做好索引工作,比如词语先要排好序,会大大提高检索的速度。
下面是用本文程序对金庸小说《天龙八部》世纪新修版(txt电子版2.5M)做的新词发现结果(部分),用时20秒左右,感觉结果还不错,主要角色的人名都自动发现了。当然因为代码比较短,没做什么特殊处理,还有很多可以改善的地方。
段誉,3535
什么,2537
萧峰,1897
自己,1730
虚竹,1671
乔峰,1243
阿紫,1157
武功,1109
阿朱,1106
姑娘,1047
笑道,992
咱们,832
师父,805
如何,771
如此,682
大理,665
丐帮,645
突然,640
王语嫣,920
慕容复,900
段正淳,780
木婉清,751
鸠摩智,600
游坦之,515
丁春秋,463
有什么,460
包不同,447
少林寺,379
保定帝,344
马夫人,324
段延庆,302
乌老大,294
不由得,275
王夫人,265
为什么,258
只听得,255
是什么,237
云中鹤,236
那少女,234
巴天石,230
王姑娘,227
忽听得,221
钟万仇,218
少林派,216
叶二娘,216
朱丹臣,213
风波恶,209
契丹人,208
南海鳄神,485
慕容公子,230
耶律洪基,189
六脉神剑,168
站起身来,116
带头大哥,103
这几句话,100
点了点头,96
星宿老怪,92
神仙姊姊,90
吃了一惊,87
大吃一惊,86
慕容先生,86
又有什么,86
完整代码(3.x版本,简单修改可以用于2.x版本,主要是输出函数不同而已):
import numpy as np
import pandas as pd
import re
from numpy import log,min
f = open('data.txt', 'r') #读取文章
s = f.read() #读取为一个字符串
#定义要去掉的标点字
drop_dict = [u',', u'\n', u'。', u'、', u':', u'(', u')', u'[', u']', u'.', u',', u' ', u'\u3000', u'”', u'“', u'?', u'?', u'!', u'‘', u'’', u'…']
for i in drop_dict: #去掉标点字
s = s.replace(i, '')
#为了方便调用,自定义了一个正则表达式的词典
myre = {2:'(..)', 3:'(...)', 4:'(....)', 5:'(.....)', 6:'(......)', 7:'(.......)'}
min_count = 10 #录取词语最小出现次数
min_support = 30 #录取词语最低支持度,1代表着随机组合
min_s = 3 #录取词语最低信息熵,越大说明越有可能独立成词
max_sep = 4 #候选词语的最大字数
t=[] #保存结果用。
t.append(pd.Series(list(s)).value_counts()) #逐字统计
tsum = t[0].sum() #统计总字数
rt = [] #保存结果用
for m in range(2, max_sep+1):
print(u'正在生成%s字词...'%m)
t.append([])
for i in range(m): #生成所有可能的m字词
t[m-1] = t[m-1] + re.findall(myre[m], s[i:])
t[m-1] = pd.Series(t[m-1]).value_counts() #逐词统计
t[m-1] = t[m-1][t[m-1] > min_count] #最小次数筛选
tt = t[m-1][:]
for k in range(m-1):
qq = np.array(list(map(lambda ms: tsum*t[m-1][ms]/t[m-2-k][ms[:m-1-k]]/t[k][ms[m-1-k:]], tt.index))) > min_support #最小支持度筛选。
tt = tt[qq]
rt.append(tt.index)
def cal_S(sl): #信息熵计算函数
return -((sl/sl.sum()).apply(log)*sl/sl.sum()).sum()
for i in range(2, max_sep+1):
print(u'正在进行%s字词的最大熵筛选(%s)...'%(i, len(rt[i-2])))
pp = [] #保存所有的左右邻结果
for j in range(i+2):
pp = pp + re.findall('(.)%s(.)'%myre[i], s[j:])
pp = pd.DataFrame(pp).set_index(1).sort_index() #先排序,这个很重要,可以加快检索速度
index = np.sort(np.intersect1d(rt[i-2], pp.index)) #作交集
#下面两句分别是左邻和右邻信息熵筛选
index = index[np.array(list(map(lambda s: cal_S(pd.Series(pp[0][s]).value_counts()), index))) > min_s]
rt[i-2] = index[np.array(list(map(lambda s: cal_S(pd.Series(pp[2][s]).value_counts()), index))) > min_s]
#下面都是输出前处理
for i in range(len(rt)):
t[i+1] = t[i+1][rt[i]]
t[i+1].sort(ascending = False)
#保存结果并输出
pd.DataFrame(pd.concat(t[1:])).to_csv('result.txt', header = False)
转载到请包括本文地址:https://kexue.fm/archives/3491
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Oct. 26, 2015). 《新词发现的信息熵方法与实现 》[Blog post]. Retrieved from https://kexue.fm/archives/3491
@online{kexuefm-3491,
title={新词发现的信息熵方法与实现},
author={苏剑林},
year={2015},
month={Oct},
url={\url{https://kexue.fm/archives/3491}},
}
October 28th, 2015
如果楼主没读过《数学之美》的话,推荐楼主读一下吴军老师的《数学之美》,里面也有谈到最大熵思想。
谢谢您的推荐。这本书我以前就已经听说过了,一直没机会拜读。今天查看了一下,才发现原来也是跟自然语言处理相关的,看上去确实值得一读。我会抽时间看一下的。再次感谢!
September 22nd, 2016
你好!
请问:min_support = 30 #录取词语最低支持度,1代表着随机组合
这个最低支持度怎么理解?
请问这个问题是怎么理解的?最低支持度?
这个最低支持度,应该是PMI的最小值
July 26th, 2017
for m in range(2, max_sep+1):
print(u'正在生成%s字词...'%m)
t.append([])
for i in range(m): #生成所有可能的m字词
t[m-1] = t[m-1] + re.findall(myre[m], s[i:])
这部分for i in range(m): #生成所有可能的m字词
t[m-1] = t[m-1] + re.findall(myre[m], s[i:])
这里不应该是range(m) 吧?应该是range(len(len(s))).
你这么写是没有从头取到尾的。
November 14th, 2017
t[i+1].sort(ascending = False)
#保存结果并输出
pd.DataFrame(pd.concat(t[1:])).to_csv('result.txt', header = False)
为什么我这报 没有sort 函数,改成sorted() 后再出现TypeError: cannot concatenate object of type ""; only pd.Series, pd.DataFrame, and pd.Panel (deprecated) objs are valid
这是什么情况啊
请查看pandas的官方帮助文档,sort函数后来已经改为sort_values
September 21st, 2018
大神,请问,如果我要对特定领域的文本抽取特定领域的专有名词,为了达到最理想的效果,如何一步步调整min_count
min_support
min_s
max_sep
,这些参数的选择和调整你有什么经验和文献可以分享一下吗?谢谢。
你在做那个电力专业词抽取?没有好的标准,你理解各参数的含义后会可以慢慢调整了,而且抽取出词表后还要和平行语料的词典对比。
http://www.matrix67.com/blog/archives/5044
这里边可以稍微参考一下。
哈哈,没错,正是在做电力专业词抽取。这个任务很特殊,不像一般的分类这些是有监督,我们可以通过log_loss 或者 F1 这些指标来分辨当前算法和参数是否更好。
还是有两个疑惑:
1. 这种无监督问题,难道我调整完参数只能通过线上分数才能知道我这个算法的优劣?
2. 你提到和平行语料的词典对比,这个是个什么操作?具体怎么做?貌似 matrix67 这篇文章中没有提到吧。能否给一些参考的链接?
再次感谢!!!
1. 无监督问题只能通过提交得到答案(鬼知道官方对专业词汇的定义是什么?)
2. “和平行语料对比”已经够清晰了,就是同样的算法在比赛语料做一次,在平行语料做一次,然后就可以对比出比赛语料中比较突出的词汇。matrix67文章提供了一种对比算法。
September 29th, 2018
您的47行好像有问题:
pp = [] #保存所有的左右邻结果
for j in range(i):
pp = pp + re.findall('(.)%s(.)'%myre[i], s[j:])
用python3做实验如下:
>>> a = 'abcdefghijklmnopqrstuvwxyz'
>>> b = re.findall('(.)(..)(.)',a)
>>> print(b)
[('a', 'bc', 'd'), ('e', 'fg', 'h'), ('i', 'jk', 'l'), ('m', 'no', 'p'), ('q', 'rs', 't'), ('u', 'vw', 'x')]
>>> b = re.findall('(.)(..)(.)',a[1:])
>>> print(b)
[('b', 'cd', 'e'), ('f', 'gh', 'i'), ('j', 'kl', 'm'), ('n', 'op', 'q'), ('r', 'st', 'u'), ('v', 'wx', 'y')]
我觉得您需要获得的应该形如:
[('a', 'bc', 'd'), ('b', 'cd', 'e'), ('c', 'de', 'f'), ('d', 'ef', 'g'), ('e', 'fg', 'h'), ...]
您的代码是分别获取a[0:]和a[1:],然后将它们合并,从这里看似乎不够。
谢谢,你是对的,应该要加上2
^_^
在苏神的代码里面是没有问题的,你这里需要修改i,是因为你要找的正则表达式比较特殊吧
October 7th, 2020
您好!
原文中对“凝合程度”的定义是:
“令 p(x) 为文本片段 x 在整个语料中出现的概率,那么我们定义“电影院”的凝合程度就是 p(电影院) 与 p(电) · p(影院) 比值和 p(电影院) 与 p(电影) · p(院) 的比值中的较小值,“的电影”的凝合程度则是 p(的电影) 分别除以 p(的) · p(电影) 和 p(的电) · p(影) 所得的商的较小值。”
而您的处理逻辑是:
待判断值 = 总字数 * 候选词出现的次数 / 词切分组合左出现的次数 / 词切分组合右出现的次数
待判断值 > 录取词语最低支持度 => 合法词,否则为非法
且以上切分组合只要有一个符合上述条件,即可进入合法词列表,即qq
如:
“一笑说道”的切分组合:
一 + 笑说到
一笑 + 说到
一笑说 + 到
与原文中关于“凝合程度”的定义还是有差异的,请问如何理解?
1、“总字数 * 候选词出现的次数 / 词切分组合左出现的次数 / 词切分组合右出现的次数”这个公式并没有错误,只要根据词频的定义去化简一下就行了;
2、“以上切分组合只要有一个符合上述条件,即可进入合法词列表”,本文不是这样实现的,请仔细阅读,不要轻率下结论:是每一种切分都算一个qq,然后用qq去筛选,每个n-gram都要经过n-1个qq去筛选。
精妙之处在于【for k in range(m-1):】中的【tt = tt[qq]】,这样就是【以上切分组合必须所有符合上述条件,方可进入合法词列表】,这样也就解释了对每种切分组合的待判断值逐个过滤,也就自然满足原文定义中的所有组合的较小值,tql!
苏神是否考虑加个对自己的评论的删除功能?这样之前不妥的结论可自行删除?
一般不考虑,如果真的不妥我也会自行删除的。
January 19th, 2021
从分词一路追到了这里,苏神的代码写的很巧妙!有两个小问题
1.matrix67提到的“相同的候选词便都集中在了一起,从头到尾扫描一遍便能算出各个候选词的频数和右邻字信息熵。将整个语料逆序后重新排列所有的后缀,再扫描一遍后便能统计出每个候选词的左邻字信息熵”,苏神有没有实践过,不是很理解
2.np.sort(np.intersect1d(rt[i-2], pp.index))中np.intersect1d我查了下是返回的交集排序结果,所以np.sort应该可以省去
1、没有试过;
2、印象中好像不sort会影响速度?不大记得了。
总的来说,由于后面研究了其他新词发现算法,所以这份代码也就没怎么更新和维护了。
谢谢苏神回复,继续学习苏神的博客,哈哈