【自然语言处理】课程实验一:新词发现

【自然语言处理】课程实验一:新词发现

nlp小白,python新手,百度大师,如有错误,还请赐教

理论简介

新词发现

词级别中文 NLP 任务首先需要分词,目前主流的分词方法都是基于词库的,那么,词库从哪里来?我们使用的分词工具的词库适用于当前数据集吗?数据集包含大量新词怎么办?此类问题在网络数据集(大量网络新词)和领域数据集(大量领域词汇)中较为常见。

【新词发现】完成的就是不加入任何先验素材,直接从大规模的语料库中,自动发现可能成词的语言片段。

频数

一种最简单自然的方法是,直接统计整个语料库中不同 ngram 的出现次数,将频数超过某阈值的记为新词。但是,按频数划分会产生错误,count(我想) > count(想法),而我们更倾向于把【想法】作为一个词。可以看到,仅根据频数划分是远远不够的,还需要考虑更多的特征。

内部凝固程度

【ngram 多字内部凝固度】是一种刻画 ngram 片段相关性和独立性的方法,可以通过点间互信息(PMI,pointwise mutual information)进行衡量。

当我们考虑把 AB 作为一个词时,如果 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∑n​pi​log(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 虚拟环境。

  1. 下载安装 conda。你可以选择安装已经预装了许多常用扩展的 Anaconda 或者没有任何预装的 Miniconda

  2. 打开 Terminal(Windows 可使用 Anaconda Prompt) 并进入当前文件目录,输入以下命令来创建一个名为 nlplab1 的虚拟环境并启用。

conda env create -f environment.yml
conda activate nlplab1
  1. 使用 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循环来进行暴力遍历,那样的话你的运行时间可能长达数小时。
  • 请尽量使用numpypandas这类支持向量化运算的库来更高效的计算。
  • 先对索引进行排序会大大加快检索速度,使用得当的话你的运行时间可以降到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)

问答

  1. 请从输出的new_words.txt的前100个词中选出你认为不合理的五个词。

    答:‘年12月’, ‘年11月’, ‘年1月’, ‘月1日’, ‘年3月’,

  2. 对于这些不合理的词,你认为可以通过哪些方法将其筛除掉?

    答:正则查找时间格式的字符串,删之

上一篇:leetcode-剑指 Offer II 081. 允许重复选择元素的组合【java】


下一篇:回溯类题目解法与实例