【自然语言处理】课程实验一:新词发现
nlp小白,python新手,百度大师,如有错误,还请赐教
理论简介
新词发现
词级别中文 NLP 任务首先需要分词,目前主流的分词方法都是基于词库的,那么,词库从哪里来?我们使用的分词工具的词库适用于当前数据集吗?数据集包含大量新词怎么办?此类问题在网络数据集(大量网络新词)和领域数据集(大量领域词汇)中较为常见。
【新词发现】完成的就是不加入任何先验素材,直接从大规模的语料库中,自动发现可能成词的语言片段。
频数
一种最简单自然的方法是,直接统计整个语料库中不同 ngram 的出现次数,将频数超过某阈值的记为新词。但是,按频数划分会产生错误,count(我想) > count(想法),而我们更倾向于把【想法】作为一个词。可以看到,仅根据频数划分是远远不够的,还需要考虑更多的特征。
内部凝固程度
【ngram 多字内部凝固度】是一种刻画 ngram 片段相关性和独立性的方法,可以通过点间互信息(PMI,pointwise mutual information)进行衡量。
当我们考虑把 A
、B
作为一个词时,如果 AB
是一个有意义的搭配,必然有 P(AB) >> P(A)*P(B),如 “语料库” 和 “的语料”,对 “的语料” 来说,P(“的语料”) 达不到远远大于 P(“的”)*P(“语料”),“的” 和 “语料” 更像是偶然拼到一起的。(for more, see here or here)
点间互信息的计算公式为:
P M I ( x ; y ) = log p ( x , y ) p ( x ) p ( y ) PMI(x; y)=\log \frac{p(x,y)}{p(x)p(y)} PMI(x;y)=logp(x)p(y)p(x,y)
对多字词,点间互信息为其所有切分组合 PMI 的最小值:(以三字词 “ABC”为例)
P M I ( " A B C " ) = M i n { log p ( " A B C " ) p ( " A " ) p ( " B C " ) , log p ( " A B C " ) p ( " A B " ) p ( " C " ) } PMI("ABC")=Min \{ \log \frac{p("ABC")}{p("A")p("BC")}, \log \frac{p("ABC")}{p("AB")p("C")} \} PMI("ABC")=Min{logp("A")p("BC")p("ABC"),logp("AB")p("C")p("ABC")}
Note: 想一想,我们切分句子究竟是在做什么?
实质即:将句子分为若干个相关性较弱的部分。对句子“我喜欢牛奶巧克力”,“喜”和“欢”、“牛”和“奶”明显相关;而考虑多字凝固度,“巧克力”又比“克力”结实。这样我们考虑将特征进行组合,得到“我/喜欢/牛奶/巧克力”,四个片段之间的相关性自然减弱,从而得到划分结果。
Assignment Notes:
- 在此实验中,为简化计算,对任意一个词 x x x(长度为1或大于1),我们用其频数(在语料库中出现的次数)除以语料库字符的总数来估计其出现的概率 $p(x) $。
- 这里我们定义一个词所有组合的最小PMI(不带log)为支持度并将其作为内部凝固度的度量。
举个栗子,假设在某语料库(总字符数100000)上统计得:
片段 | 频数 |
---|---|
恭喜发财 | 10 |
恭喜发 | 11 |
喜发财 | 12 |
恭喜 | 60 |
喜发 | 13 |
发财 | 50 |
恭 | 150 |
喜 | 300 |
发 | 200 |
财 | 100 |
对于“恭喜发财”这四个字我们有三种组合方法,“恭” + “喜发财”,“恭喜” + “发财”和“恭喜发” + “财”。于是我们可以计算得:
x | y | p(x) | p(y) | p(x,y) | PMI(x;y) 不带log |
---|---|---|---|---|---|
恭 | 喜发财 | 150/100000 | 12/100000 | 10/100000 | 555.56 |
恭喜 | 发财 | 60/100000 | 50/100000 | 10/100000 | 333.33 |
恭喜发 | 财 | 11/100000 | 100/100000 | 10/100000 | 909.09 |
于是我们将这三个值中的最小值 333.33 作为词语“恭喜发财”的支持度。
外部随机程度
【文本片段的外部随机程度】指的是该文本片段前后可接搭配的丰富程度,可以通过信息熵(Entropy)进行衡量。
我们用信息熵来衡量一个文本片段的左邻字集合和右邻字集合有多随机(文本片段的*运用程度),以判断它是否成词。如果一个文本片段能够算作一个词的话,它应该能够灵活地出现在各种不同的语境中,具有非常丰富的左邻字集合和右邻字集合。(for more, see here or here)
比如考虑 A(spite)、B(constant) 的 bi-gram,出现次数都为 993,但是以 A 词开始的只有 9 种(大多情况下 spite 直接跟的 of),但 B 词后的单词有 415 种,可以跟的搭配就多了。所以,我们更可能见到一个跟在 constant 后面的 bigram ,故 右信息熵(constant)>右信息熵(spite)。
信息熵的计算公式为:
H ( U ) = − ∑ i = 1 n p i l o g ( p i ) H(U)=-\sum_{i=1}^n p_ilog(p_i) H(U)=−i=1∑npilog(pi)
Assignment Notes:
- 这里的log指自然对数,我们取以 e e e为底,而不是以2或10为底。
- 信息熵计算步骤:统计目标词所有的左邻字集合、右邻字集合,算出概率,求和熵。以"我不"为例,我们统计得到:
左邻字 | 出现次数 | 右邻字 | 出现次数 |
---|---|---|---|
说 | 10 | 喜 | 10 |
道 | 10 | 说 | 10 |
想 | 40 | ||
要 | 20 |
则有,
H
l
e
f
t
=
−
1
2
∗
l
o
g
1
2
−
1
2
∗
l
o
g
1
2
H_{left}=-\frac{1}{2}*log\frac{1}{2}-\frac{1}{2}*log\frac{1}{2}
Hleft=−21∗log21−21∗log21
H
r
i
g
h
t
=
−
1
8
∗
l
o
g
1
8
−
1
8
∗
l
o
g
1
8
−
1
2
∗
l
o
g
1
2
−
1
4
∗
l
o
g
1
4
H_{right}=-\frac{1}{8}*log\frac{1}{8}-\frac{1}{8}*log\frac{1}{8}-\frac{1}{2}*log\frac{1}{2}-\frac{1}{4}*log\frac{1}{4}
Hright=−81∗log81−81∗log81−21∗log21−41∗log41
- 我们需要对左邻字集和的信息熵和右邻字集和的信息熵分别进行过滤,假设我们设定的信息熵阈值为 s s s,我们要保证 $H_{left} > s $ 并且 $H_{right} > s $
实验内容
依据上边提到的三个特征,频数 + 内部凝固程度 + 外部随机程度,读入并预处理 data 语料(语料全文作为一整个字符串),将文本中所有出现长度不超过 n 的子串都当作候选词(n 设为 4),从头到尾扫描一遍得到各个候选词的频数和左邻字信息熵、右邻字信息熵,再由频数算得凝固程度。最后完成抽词,将符合三个阈值的所有结果输出。
实验环境
你可以使用任何你自己熟悉的 python 环境来完成实验,我们提供了 conda 环境配置文件 environment.yml
,里面给出了一些必要的环境依赖,请确保你安装了这些依赖。如果你需要使用其他 python 扩展包可自行安装。
这里我们推荐使用 conda 来创建管理 python 虚拟环境。
-
下载安装 conda。你可以选择安装已经预装了许多常用扩展的 Anaconda 或者没有任何预装的 Miniconda。
-
打开 Terminal(Windows 可使用 Anaconda Prompt) 并进入当前文件目录,输入以下命令来创建一个名为
nlplab1
的虚拟环境并启用。
conda env create -f environment.yml
conda activate nlplab1
- 使用
jupyter-notebook
打开lab1.ipynb
文件开始下面的实验。
jupyter-notebook lab1.ipynb
实验过程
依据上面介绍的算法,我们需要分别实现三种方法并将其组合起来在大型语料库上进行新词发现,并将其与现有语料库进行对比,找出新词。下面会给出一部分代码,并需要你来完成几个关键的函数。
数据读取
实验数据来自 北大法宝数据库 法律法规立法资料白皮书。
读取完后我们将得到一个长字符串 corpus
来表示整个语料。
from __future__ import division
import re
import os
import sys
from collections import Counter
import numpy as np
import pandas as pd
DATA_FOLDER_PATH = 'data/white_papers'
filenames=os.listdir(DATA_FOLDER_PATH)
corpus = ''
for filename in filenames:
filepath = os.path.join(DATA_FOLDER_PATH, filename)
with open(filepath, mode='r', encoding='utf-8') as f:
corpus += f.read()
数据预处理
我们将一些不必要的标点字去除,因为这些标点字不会参与目标词的生成。
drop_list = [
u',', u'\n', u'。', u'、', u':', u'(', u')', u'[', u']',
u'.', u',', u' ', u'\u3000', u'”', u'“', u'?', u'?', u'!',
u'‘', u'’', u'…',u'-',u'-', u'—', u'━',u'》', u'《',u';', u':',
u'(', u')', u'【', u'】', u'%', u'·', u'#', u'\t', u'[', u'"',
u'*',u'/',u'<',u'>'
]
for i in drop_list:
corpus = corpus.replace(i, '')
定义阈值
定义我们需要用到的一些阈值。
MIN_COUNT = 10 # 录取词语最小出现次数
MIN_SUPPORT = 50.0 # 录取词语最低支持度
MIN_ENTROPY = 2.0 # 录取词语最低信息熵
MAX_SEP = 4 # 候选词语的最大字数
生成所有的候选词
首先我们需要你实现一个函数 gen_all_candidates
生成所有可能成词的候选词并记录其频数,这里我们同样记录单字(长度为1的词)以方便后续计算。
Assignment Notes:
- 使用Python标准库的 re 模块可以方便而高效的进行很多文本的处理,比如
test_corpus = '你就是你他就是他你不是他他也不是你你如果是他那他就是你'
re.findall('(..)', test_corpus)
['你就', '是你', '他就', '是他', '你不', '是他', '他也', '不是', '你你', '如果', '是他', '那他', '就是']
def gen_all_candidates(s, max_n):
'''
给定一个字符串,生成所有的候选词。候选词的最小长度为1(单字),最大长度为
max_n。
Args:
s (str): 给定的字符串。
max_n (int): 候选词的最大长度。
Returns:
dict[str, int]: 一个以词为key,以对应的频数为value的字典。
'''
###### 开始 ######
dict={}
wordList=[]
for count in range(1,max_n+1):
for i in range(0,len(s)-count+1):
wordList.append(s[i:i+count])
i+=1
for key in wordList:
dict[key]=dict.get(key,0)+1
return dict
###### 结束 ######
运行下面的测试,你应该得到如下结果(顺序可以不同):
{'是': 6,
'他': 6,
'你': 6,
'就是': 3,
'就': 3,
'是你': 3,
'是他': 3,
'不': 2,
'不是': 2,
'他就': 2,
'你你': 1,
'他你': 1,
'你如': 1,
'如': 1,
'那他': 1,
'那': 1,
'也': 1,
'如果': 1,
'也不': 1,
'果': 1,
'你他': 1,
'你就': 1,
'他也': 1,
'他他': 1,
'果是': 1,
'他那': 1,
'你不': 1}
test_all_candidates = gen_all_candidates(test_corpus, 2)
test_all_candidates
{'你': 6,
'就': 3,
'是': 6,
'他': 6,
'不': 2,
'也': 1,
'如': 1,
'果': 1,
'那': 1,
'你就': 1,
'就是': 3,
'是你': 3,
'你他': 1,
'他就': 2,
'是他': 3,
'他你': 1,
'你不': 1,
'不是': 2,
'他他': 1,
'他也': 1,
'也不': 1,
'你你': 1,
'你如': 1,
'如果': 1,
'果是': 1,
'他那': 1,
'那他': 1}
下面我们在真正的语料库上运行你实现的函数得到所有的候选词。
%%time
all_candidates = gen_all_candidates(corpus, MAX_SEP)
Wall time: 2.76 s
依据频数筛选
第一步我们先从候选词中筛除掉出现次数不够多的词。这里需要你实现一个函数 filter_low_frequency_words
来进行低频词的筛选。因为我们是希望找出可以成词的片段(至少两个字),因此不考虑单字成词,所以同时请将长度为1的单字也筛除掉。
def filter_low_frequency_words(all_candidates, threshold):
'''
筛除掉出现频率过低的词以及长度为1的词。请不要直接修改输入的变量,将结果用一
个新的变量返回。
Args:
all_candidates (dict[str, int]): 候选词及其频数。
threshold (int): 需要筛除频数小于等于此阈值的候选词,即只保留频数大于
此阈值的候选词。
Returns:
list[str]: 一个列表,为筛除低频词后的剩余的候选词。
'''
###### 开始 ######
list=[]
for key in all_candidates:
if(all_candidates.get(key)>threshold and len(key)>1):
list.append(key)
return list
###### 结束 ######
运行下面的测试,你应该得到如下结果(顺序可以不同):
['是他', '就是', '是你', '不是', '他就']
test_remain_candidates_1 = filter_low_frequency_words(test_all_candidates, 1)
test_remain_candidates_1
['就是', '是你', '他就', '是他', '不是']
我们在真正语料库的所有候选词上运行你实现的函数来得到经过频数筛选后的候选词。
%%time
remain_candidates_1 = filter_low_frequency_words(all_candidates, MIN_COUNT)
Wall time: 311 ms
依据凝固程度筛选
第二步我们需要你实现一个函数 filter_low_support_words
根据凝固度对候选词进行筛选。
def filter_low_support_words(
all_candidates, remain_candidates, n_total_words, threshold
):
'''
筛除支持度过低的候选词。请不要直接修改输入的变量,将结果用一个新的变量返回。
Args:
all_candiates (dict[str, int]): 所有的候选词及其频数。
remain_candidates (list[str]): 需要进行筛选的候选词。
n_total_words (int): 整个语料库字符的长度。
threshold (float): 最小支持度,低于此阈值的候选词将会被筛除。
Returns:
list[str]: 一个新的列表,筛选过后剩余的候选词。
'''
###### 开始 ######
list=[]
for candidate in remain_candidates:
pxy=all_candidates.get(candidate)/n_total_words
minsupport=sys.maxsize
# 遍历数组获取索引需要使用range,range不包含上界
for index in range(0,len(candidate)-1):
px=all_candidates.get(candidate[0:index+1])/n_total_words
py=all_candidates.get(candidate[index+1:len(candidate)])/n_total_words
minsupport=min(pxy/(px*py),minsupport)
if(minsupport>=threshold):
list.append(candidate)
return list
###### 结束 ######
运行下面的测试,你应该得到如下结果(顺序可以不同):
['就是', '不是', '他就']
test_remain_candidates_2 = filter_low_support_words(
test_all_candidates, test_remain_candidates_1, len(test_corpus), 3.0
)
test_remain_candidates_2
['就是', '他就', '不是']
在经过频数筛选后的候选词上运行你实现的函数来得到经过内部凝固度筛选后的候选词。
%%time
remain_candidates_2 = filter_low_support_words(
all_candidates, remain_candidates_1, len(corpus), MIN_SUPPORT
)
Wall time: 97 ms
依据外部随机程度筛选
第三步需要你实现一个函数 filter_low_entropy_words
依据外部随机程度来对候选词进行筛选。
Assignment Notes:
- 下面的例子展示了正则表达式
(.)(..)(.)
可以匹配的字符串模式。
test_corpus = '你就是你他就是他你不是他他也不是你你如果是他那他就是你'
re.findall('(.)(..)(.)', test_corpus)
[('你', '就是', '你'),
('他', '就是', '他'),
('你', '不是', '他'),
('他', '也不', '是'),
('你', '你如', '果'),
('是', '他那', '他')]
Performance Tips
- 请尽量不要只使用for循环来进行暴力遍历,那样的话你的运行时间可能长达数小时。
- 请尽量使用
numpy
,pandas
这类支持向量化运算的库来更高效的计算。 - 先对索引进行排序会大大加快检索速度,使用得当的话你的运行时间可以降到1~2分钟。
def filter_low_entropy_words(
corpus, max_n, all_candidates, remain_candidates, threshold
):
'''
筛除左邻字集合和右邻字集合信息熵过低的候选词。请不要直接修改输入的变量,将结果
用一个新的变量返回。不要求你的实现一定要使用传入的每个参数,如果你认为某些不是
必须的,你可以不使用它。
Args:
corpus (str): 整个语料库拼接在一起的一个字符串。
max_n (int): 候选词的最大长度。
all_candiates (dict[str, int]): 所有的候选词及其频数。
remain_candidates (list[str]): 需要进行筛选的候选词。
threshold (float): 左右邻字集的最小信息熵,低于此阈值的候选词将会被筛除。
Returns:
list[str]: 筛选过后剩余的候选词。
'''
###### 开始 ######
list = []
for candidate in remain_candidates:
# 先将re的查询结果转化成数组:np.array()
arr = np.array(re.findall('(.)('+candidate+')(.)', corpus))
left = [i[0] for i in arr]
right = [i[len(arr[0]) - 1] for i in arr]
left.sort()
right.sort()
left_count = Counter(left)
right_count = Counter(right)
h_left = 0
for element in left_count:
temp = left_count.get(element) / len(left)
h_left += -temp * np.log(temp)
h_right = 0
for element in right_count:
temp = right_count.get(element) / len(right)
h_right += -temp * np.log(temp)
if h_left > threshold and h_right > threshold:
list.append(candidate)
return list
###### 结束 ######
运行下面的测试,你应该得到如下结果(顺序可以不同):
['不是']
test_remain_candidates_3 = filter_low_entropy_words(
test_corpus, 2, test_all_candidates, test_remain_candidates_2, 0.65
)
test_remain_candidates_3
['不是']
在经过内部凝固度筛选后的候选词上运行你实现的函数来得到经过外部随机程度筛选后的候选词。
%%time
remain_candidates_3 = filter_low_entropy_words(
corpus, MAX_SEP, all_candidates, remain_candidates_2, MIN_ENTROPY
)
remain_candidates_3
我们来查看一下剩余的候选词中频数从高到低排前20的是哪些词。
pd.Series(all_candidates)[remain_candidates_3].sort_values(ascending=False)[:20]
公司 4929
审判 4898
知识产权 4716
201 4511
人民法院 4305
工作 4185
纠纷 4054
行政 3272
合同 3169
执行 2748
诉讼 2567
保护 2375
建设 2090
管理 2054
问题 1962
环境 1885
社会 1884
加强 1721
情况 1700
机制 1554
dtype: int64
对比现有词库
接下来我们把依据我们领域内语料库生成的词与常用分词器的原始词库做对比,看看程序的运行结果能不能发现不同的新词。
我们使用jieba词表作为我们的原始词库,一共584429个词。首先加载jieba词表:
jieba_vocab = pd.read_csv('data/dict.txt.big', header=None, sep=' ')[0].tolist()
len(jieba_vocab)
584429
def find_new_words(candidates, existing_vocab):
'''
将候选词与现有词表进行对比,找出不在现有词汇表内的新词并将其返回。
Args:
candidates (list[str]): 候选词。
existing_vocab (list[str]): 现有词汇表。
Returns:
(list[str]): 找出的新词。
'''
###### 开始 ######
list = []
existing_vocab = set(existing_vocab)
for i in candidates:
if i not in existing_vocab:
list.append(i)
return list
###### 结束 ######
运行下面的测试,你应该得到如下结果(顺序可以不同):
['b', 'e']
test_new_words = find_new_words(['a', 'b', 'c', 'e'], ['a', 'c', 'd', 'f'])
test_new_words
['b', 'e']
在经过外部随机程度筛选后的候选词上运行你实现的函数来得到不在现有词汇表的新词。
%%time
new_words = find_new_words(remain_candidates_3, jieba_vocab)
Wall time: 55 ms
接下来我们将找到的新词按频数高低排序并查看前19个词。如果你的实现是正确的话,你应该得到以下结果:
201 4511
行政机关 577
环境资源 520
新收 506
广州中院 500
占比 405
融资租赁 401
过程中 381
案件数量 364
人民群众 320
典型案例 308
信息公开 296
司法救助 294
裁判文书 290
毒品犯罪 274
实践中 270
对赌 242
公益诉讼 235
行政行为 234
dtype: int64
sorted_new_words = pd.Series(all_candidates)[new_words].sort_values(ascending=False)
sorted_new_words[:19]
201 4511
行政机关 577
环境资源 520
新收 506
广州中院 500
占比 405
融资租赁 401
过程中 381
案件数量 364
人民群众 320
典型案例 308
信息公开 296
司法救助 294
裁判文书 290
毒品犯罪 274
实践中 270
对赌 242
公益诉讼 235
行政行为 234
dtype: int64
最后将发现的新词输出到文件。
sorted_new_words.to_csv('new_words.txt', header=False)
问答
-
请从输出的
new_words.txt
的前100个词中选出你认为不合理的五个词。答:‘年12月’, ‘年11月’, ‘年1月’, ‘月1日’, ‘年3月’,
-
对于这些不合理的词,你认为可以通过哪些方法将其筛除掉?
答:正则查找时间格式的字符串,删之