生活中,有很多年度流行词,这些年度流行词依靠人为的判断很容易就能辨别,但是人的精力毕竟是有限的,利用python代码发现新词,不仅可以发现新词,只要语料库够大,甚至还可以找到年度网络流行语。
我们可以想象一篇文本就是很长的一个字符串,我们需要在文本中找到新词,就是把一个很长的字符串分解成若干短的字符串,判断这个字符串出现次数的多少,出现的次数多并且没有在我们现有的词库中出现,我们就可把这个词想象成是一个新词。在对现有文本分析的时候发现, “图书馆” 出现了116次, “学校的” 出现了312次,然而我们却更倾向于把“图书馆”当作一个词,因为直觉上“图书”和“馆”凝固程度更好。
为了证明机器也可以和人脑一样进行这样判断,我们可以计算一下,在整整1295万的分词数据中,“图书”一共出现了1523次,出现的概率为0.0001176062,“馆”一共出现了3512次,出现的概率为0.0002711969,假设这两词毫无关系,那么两者组合在一起的概率就是P(预测1) = 0.00011760620.0002711969,约为3.1894e-8,但是“图书馆”出现的概率为8.9575e-6,是预测的280倍,。“的”字出现了13.58万次,概率为0.0104864865,“学校”出现的次数为3184次,概率为0.0002458687,得出 P(预测2) = 2.5786e-6,“学校的”出现的概率为2.4092e-5,是预测值的9.3倍。计算结果表明,“图书馆”应该是一个更让人信服的搭配,而“学校的”则更像是“学校”和“的”这两个成分偶然拼到一起的。
当然,作为一个刚开始没有任何语料库的抽词程序,我们并不知道“图书馆”是“图书”加“馆”拼接而来的,还是“图”加“书馆”拼接而来的,或许我们用“图”加“书馆”拼接而算的的概率可能会更大。因此,为了算出一个字符串的凝固程度,我们需要枚举这个字符串是由哪两部分组合而来的。令p(x)为文本片段x在整个语料中出现的概率,那么我们定义“图书馆”的凝固程度就是p(图书馆)与p(图)·p(书馆)的比值和p(图书馆)与p(图书)·p(馆)的比值中的较小值。
光看字符串内部的凝合程度还不够,我们还需要从整体来看它在外部的表现。考虑“杯子”和“辈子”这两个字符串。我们可以说“买杯子”、“拿杯子”、“好杯子”、“这杯子”、“玻璃杯子”等,在“杯子”前面加各种字;但“辈子”的用法却比较固定,除了“几辈子”、“这辈子”、“上辈子”、“下辈子”,基本上 “辈子”前面不能加别的字了。“辈子”这个文本片段左边可以出现的字太有限,以至于视觉上我们可能会认为,“辈子”并不单独成词,真正成词的其实是“几辈子”、“这辈子”之类的整体。可见,文本片段的自由运用程度也是判断它是否成词的重要标准。如果一个文本片段能够算作一个词的话,它应该能够灵活地出现在各种不同的文本片段中,具有非常丰富的左邻字集合和右邻字集合。
信息熵是一个很神奇的概念,意外越大,越不可能发生,概率就越小,信息量也就越大,也就是信息越多。比如说“太阳从东边升起”,实现概率100%,说了和没说差不多,信息量就是0。
信息量= log2(1/概率)=log2(概率^-1)=-log2(概率),log2是以2为底的对数。
举个例子:一个骰子6面有五面为1,一面为5,投掷一次,出现为1,你或许不那么吃惊,它带给你的信息量为 log2(6/5) = 0.2630。但如果出现5,它所带给你的信息量就为log2(6/1) = 2.5849,但你只有1/6的概率得到这个结果。因而平均情况下你可以得到 50.2630 + 12.5849 = 3.8999的信息量。再考虑一个最极端的情况:如果一颗骰子的六个面都是1,投掷它不会给你带来任何信息,它的信息熵为-log2(1)=0。什么时候 信息熵会更大呢?换句话说,发生了怎样的事件之后,你最想问一下它的结果如何?直觉上看,当然就是那些结果最不确定的事件。
考虑下面一段话:“吃葡萄不吐葡萄皮儿,不吃葡萄倒吐葡萄皮儿”,“葡萄”一次一共出现了4次,其中左邻字集合为{“吃”,“吐”,“吃”,“吐”},右邻字集合为{“不”,“皮”,“倒”,“皮”},根据公式,“葡萄”一词的左邻字的信息熵为 2log2(2) + 2log2(2) = 4.000,右邻字的信息熵为 log2(4) + 2*log2(2) + log2(4) = 6.000,可见葡萄的右邻字更丰富一些。事实上,只要合适,葡萄的右邻字可以为任意字。所以不妨定义一个字符串的自由运用程度为它的左、右信息熵的较小值。
在实际运用中你会发现,字符串的凝固程度和自由程度,两种判断标准缺一不可。只看凝固程度的话,程序会找出“巧克”、“俄罗”、“颜六色”、“柴可夫”等实际上是“半个词”的字符串片段;只看自由程度的话,程序则会把“吃了一 顿”、“看了一遍”、“睡了一晚”、“去了一趟”中的“了一”提取出来,因为它的左右邻字都太丰富了。
首先,分词之后,构建字典树,然后,根据计算,得出内部凝固程度和自由运用程度,最后根据内部凝固程度和自由运用程度,算出得分,当得分超过阈值时得到新词。
import jieba
def loadDate(fileName, stopwords):
# 加载数据集
data = []
with open(fileName, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
line = line.strip()
line = [x for x in jieba.cut(line, cut_all=False) if x not in stopwords]
data.append(line)
# 按照行进行切分句子,得到一个数组
# [[行,切词], [], []]
# print(data)
#[['台湾', '中', '时', '电子报', '26', '日', '报道', '称', '蔡', '英文', '今日', '一早', '会见', '世卫', '行 动', '团', '她', '称', '台湾', '虽然', '无法', '参加', 'WHA', '世界卫生', '大会', '但', '还是', '要', '有', '贡献', '于是', '她', '表示', '要', '捐', '100', '万美元', '给', 'WHO', '对抗', '埃', '博拉', '病毒'],
#['对于', '台湾', '为何', '不能', '蔡', '英文', '又', '一次', '惯性', '甩锅', '宣称', '中国', '对', '台湾', ' 外交', '打压', '已', '无所不用其极'],
#['不过', '环环', '想', '提醒', '一句', '此次', '大会', '上', '确实', '有', '多个', '台湾', '友邦', '受', '台当局', '邀请', '向', '大会', '提案', '邀请', '台湾', '作为', '观察员', '参加', 'WHA', '然而', '结果', '是', '立即', '被', '大会', '否决'],
#[]]
return data
loadDate接收文件名和停用词文件名
直接看代码,后面具体解释用法
class Node:
"""
建立字典树的节点
"""
def __init__(self, char):
self.char = char#存放节点字符串
self.word_finish = False#记录到当前节点时候可生成一个词
self.count = 0#计数
self.child = {}#存放孩子节点 {"char":Node}
self.isback = False#判断是否是左邻接字
class TrieNode:
"""
建立前缀树,并且包含统计词频,计算左右熵,计算互信息的方法
"""
def __init__(self, node, data=None, PMI_limit=20):
"""
初始函数,data为外部词频数据集
:param node:
:param data:
建立初始字典树
"""
self.root = Node(node)#构建字典树的根节点
self.PMI_limit = PMI_limit#这里的PMI_limit就是设置的初始阈值,可根据需要自行调整
"""
这里的data,可不传,这里只是我需要用到已有的数据,
所以构建root节点之后我还需要将我原有的数据导入进去
"""
if not data:
return
node = self.root
for key, values in data.items():
new_node = Node(key)
new_node.count = int(values)
new_node.word_finish = True
node.child[key]=new_node
def add(self, word):
"""
添加节点,对于左熵计算时,这里采用了一个trick,用a->b<-c 来表示 cba
具体实现是利用 self.isback 来进行判断
:param word:
:return:
增加节点
"""
node = self.root
# 正常加载
for count, char in enumerate(word):
found_in_child = False
# 在节点中找字符
if node.child.get(char)!=None:
node = node.child.get(char)
found_in_child = True
else:
new_node = Node(char)
node.child[char]=new_node
node = new_node
# 判断是否是最后一个词
if count == len(word) - 1:
node.count += 1#到此节点可构成新词,考虑到之前就已经构成,所以为count += 1
node.word_finish = True
# 建立后缀表示
"""
寻找左邻接字
["我","是","瓜皮"]转化为["是","瓜皮","我"],这样,
方便我们的寻找字符串“是瓜皮”的左邻接字“我”。
“是” --->“瓜皮” --->“我”。
建立分叉之后,设置“我”子节点的word_finish = True,isback = True,count += 1。
"""
length = len(word)
node = self.root
if length == 3:
word[0], word[1], word[2] = word[1], word[2], word[0]
for count, char in enumerate(word):
found_in_child = False
# 在节点中找字符
if count != length - 1:
if node.child.get(char)!=None:
node = node.child.get(char)
found_in_child = True
else:
if node.child.get(char)!=None and node.child.get(char).isback:
node = node.child.get(char)
found_in_child = True
if not found_in_child:
new_node = Node(char)
node.child[char]=new_node
node = new_node
# 判断是否是最后一个节点
if count == len(word) - 1:
node.count += 1
node.isback = True#左邻接字
node.word_finish = True
TrieNode.search_one方法,寻找一阶共现,并返回所有词概率和词总和
def search_one(self):
"""
寻找一阶共现,并返回所有词概率
对于:["我","爱","你","我","是","你","哥哥"]
result:
{"我":0.2857142857142857,"你":0.2857142857142857,"爱":0.14285714285714285,
"是":0.14285714285714285,"哥哥":0.14285714285714285}
total: 7
:return:
"""
result = {}
node = self.root
if not node.child:
return False, 0
total = 0
for child in node.child.values():
if child.word_finish == True:
total += child.count
for child in node.child.values():
if child.word_finish == True:
result[child.char] = child.count / total
return result, total
def search_right(self):
"""
寻找右频次
统计右熵,并返回右熵
:return:
"""
result = {}
node = self.root
if not node.child:
return False, 0
for child in node.child.values():
for cha in child.child.values():
total = 0
p = 0.0
for ch in cha.child.values():
if ch.word_finish == True and not ch.isback:
total += ch.count
for ch in cha.child.values():
if ch.word_finish == True and not ch.isback:
p += (ch.count / total) * math.log(ch.count / total, 2)
result[child.char + cha.char] = -p
return result
def search_left(self):
"""
寻找左频次
统计左熵, 并返回左熵
:return:
"""
result = {}
node = self.root
if not node.child:
return False, 0
for child in node.child.values():
for cha in child.child.values():
total = 0
p = 0.0
for ch in cha.child.values():
if ch.word_finish == True and ch.isback:
total += ch.count
for ch in cha.child.values():
if ch.word_finish == True and ch.isback:
p += (ch.count / total) * math.log(ch.count / total, 2)
result[child.char + cha.char] = -p
return result
def search_bi(self):
"""
寻找二阶共现,并返回log2( P(X,Y) / (P(X) * P(Y))和词概率
:return:
"""
result = {}
node = self.root
if not node.child:
return False, 0
total = 0
one_dict, total_one = self.search_one()
for child in node.child.values():
for ch in child.child.values():
if ch.word_finish == True:
total += ch.count
for child in node.child.values():
for ch in child.child.values():
if ch.word_finish == True :
PMI = math.log(max(ch.count, 1), 2) - math.log(total, 2) - math.log(one_dict[child.char], 2) - math.log( one_dict[ch.char], 2)
# 这里做了PMI阈值约束
if PMI > self.PMI_limit:
result[child.char + '_' + ch.char] = (PMI,
ch.count / total)
return result
TrieNode.wordFind方法,统计内部凝固程度,左右熵,计算新词得分,根据得分筛选新词
def wordFind(self, N):
# 通过搜索得到内部凝固程度(互信息)
bi = self.search_bi()
# 通过搜索得到左右熵
left = self.search_left()
right = self.search_right()
result = {}
for key, values in bi.items():
d = "".join(key.split('_'))
# 计算公式 score = PMI + min(左熵, 右熵)
result[key] = (values[0] + min(left[d], right[d])) * values[1]
result = sorted(result.items(), key=lambda x: x[1], reverse=True)
dict_list = [result[0][0]]
add_word = {}
new_word = "".join(dict_list[0].split('_'))
# 获得概率
add_word[new_word] = result[0][1]
# 取前N个
for d in result[1:N]:
flag = True
for tmp in dict_list:
pre = tmp.split('_')[0]
if d[0].split('_')[-1] == pre or "".join(tmp.split('_')) in "".join(d[0].split('_')):
flag = False
break
if flag:
new_word = "".join(d[0].split('_'))
add_word[new_word] = d[1]
dict_list.append(d[0])
return result, OrderedDict(sorted(add_word.items(),key = lambda t:t[1],reverse=True))
戳我
感谢F哥给我这次接触新词发现的机会,欢迎各位大佬提出意见、指出不足与错误。