发现新词 | NLP之无监督方式构建词库(二)

前言

上文中提到的发现新词的方法主要原理是基于互信息熵判断两个字是否成词(即片段的凝固度大于一定程度),而所谓成词,就是它相对独立,不可切分。如果其成词则加入初始词库。那为什么不反过来呢?为什么我们不去找一下哪些片段不能成词呢?根据前面的说法,我们说片段的凝固度大于一定程度时,片段可能成词(接下来要去考虑它的边界熵)。那这不就是说,如果片段的凝固度低于一定程度时,这个片段就不可能成词了吗?那么我们就可以在原来的语料中把它断开了。这也就是这篇文章的核心原理。

一、数据介绍

本文针对一万多条商品名称语料来进行实验,数据格式如下:
在这里插入图片描述

二、实验代码

代码部分参考:基于切词的新词发现。其基本原理为:如果a aa,b bb是语料中相邻两字,那么可以统计( a , b ) (a,b)(a,b)成对出现的次数# ( a , b ) \#(a,b)#(a,b),继而估计它的频率P ( a , b ) P(a,b)P(a,b),然后我们分别统计a aa,b bb出现的次数# a \#a#a,# b \#b#b,然后估计它们的频率P ( a ) P(a)P(a),P ( b ) P(b)P(b),如果
P ( a , b ) P ( a ) P ( b ) < α \frac{P(a,b)}{P(a)P(b)}<αP(a)P(b)P(a,b)<α
(α αα是给定的大于1的阈值),那么就应该在原来的语料中把这两个字断开。这个操作本质上就是我们根据这个指标,对原始语料进行初步的分词!

#!usr/bin/env python
# -*- coding:utf-8 -*-
from collections import defaultdict  # defaultdict是经过封装的dict,它能够让我们设定默认值
from tqdm import tqdm  # tqdm是一个非常易用的用来显示进度的库
from math import log
import re
import codecs
import pandas as pd
from words_search import search


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):
        """
        计数函数,计算单字出现频数、相邻两字出现的频数,
        利用最小频率过滤一部分单字与双字
        计算双字的互信息熵,将大于最小互信息熵的稳定词添加到集合中
        :param texts:
        :return:
        """
        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.strong_segments来找词语
        如果两个字能够成词,则判断第二个词与语料中的下一次词是否能够成词,如果能够成词则更新s,继续判断;
        如果不能成词,则将s添加到词典中,并更新s
        最后,对生成的词典进行频数的过滤
        :param texts:
        :return:
        """
        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]
                # 如果新的两个词不密切则断开,前述片段作为一个词来统计,s设置为当前词
                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}  # 最后再次根据频数过滤


if __name__ == '__main__':
    fw = Find_Words(2, 1)
    input_data = codecs.open("data/file_corpus.txt", 'r', encoding="utf-8")
    def texts():
        for a in input_data:
            yield a


    fw.count(texts())
    input_data.close()

    input_data = codecs.open("data/file_corpus.txt", 'r', encoding="utf-8")


    def texts():
        for a in input_data:
            yield a


    fw.find_words(texts())
    input_data.close()

    words = pd.Series(fw.words).sort_values(ascending=False).to_frame()
    output_data = codecs.open("data/segment.txt", "w", encoding="utf-8")

    for word in words.itertuples():
        output_data.write(str(word[0]) + "\t" + str(word[1]) + "\n")
    output_data.close()

    file_segment = r"data\segment.txt"
    file_dict = r"data\dict.txt"
    search(file_segment, file_dict, H=10000, R=60)

经过初步分词,得到的片段语料segment.txt如下:
在这里插入图片描述
生成这部分词后,应该人工筛选后添加到词库中。也可以访问百度来判断是否成词,百度访问不一定准确。

#!usr/bin/env python
# -*- coding:utf-8 -*-
"""
    对切分产生的字串按频率排序,前H=2000的字串进行搜索引擎(百度),
    若字串是“百度百科”收录词条,将该字串作为词加入词库,
    或者在搜索页面的文本中出现的次数超过60,也将该字串作为词加入词库;
"""
import requests
from lxml import etree
import codecs
import re


def search(file_segment, file_dict, H, R):
    # headers,从网站的检查中获取
    headers = {'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
               'Accept-Encoding': 'gzip, deflate, sdch, b',
               'Accept-Language': 'zh-CN,zh;q=0.8',
               'Cache-Control': 'max-age=0',
               'Connection': 'keep-alive',
               'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.'
               }
    # 加载切分出来的子符串
    input_data = codecs.open(file_segment, 'r', encoding='utf-8')
    read_data = input_data.readlines()
    N = len(read_data)
    if H > N:
        H = N
    output_data = codecs.open(file_dict, 'a', encoding='utf-8')
    n = 0
    m = 1
    # 遍历切分出的子符串
    for line in read_data[:H]:
        line = line.rstrip()
        line = line.split('\t')
        # 字符串
        word = line[0]
        try:
            # 访问百度百科词条
            urlbase = 'https://www.baidu.com/s?wd=' + word
            dom = requests.get(urlbase, headers=headers)
            ct = dom.text
            # 在搜索页面的文本中出现的次数
            num = ct.count(word)
            html = dom.content
            selector = etree.HTML(html)
            flag = False
            # 若字串是“百度百科”收录词条,将该字串作为词加入词库
            if selector.xpath('//h3[@class="t c-gap-bottom-small"]'):
                ct = ''.join(selector.xpath('//h3[@class="t c-gap-bottom-small"]//text()'))
                lable = re.findall(u'(.*)_百度百科', ct)
                for w in lable:
                    w = w.strip()
                    if w == word:
                        flag = True
            if flag:
                output_data.write(word + '\n')
                n += 1
            # 在搜索页面的文本中出现的次数超过阈值R=60,也将该字串作为词加入词库
            else:
                if num >= R:
                    output_data.write(word+ '\n')
                    n += 1
            m += 1
            if m % 100 == 0:
                print('having crawl %dth word\n' % m)
        except:
            pass
    print('Having add %d words' % (n))
    input_data.close()
    output_data.close()
    return n

三、分析

一般情况下,为了得到更细粒度的词语(避免分出太多无效的长词),我们可以选择较大的α αα,比如α = 10 α=10α=10,但是这带来一个问题:一个词语中相邻两个字的凝固度不一定很大。一个典型的例子是“共和国”,“和”跟“国”都是很频繁的字,“和国”两个字的凝固度并不高,如果α αα太大就会导致切错了这个词语(事实上,是“共和”跟“国”的凝固度高)。而如果设置α = 1 α=1α=1,则需要更大的语料库才能使得词库完备起来。这是在使用本算法时需要仔细考虑的。我试验的时候,由于文本语料较小,又不想语料切的很散,故设置min_count = 2min_pmi = 2,取得了不错的效果!这个方法的效果远远高于预期!经过数据清洗后,结果为:
在这里插入图片描述