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

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


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


最终的实体打标结果为:
可以看到,[疼痛] 这个实体没有被匹配出来,因为[双下肢疼痛]的粒度更大,表达的含义更明确,这也是最大匹配的意义。
前向最大匹配的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
else: return 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。
祝大家五一节快乐啊!