双向最大匹配和实体标注:你以为我只能分词?

2020 年 5 月 2 日 AINLP


一 :内容预告


前几天,我看了叉烧同学写的文章:


NLP.TM[29] | ner自动化打标方法


文章介绍了一种实体自动打标的方法:实体词典+最大逆向匹配


啥叫实体打标呢?


举叉烧同学文章里的例子,有一句话:宫保鸡丁和红烧牛肉哪个好吃,我们需要给句子中的两个实体 [宫保鸡丁, 红烧牛肉] 打上 Food 标签,非实体打上 O 标签。


做法是,首先准备一个实体词典,实体词典包含这两个实体在内的食物名,然后用这个词典和最大逆向匹配算法,给实体打标签。


打好标签的句子,可以直接作为实体提取的最终结果,也可以作为训练实体识别模型的样本。


用于构造样本时,打标签的结果如下:


""" 1:句子 """

宫保鸡丁和红烧牛肉哪个好吃

""" 2:标注结果 """
宫 B-Food
保 I-Food
鸡 I-Food
丁 I-Food
和 O
红 B-Food
烧 I-Food
牛 I-Food
肉 I-Food
哪 O
个 O
好 O
吃 O


我之前也做过实体自动打标签的活,不过用的方法是:实体词典+jieba词性标注


jieba词性标注应该用到了双向最大匹配,所以其实我之前做的,就是掉包实现了以上打标的方法。


这次准备了一个医疗实体词典(带标签)和若干医疗文本,用python实现双向最大匹配算法,并结合实体词典进行实体自动打标,作为训练实体识别模型的样本。


同时也整理了实体词典+jieba词性标注进行实体自动打标的方法。


代码已上传github地址:


https://github.com/DengYangyong/medical_entity_recognize


本文主要关注以下四方面的内容:


  • 双向最大匹配算法的思路

  • 双向最大匹配算法的代码实现

  • 实体词典+双向最大匹配算法做实体打标

  • 实体词典+jieba词性标注做实体打标


二:双向最大匹配算法


前向最大匹配算法和后向最大匹配算法是基于规则(词典)的分词方法,二者按照一定的规则结合使用,就是双向最大匹配算法。


双向最大匹配算法不仅可以用于分词,也可以用于序列标注,速度快且准确率高,不会分出奇怪的词或实体,但也难以正确处理未登录词。


由于完全依赖词典,通用性不强,所以比较适用于处理具体领域的任务,比如医疗领域。


01

前向最大匹配算法


首先拿到一个词典,比如实体词典,每一行是一个实体和对应的标签:


""" 词典(包含:实体和标签) """

肾抗针,DRU
肾囊肿,DIS
肾区,REG
肾上腺皮质功能减退症,DIS
肾性高血压,DIS
肾性贫血,DIS
肾血管,ORG
肾脏,ORG
伴脓性渗出,NBP
生理反射存在,NBP


然后计算词典中实体的最大长度,作为截取句子片段的最大长度。


为什么叫最大匹配呢?


对句子进行分词和标注的一个原则是:单个词(实体)的长度尽可能大。所以会先按最大长度从句子中截取片段,去词典中匹配,匹配中了,就切分出来。


按最大长度匹配不中,那么最大长度减一,再去截取句子片段,去词典中匹配,直到匹配中,或者截取的长度减小至一。


为什么叫前向呢?


前向的意思是,从句子中截取片段是从左向右进行的。


更多的细节,来看一个例子。

     

患者的电子病历上写着:我最近双下肢疼痛,我该咋办。


句子中有个两个实体,出现在实体词典中:双下肢疼痛、疼痛。


句子的长度为14(包括标点符号),实体词典中的最大长度为10。


第一轮遍历未匹配中,把第一个字切分出来:[我]。



第二轮遍历未匹配中,把第二个字切分出来:[我,最]。



第四轮遍历,匹配中了[双下肢疼痛],就把这个实体从句子中剔除,拿剩下的句子继续匹配。



最终的实体打标结果为:



可以看到,[疼痛] 这个实体没有被匹配出来,因为[双下肢疼痛]的粒度更大,表达的含义更明确,这也是最大匹配的意义。


前向最大匹配的python实现如下:


#coding:utf-8
import pandas as pd

class PsegMax:
    def __init__(self, dict_path):
        self.entity_dict, self.max_len = self.load_entity(dict_path)

    def load_entity(self, dict_path):
        """
        加载实体词典
        """


        entity_list = []
        max_len = 0

        """ 实体词典: {'肾抗针': 'DRU', '肾囊肿': 'DIS', '肾区': 'REG', '肾上腺皮质功能减退症': 'DIS', ...} """
        df = pd.read_csv(dict_path,header=None,names=["entity","tag"])
        entity_dict = {entity.strip(): tag.strip() for entity,tag in df.values.tolist()}

        """ 计算词典中实体的最大长度 """
        df["len"] = df["entity"].apply(lambda x:len(x))
        max_len = max(df["len"])

        return entity_dict, max_len


    def max_forward_seg(self, sent):
        """
        前向最大匹配实体标注
        """

        words_pos_seg = []
        sent_len = len(sent)

        while sent_len > 0:

            """ 如果句子长度小于实体最大长度,则切分的最大长度为句子长度 """
            max_len = min(sent_len,self.max_len)

            """ 从左向右截取max_len个字符,去词典中匹配 """
            sub_sent = sent[:max_len]

            while max_len > 0:

                """ 如果切分的词在实体词典中,那就是切出来的实体 """
                if sub_sent in self.entity_dict:
                    tag = self.entity_dict[sub_sent]
                    words_pos_seg.append((sub_sent,tag))
                    break

                elif max_len == 1:

                    """ 如果没有匹配上,那就把单个字切出来,标签为O """
                    tag = "O"
                    words_pos_seg.append((sub_sent,tag))
                    break

                else:

                    """ 如果没有匹配上,又还没剩最后一个字,就去掉右边的字,继续循环 """
                    max_len -= 1
                    sub_sent = sub_sent[:max_len]

            """ 把分出来的词(实体或单个字)去掉,继续切分剩下的句子 """
            sent = sent[max_len:]
            sent_len -= max_len

        return words_pos_seg


02

后向最大匹配算法


后向的意思是,从句子中截取片段是从右往左进行的。


还是来看上面的那个例子。


第一轮遍历未匹配中,把最后一个字切分出来:[。]。



第二轮遍历未匹配中,把倒数第二个字切分出来:[办,。]。



直到第七轮遍历,匹配中了[双下肢疼痛],就把这个实体从句子中剔除,拿剩下的句子继续匹配。



最终的实体打标结果和前向最大匹配的结果一致。


这个例子没反映出前向最大匹配和后向最大匹配的差别,感觉在实体标注这一块,二者的结果差别不大。


有资料统计,在分词时,仅使用前向最大匹配的错误率为 1/169,而使用后向最大匹配的错误率为 1/245,可见使用后向最大匹配可以提高分词或标注的准确率。


后向最大匹配的python实现如下:


#coding:utf-8
import pandas as pd

class PsegMax:
    def __init__(self, dict_path):
        self.entity_dict, self.max_len = self.load_entity(dict_path)

    def load_entity(self, dict_path):
        """
        加载实体词典
        """


    def max_forward_seg(self, sent):
        """
        前向最大匹配实体标注
        """


    def max_backward_seg(self, sent):
        """
        后向最大匹配实体标注
        """


        words_pos_seg = []
        sent_len = len(sent)

        while sent_len > 0:

            """ 如果句子长度小于实体最大长度,则切分的最大长度为句子长度 """
            max_len = min(sent_len,self.max_len)

            """ 从右向左截取max_len个字符,去词典中匹配 """
            sub_sent = sent[-max_len:]

            while max_len > 0:

                """ 如果切分的词在实体词典中,那就是切出来的实体 """
                if sub_sent in self.entity_dict:
                    tag = self.entity_dict[sub_sent]
                    words_pos_seg.append((sub_sent,tag))
                    break

                elif max_len == 1:

                    """ 如果没有匹配上,那就把单个字切出来,标签为O """
                    tag = "O"
                    words_pos_seg.append((sub_sent,tag))
                    break

                else:

                    """ 如果没有匹配上,又还没剩最后一个字,就去掉右边的字,继续循环 """
                    max_len -= 1
                    sub_sent = sub_sent[-max_len:]

            """ 把分出来的词(实体或单个字)去掉,继续切分剩下的句子 """
            sent = sent[:-max_len]
            sent_len -= max_len

        """ 把切分的结果反转 """
        return words_pos_seg[::-1]


03

双向最大匹配算法


双向最大匹配就是,将前向最大匹配的切分结果,和后向最大匹配的结果进行比较,按一定的规则选择其一作为最终的结果。


按照什么规则来选择呢?


(1)如果前向和后向切分结果的词数不同,则取词数较少的那个。


(2)如果词数相同 :

  • 切分结果相同,则返回任意一个;

  • 切分结果不同,则返回单字较少的那个。


网上看到一个非常好的例子,前向和后向切分后,词数相同,结果不同,返回单字少的那个结果(例子中为后向最大匹配)。



双向最大匹配的python实现如下:


#coding:utf-8
import pandas as pd

class PsegMax:
    def __init__(self, dict_path):
        self.entity_dict, self.max_len = self.load_entity(dict_path)

    def load_entity(self, dict_path):

    def max_forward_seg(self, sent):

    def max_backward_seg(self, sent):

    def max_biward_seg(self, sent):
        """
        双向最大匹配实体标注
        """


        """ 1: 前向和后向的切分结果 """
        words_psg_fw = self.max_forward_seg(sent)
        words_psg_bw = self.max_backward_seg(sent)

        """ 2: 前向和后向的词数 """
        words_fw_size = len(words_psg_fw)
        words_bw_size = len(words_psg_bw)

        """ 3: 前向和后向的词数,则取词数较少的那个 """
        if words_fw_size < words_bw_size: return words_psg_fw

        if words_fw_size > words_bw_size: return words_psg_bw

        """ 4: 结果相同,可返回任意一个 """
        if words_psg_fw == words_psg_bw: return words_psg_fw

        """ 5: 结果不同,返回单字较少的那个 """
        fw_single = sum([1 for i in range(words_fw_size) if len(words_psg_fw[i][0])==1])
        bw_single = sum([1 for i in range(words_fw_size) if len(words_psg_bw[i][0])==1])

        if fw_single < bw_single: return words_psg_fw
        elsereturn words_psg_bw


if __name__ == "__main__":

    dict_path = "medical_ner_dict.csv"
    text = "我最近双下肢疼痛,我该咋办。"

    psg = PsegMax(dict_path)
    words_psg = psg.max_biward_seg(text)

    print(words_psg)


最终的实体打标结果为:




三:实体自动标注


接下来就两种方法来做实体自动打标,构造训练实体识别模型的样本。


一是用:实体词典+双向最大匹配,

二是用:实体词典+jieba词性标注。


01

jieba加载词典


医疗实体词典的格式如下,每一行是实体词和对应的标签:


肾抗针,DRU
肾囊肿,DIS
肾区,REG
肾上腺皮质功能减退症,DIS
肾性高血压,DIS
肾性贫血,DIS
肾血管,ORG
肾脏,ORG
伴脓性渗出,NBP
生理反射存在,NBP


未标注的电子病历内容如下:


患者精神状况好,无发热,诉右髋部疼痛,饮食差,二便正常,查体:神清,各项生命体征平稳,心肺腹查体未见异常。右髋部压痛,右下肢皮牵引固定好,无松动,右足背动脉搏动好,足趾感觉运动正常。


首先导入必要的包,max_seg.py 就是上面写好的双向最大匹配算法。


#encoding=utf8
import os,jieba,csv,random,re
import jieba.posseg as psg
from max_seg import PsegMax

""" 医疗实体词典, 每一行类似:(视力减退,SYM) """
dict_path = "medical_ner_dict.csv"
psgMax = PsegMax(dict_path)

c_root = os.getcwd() + os.sep + "source_data" + os.sep

""" 实体类别 """
biaoji = set(['DIS''SYM''SGN''TES''DRU''SUR''PRE''PT''Dur''TP''REG''ORG''AT''PSB''DEG''FW','CL'])

""" 句子结尾符号,表示如果是句末,则换行 """
fuhao = set(['。','?','?','!','!'])


然后把医疗实体和标签加载到jieba中,同时为了保证由多个词组成的实体不被切开,我们为实体设置较高的权重。


def add_entity(dict_path):
    """
    把实体字典加载到jieba里,
    实体作为分词后的词,
    实体标记作为词性
    """


    dics = csv.reader(open(dict_path,'r',encoding='utf8'))

    for row in dics:

        if len(row)==2:
            jieba.add_word(row[0].strip(),tag=row[1].strip())

            """ 保证由多个词组成的实体词,不被切分开 """
            jieba.suggest_freq(row[0].strip())


我们可以选择用jieba还是双向最大匹配来标注。


def sentence_seg(sentence,mode="jieba"):
    """
    1: 实体词典+jieba词性标注。mode="jieba"
    2: 实体词典+双向最大匹配。mode="max_seg"
    """


    if mode == "jieba"return psg.cut(sentence)
    if mode == "max_seg"return psgMax.max_biward_seg(sentence)


我们来看实体标注(词性标注)的效果:


sentence = "我最近双下肢疼痛。"

""" 1: 不加词典的jieba词性标注 """
[pair('我''r'), pair('最近''f'), pair('双下肢''n'), pair('疼痛''n'), pair('。''x')]

""" 2: 加词典的jieba词性标注 """
[pair('我''r'), pair('最近''f'), pair('双下肢疼痛''SYM'), pair('。''x')]

""" 3: 双向最大匹配的标注 """
[('我''O'), ('最''O'), ('近''O'), ('双下肢疼痛''SYM'), ('。''O')]


02

样本自动标注


这次一共有100篇电子病历,我们按7:2:1的比例划分为训练集、验证集和测试集。


def split_dataset():
    """
    划分数据集,按照7:2:1的比例
    """


    file_all = []
    for file in os.listdir(c_root):
        if "txtoriginal.txt" in file:
            file_all.append(file)

    random.seed(10)       
    random.shuffle(file_all)

    num = len(file_all)
    train_files = file_all[: int(num * 0.7)]
    dev_files = file_all[int(num * 0.7):int(num * 0.9)]
    test_files = file_all[int(num * 0.9):]

    return train_files,dev_files,test_files


然后进行样本自动标注,采用的实体标注格式为BIO。


BIO格式就是说,对于实体词,第一个字标注为B,其他的字标注为I;对于非实体词,每个字都标注为O。


还有一个小细节是句与句之间要留空格。


例子如下:


我 O
最 O
近 O
双 B-SYM
下 I-SYM
肢 I-SYM
疼 I-SYM
痛 I-SYM
。 O

怎 O
么 O
办 O
? O


我们把每个字的标注结果,作为一行,写入到文件中。


代码实现如下:


def auto_label(files, data_type, mode="jieba"):
    """
    不是实体,则标记为O,
    如果是句号等划分句子的符号,则再加换行符,
    是实体,则标记为BI。
    """


    writer = open("example.%s" % data_type,"w",encoding="utf8")

    for file in files:
        fp = open(c_root+file,'r',encoding='utf8')

        for line in fp:

            """ 按词性分词 """
            words = sentence_seg(line,mode)
            for word,pos in words: 
                word,pos = word.strip(), pos.strip()   
                if not (word and pos):
                    continue

                """ 如果词性不是实体的标记,则打上O标记 """
                if pos not in biaoji:

                    for char in word:
                        string = char + ' ' + 'O' + '\n'

                        """ 在句子的结尾换行 """
                        if char in fuhao:
                            string += '\n'

                        writer.write(string)

                else:

                    """ 如果词性是实体的标记,则打上BI标记"""   
                    begin = 0
                    for char in word:

                        if begin == 0:
                            begin += 1
                            string = char + ' ' + 'B-' + pos + '\n'

                        else:
                            string = char + ' ' + 'I-' + pos + '\n'

                        writer.write(string)

    writer.close()

def main():

    """ 1: 加载实体词和标记到jieba """
    add_entity(dict_path)    

    """ 2: 划分数据集 """
    trains, devs, tests = split_dataset()

    """ 3: 自动标注样本 """
    for files, data_type in zip([trains,devs,tests],["train","dev","test"]):
        auto_label(files, data_type,mode="max_seg")

if __name__ == "__main__":

    main()


标注好的样本的部分内容如下:


部 O
分 O
脱 O
痂 O
。 O

双 B-ORG
侧 I-ORG
瞳 I-ORG
孔 I-ORG
正 O
大 O
等 O
圆 O


哈哈,感觉很多人都是用医疗领域的数据来学习实体识别,是因为在其他领域做实体识别的效果不好吗?


五月份要认真整理一下实体识别的模型,不然就是个假NLPer。


祝大家五一节快乐啊!


END


推荐阅读

AINLP年度阅读收藏清单

太赞了!Springer面向公众开放电子书籍,附65本数学、编程、机器学习、深度学习、数据挖掘、数据科学等书籍链接及打包下载

深度学习如何入门?这本“蒲公英书”再适合不过了!豆瓣评分9.5!【文末双彩蛋!】

斯坦福大学NLP组Python深度学习自然语言处理工具Stanza试用

数学之美中盛赞的 Michael Collins 教授,他的NLP课程要不要收藏?

自动作诗机&藏头诗生成器:五言、七言、绝句、律诗全了

From Word Embeddings To Document Distances 阅读笔记

模型压缩实践系列之——bert-of-theseus,一个非常亲民的bert压缩方法

这门斯坦福大学自然语言处理经典入门课,我放到B站了

可解释性论文阅读笔记1-Tree Regularization

征稿启示 | 稿费+GPU算力+星球嘉宾一个都不少

关于AINLP

AINLP 是一个有趣有AI的自然语言处理社区,专注于 AI、NLP、机器学习、深度学习、推荐算法等相关技术的分享,主题包括文本摘要、智能问答、聊天机器人、机器翻译、自动生成、知识图谱、预训练模型、推荐系统、计算广告、招聘信息、求职经验分享等,欢迎关注!加技术交流群请添加AINLPer(id:ainlper),备注工作/研究方向+加群目的。


登录查看更多
1

相关内容

专知会员服务
42+阅读 · 2020年7月29日
实体关系抽取方法研究综述
专知会员服务
177+阅读 · 2020年7月19日
基于多头注意力胶囊网络的文本分类模型
专知会员服务
77+阅读 · 2020年5月24日
专知会员服务
155+阅读 · 2020年4月21日
【斯坦福大学-论文】实体上下文关系路径的知识图谱补全
【AAAI2020知识图谱论文概述】Knowledge Graphs @ AAAI 2020
专知会员服务
133+阅读 · 2020年2月13日
【NLP】文本匹配相关方向打卡点总结
深度学习自然语言处理
21+阅读 · 2019年12月9日
分词那些事儿
AINLP
6+阅读 · 2019年3月26日
NLP 分词的那些事儿
人工智能头条
8+阅读 · 2019年3月15日
R语言自然语言处理:词性标注与命名实体识别
R语言中文社区
7+阅读 · 2019年3月5日
论文浅尝 | 基于知识库的类型实体和关系的联合抽取
开放知识图谱
35+阅读 · 2018年12月9日
【推荐】中文处理(BiLSTM分词)工具包FoolNLTK
机器学习研究会
6+阅读 · 2017年12月27日
Arxiv
11+阅读 · 2019年6月19日
Learning From Positive and Unlabeled Data: A Survey
Arxiv
5+阅读 · 2018年11月12日
Multi-task Deep Reinforcement Learning with PopArt
Arxiv
4+阅读 · 2018年9月12日
VIP会员
相关VIP内容
专知会员服务
42+阅读 · 2020年7月29日
实体关系抽取方法研究综述
专知会员服务
177+阅读 · 2020年7月19日
基于多头注意力胶囊网络的文本分类模型
专知会员服务
77+阅读 · 2020年5月24日
专知会员服务
155+阅读 · 2020年4月21日
【斯坦福大学-论文】实体上下文关系路径的知识图谱补全
【AAAI2020知识图谱论文概述】Knowledge Graphs @ AAAI 2020
专知会员服务
133+阅读 · 2020年2月13日
相关资讯
【NLP】文本匹配相关方向打卡点总结
深度学习自然语言处理
21+阅读 · 2019年12月9日
分词那些事儿
AINLP
6+阅读 · 2019年3月26日
NLP 分词的那些事儿
人工智能头条
8+阅读 · 2019年3月15日
R语言自然语言处理:词性标注与命名实体识别
R语言中文社区
7+阅读 · 2019年3月5日
论文浅尝 | 基于知识库的类型实体和关系的联合抽取
开放知识图谱
35+阅读 · 2018年12月9日
【推荐】中文处理(BiLSTM分词)工具包FoolNLTK
机器学习研究会
6+阅读 · 2017年12月27日
Top
微信扫码咨询专知VIP会员