BiLSTM+CRF命名实体识别:达观杯败走记(上篇)

一 :今日吐槽

去年7月份入职那会,由达观数据主办的信息抽取大赛正在进行中,那是一个命名实体识别的比赛。

听大佬们说, 参加比赛是一种DFS的学习方法,带着问题去学习 ,比按部就班地看书和听课效果更好。

想起多年前, 三天搞定一个题目 的数学建模比赛经历,我激动地直拍高铁的座椅:

这比赛给了俩月呢!

在广州到上海的路上我就组好了队。

拿到比赛数据,小群里开始热火朝天:

  • 把IOB格式改为IOBES格式

  • 把词的长度特征,做embedding,再和字向量拼接

  • 模型可以试试IDCNN+CRF,训练更快

  • NLP中的数据增强不可能不用

  • 地表最强的BERT必须安排一下

两周过去了,自己用keras写好的模型拿了0.66分。

一个月过去了,git clone 的pytorch版 baseline 勉强跑通。

两个月过去了,我把《十强选手方案》搬运到安静的群里,又回到了峡谷。

身体里沉睡的野兽,觉醒了!

战斗,让我忘记疯狂!

球球你阻止我!

二:内容预告

上个月写了篇文章,介绍了怎么用双向最大匹配+实体词典进行实体自动标注。

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

如此一来,有了实体词典,实体识别中最繁琐的样本标注问题就解决了。

目前我手头有一份标注好的医疗实体数据集, 训练集、验证集和测试集的数量分别为:101218 / 7827 / 16804 ,医疗实体有15类:

{'PSB', 'SGN', 'PT', 'TES', 'SUR', 'DIS', 'DRU', 'ORG', 'DEG', 'PRE', 'CL', 'SYM', 'REG', 'Dur', 'FW'}

数据集不小,实体类别够多,又属于专业领域,适合搞事情。

于是这次整理了一个BILSTM+CRF的模型,经过比较细致地优化,在这份数据的测试集上,F1值可达0.97。

在网上找了条和医疗相关的句子,测试结果如下:

{'entities': [{'end': 7, 'start': 5, 'type': 'ORG', 'word': '心脏'},
{'end': 10, 'start': 8, 'type': 'ORG', 'word': '血管'},
{'end': 40, 'start': 36, 'type': 'DIS', 'word': '心血管病'}],

'string': '循环系统由心脏、血管和调节血液循环的神经体液组织构成,循环系统疾病也称为心血管病。'}

BILSTM+CRF尽管是实体识别的一个BaseLine,但是数据预处理、特征构造、损失计算和维特比解码,都有不少需要注意的点。

看了网上的一些代码,你是否和我一样,还有以下疑问:

  • 样本和标签是否需要加<start>和<end>标记

  • 怎么把IOB格式转换为IOBES格式

  • 论文源码中的大小写特征(Capitalization feature)怎么借鉴

  • 怎么计算CRF损失

  • 怎么对损失做MASK

这篇文章争取把以上疑问解决,并得到一个F1值达0.97的模型。

完整的数据和代码都已经上传github,这次也有好好写REDME.md。

https://github.com/DengYangyong/medical_entity_recognize

三:预修知识

BILSTM+CRF的模型出自这篇论文:

《Neural Architectures for Named Entity Recognition》

论文介绍了模型结构、损失函数、数据处理格式和参数的配置,是必看的第一手资料。

但是论文对损失函数的介绍,以及如何用动态规划计算损失和解码,不是很详细。

推荐看这两篇文章:

BiLSTM上的CRF,用命名实体识别任务来解释CRF(2)损失函数

BiLSTM上的CRF,用命名实体识别任务来解释CRF(3)推理

喂,推荐这两篇文章真不是因为 园长就在我背后 啊,是因为真的比较清楚啊!

四:数据介绍

代码结构如下:

这次的数据集由医疗电子病历标注而成,标注格式为IOB,每个句子是一个样本,句子之间用空格隔开。

O表示这个字不是实体,B表示这个字是实体的开头,实体除开头以外的字,都用I标记。

如下就是两个样本。

入 O
院 O
诊 O
断 O
:O
腰 B-DIS
椎 I-DIS
间 I-DIS
盘 I-DIS
突 I-DIS
出 I-DIS
症 I-DIS
( O
L O
4 O
- O
S O
1 O
) O
。O

诊 O
疗 O
经 O
过 O
:O
完 O
善 O
心 B-TES
电 I-TES
图 I-TES
、 O
胸 B-TES
透 I-TES
、 O
化 B-TES
验 I-TES
等 O
相 O
关 O
检 O
查 O
。O

五:数据预处理

数据预处理的代码如下。

一共是六步:

首先将标注好的数据集,整理成样本,每个样本是一个句子。

然后将IOB格式转换成IOBES格式。

接着根据训练集和预训练的字向量,建立字与id的映射,标签与id的映射。

接着加载预训练的字向量。

接着把样本和标签加上<start>和<end>的标记,转化为id。

最后保存样本、标签、映射和字向量。

#coding:utf-8
from data_utils import char_mapping,tag_mapping,augment_with_pretrained
from data_utils import zero_digits,iob, iob_iobes, get_seg_features
from logs.logger import logger
from params import params
import os
import pickle
from tqdm import tqdm
import numpy as np
import torch

config = params()


def build_dataset():

train_sentences = load_sentences(
config.train_file, config.lower, config.zero
)
dev_sentences = load_sentences(
config.dev_file, config.lower, config.zero
)
test_sentences = load_sentences(
config.test_file, config.lower, config.zero
)
logger.info("成功读取标注好的数据")


update_tag_scheme(
train_sentences, config.tag_schema
)
update_tag_scheme(
test_sentences, config.tag_schema
)
update_tag_scheme(
dev_sentences, config.tag_schema
)
logger.info("成功将IOB格式转化为IOBES格式")


if not os.path.isfile(config.map_file):
char_to_id, id_to_char, tag_to_id, id_to_tag = create_maps(train_sentences)
logger.info("根据训练集建立字典完毕")
else:
with open(config.map_file, "rb") as f:
char_to_id, id_to_char, tag_to_id, id_to_tag = pickle.load(f)
logger.info("已有字典文件,加载完毕")


emb_matrix = load_emb_matrix(char_to_id)
logger.info("加载预训练的字向量完毕")


train_data = prepare_dataset(
train_sentences, char_to_id, tag_to_id, config.lower
)
dev_data = prepare_dataset(
dev_sentences, char_to_id, tag_to_id, config.lower
)
test_data = prepare_dataset(
test_sentences, char_to_id, tag_to_id, config.lower
)
logger.info("把样本和标签处理为id完毕")
logger.info("%i / %i / %i sentences in train / dev / test." % (
len(train_data), len(dev_data), len(test_data))
)

with open(config.data_proc_file, "wb") as f:
pickle.dump([train_data,dev_data,test_data], f)
pickle.dump([char_to_id,id_to_char,tag_to_id,id_to_tag], f)
pickle.dump(emb_matrix, f)

return train_data,dev_data,test_data, char_to_id, tag_to_id, emb_matrix

01

构造样本

由于数据集中,每一行是一个字和对应的标签,而样本是一个句子,那么需要把字添加到句子中,遇到换行符,则表明句子已经结束,下一个字属于另一个句子。

另外,数据处理的一个小技巧是,把数据集中的数字,全部用0替换,然后大写字母转化为小写。当然,这个可以自行选择。

def load_sentences(path, lower, zero):
"""
加载训练样本,一句话就是一个样本。
训练样本中,每一行是这样的:长 B-Dur,即字和对应的标签
句子之间使用空行隔开的
return : sentences: [[[['无', 'O'], ['长', 'B-Dur'], ['期', 'I-Dur'],...]]
"""


sentences = []
sentence = []

for line in open(path, 'r',encoding='utf8'):

""" 如果包含有数字,就把每个数字用0替换 """
line = line.rstrip()
line = zero_digits(line) if zero else line

""" 如果不是句子结束的换行符,就继续添加单词到句子中 """
if line:
word_pair = ["<unk>", line[2:]] if line[0] == " " else line.split()
assert len(word_pair) == 2
sentence.append(word_pair)

else:

""" 如果遇到换行符,说明一个句子处理完毕 """
if len(sentence) > 0:
sentences.append(sentence)
sentence = []

""" 最后一个句子没有换行符,处理好后,直接添加到样本集中 """
if len(sentence) > 0:
sentences.append(sentence)

return sentences

处理好后,每个样本的如下:

train_sentences[0]
[['无', 'O'], ['长', 'B-Dur'], ['期', 'I-Dur'], ['0', 'O'], ['0', 'O'], ['0', 'O'], ['年', 'O']

02

转换为IOBES格式

论文中作者是将IOB格式转化为了IOBES格式,也就是:

如果实体只有一个字,那就用S标记。

如果实体有两个字或以上,那么开头用B标记,结尾用E标记,中间的字用I标记。

IOBES这种标记方式按道理是更好的,因为提供了更丰富的信息,用特定的符号来标记开头和结尾,便于在预测时提取实体。

比如以下就是预测时,提取实体的格式:

{'entities': [{'end': 7, 'start': 5, 'type': 'ORG', 'word': '心脏'},
{'end': 10, 'start': 8, 'type': 'ORG', 'word': '血管'},
{'end': 40, 'start': 36, 'type': 'DIS', 'word': '心血管病'}],

'string': '循环系统由心脏、血管和调节血液循环的神经体液组织构成,循环系统疾病也称为心血管病。'}

实际转换的时候,我们先对IOB格式进行检查,如果有不合理的,则纠正。

比如下面这个就是错误的格式,I不能作为开头,O也不可能为实体的标记。

[O,I-ORG,B-ORG,O,O-ORG,...]

纠正之后,再转换为IOBES格式。

具体的纠正和转换函数,直接用就好了,自己写是很难写出来的(-.-)。

def update_tag_scheme(sentences, tag_scheme):
"""
1:检查样本的标签是否为正确的IOB格式,如果不对则纠正。
2:将IOB格式转化为IOBES格式。
"""


for i, s in enumerate(sentences):

tags = [w[-1] for w in s]

if not iob(tags):
s_str = '\n'.join(' '.join(w) for w in s)
print('Sentences should be given in IOB format! \n' +
'Please check sentence %i:\n%s' % (i, s_str))

""" 如果用IOB格式训练,则检查并纠正一遍 """
if tag_scheme == 'iob':

for word, new_tag in zip(s, tags):
word[-1] = new_tag

elif tag_scheme == 'iobes':

""" 将IOB格式转化为IOBES格式 """
new_tags = iob_iobes(tags)
for word, new_tag in zip(s, new_tags):
word[-1] = new_tag

转换后的样本格式如下:

[['突', 'B-SYM'], ['发', 'E-SYM'], ['右', 'B-REG'], ['侧', 'I-REG'], ['肢', 'I-REG'], ['体', 'E-REG'],...]

03

建立字、标签到id的映射

下面的两个函数分别用来构造字和id的映射、标签和id的映射,在data_utils.py中。

首先create_dico这个函数统计字、标签的频率字典,再按频率降序,构造item到id的映射。

因为要对每个batch中不等长的输入序列做zero pad,让batch中样本长度一致, 所以给<pad>标记设定最高的频率,使它的id为0。

又因为这是加了CRF的模型,所以 需要在样本和标签的前后加<start>和<end>的标记。

以下 构造字和id的映射:

def char_mapping(sentences, lower):
"""
建立字和id对应的字典,按频率降序排列
由于用了CRF,所以需要在句子前后加<start>和<end>
那么在字典中也加入这两个标记
"""

chars = [[x[0].lower() if lower else x[0] for x in s] for s in sentences]
dico = create_dico(chars)
dico["<pad>"] = 100000003
dico['<unk>'] = 100000002
dico["<start>"] = 100000001
dico["<end>"] = 100000000
char_to_id, id_to_char = create_mapping(dico)
logger.info("Found %i unique words (%i in total)" % (len(dico), sum(len(x) for x in chars)))

return dico, char_to_id, id_to_char

以下构造标签和id的映射:

def tag_mapping(sentences):
"""
建立标签和id对应的字典,按频率降序排列
由于用了CRF,所以需要在标签前后加<start>和<end>
那么在字典中也加入这两个标记
"""


f = open('data/tag_to_id.txt','w',encoding='utf8')
f1 = open('data/id_to_tag.txt','w',encoding='utf8')

tags = [[x[-1] for x in s] for s in sentences]

dico = create_dico(tags)
dico["<pad>"] = 100000002
dico["<start>"] = 100000001
dico["<end>"] = 100000000
tag_to_id, id_to_tag = create_mapping(dico)

logger.info("Found %i unique named entity tags" % len(dico))
for k,v in tag_to_id.items():
f.write(k+":"+str(v)+"\n")
for k,v in id_to_tag.items():
f1.write(str(k)+":"+str(v)+"\n")
return dico, tag_to_id, id_to_tag

为啥要加<start>和<end>标记呢?

以下内容引用自论文。

y(0) and y(n) are the start and end tags of a sentence, that we add to the set of possible tags.

以下内容引用自上面第一篇文章:

为了使transition评分矩阵更健壮,我们将添加另外两个标签,START和END 。START是指一个句子的开头,而不是第一个单词。END表示句子的结尾。

ronghuaiyang,公众号:AI公园 BiLSTM上的CRF,用命名实体识别任务来解释CRF(2)损失函数

以下为转移矩阵的样子,我们可以看到从 START到 I-Person 的概率非常低(0.007),而从START到B-Person的概率非常高(0.8)。

这可以让转移矩阵学习到有用的约束:让一个句子的第一个字标记为I的概率非常低,标记为B的概率非常高,从而提高标注的准确率。

另外,由于使用了预训练的字向量,我们需要把在字向量中但是不在训练集中的字,加入到字与id的映射中。

下面这段代码用到了上面两个函数。

def create_maps(sentences):
"""
建立字和标签的字典
"""


if config.pre_emb:

""" 首先利用训练集建立字典 """
dico_chars_train, _, _ = char_mapping(sentences, config.lower)

""" 预训练字向量中的字,如果不在上面的字典中,则加入 """
dico_chars, char_to_id, id_to_char = augment_with_pretrained(dico_chars_train.copy(),
config.emb_file)

else:

""" 只利用训练集建立字典 """
_, char_to_id, id_to_char = char_mapping(sentences, config.lower)

""" 利用训练集建立标签字典 """
_, tag_to_id, id_to_tag = tag_mapping(sentences)

with open(config.map_file, "wb") as f:
pickle.dump([char_to_id, id_to_char, tag_to_id, id_to_tag], f)

return char_to_id, id_to_char, tag_to_id, id_to_tag

建立的字和id的映射、标签和id的映射如下:

char_to_id
{'<pad>': 0, '<unk>': 1, '<start>': 2, '<end>': 3, '0': 4, ',': 5, ':': 6, '。': 7, '无': 8, '、': 9, '常': 10, ...}

tag_to_id
{'<pad>': 0, '<start>': 1, '<end>': 2, 'O': 3, 'I-TES': 4, 'I-DIS': 5, 'I-SGN': 6, 'B-TES': 7, ...}

04

加入<start>和<end>的标记

接着在样本(句子)和标签的前后加入<start>和<end>的标记,并转化为id。

如果模型训练好了,输入一条句子预测,那么句子没有自带标签,所以test=True

时,tags_idx随便搞,只要和句子长度一致即可。

def prepare_dataset(sentences, char_to_id, tag_to_id, lower=False, test=False):

"""
把文本型的样本和标签,转化为index,便于输入模型
需要在每个样本和标签前后加<start>和<end>
"""


def f(x): return x.lower() if lower else x

data = []
for s in sentences:

chars = [w[0] for w in s]
tags = [w[-1] for w in s]

""" 句子转化为index """
chars_idx = [char_to_id[f(c) if f(c) in char_to_id else '<unk>'] for c in chars]

""" 对句子分词,构造词的长度特征 """
segs_idx = get_seg_features("".join(chars))

""" 每个样本前后加<start>和<end> """
chars_idx = [char_to_id["<start>"]] + chars_idx + [char_to_id["<end>"]]
segs_idx = [0] + segs_idx + [0]

""" 把标签转化为index, 标签前后加<start>和<end> """
tags = ["<start>"] + tags + ["<end>"]
if not test:
tags_idx = [tag_to_id[t] for t in tags]

else:
tags_idx = [tag_to_id["<pad>"] for _ in tags]

assert len(chars_idx) == len(segs_idx) == len(tags_idx)
data.append([chars_idx, segs_idx, tags_idx])

return data

另外注意到有个segs_idx,这是什么?

这是对句子进行分词后,提取的词长度特征,作为字向量特征的补充。

每个字的长度特征为0~3的一个id,后面我们把这个id处理为20维的向量,和100维的字向量进行拼接,得到120维的向量。

具体的解释看下面的代码。

def get_seg_features(string):
"""
对句子分词,构造词的长度特征,为BIES格式,
[对]对应的特征为[0],
[句子]对应的特征为[1,3],
[中华人民]对应的特征为[1,2,2,3]
"""

seg_feature = []

for word in jieba.cut(string):
if len(word) == 1:
seg_feature.append(0)
else:
tmp = [2] * len(word)
tmp[0] = 1
tmp[-1] = 3
seg_feature.extend(tmp)
return seg_feature

比如下面这个句子的分词特征为:

句子:
"循环系统由心脏、血管和调节血液循环的神经体液组织构成"

分词结果:
['循环系统', '由', '心脏', '、', '血管', '和', '调节', '血液循环', '的', '神经', '体液', '组织', '构成']

长度特征:
[1, 2, 2, 3, 0, 1, 3, 0, 1, 3, 0, 1, 3, 1, 2, 2, 3, 0, 1, 3, 1, 3, 1, 3, 1, 3]

这个是怎么来的?

论文的源码中用到了一个叫做Capitalization feature 的特征,也就是单词的大小写特征,也是作为嵌入,和单词向量进行拼接。

def cap_feature(s):
"""
Capitalization feature:
0 = low caps
1 = all caps
2 = first letter caps
3 = one capital (not first letter)
"""

if s.lower() == s:
return 0
elif s.upper() == s:
return 1
elif s[0].upper() == s[0]:
return 2
else:
return 3

所以我们的分词特征借鉴了上面的思路,应该可以提供更丰富的信息。

六:batch 分桶

把数据构造成batch,没有用pytorch的 Dataset 和 DataLoader 这两个函数,因为不方便做 batch 分桶。

啥叫batch分桶?

这个叫法很土,意思是把所有样本先按长度排序,生成batch的时候,长度相近的样本在一个batch内,batch内部按最长的样本长度进行zero pad。

而batch之间的长度不同,最大程度减少了zero pad 的数量,从而加快训练速度。

#coding:utf-8
import math
import random
import torch

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class BatchManager(object):

def __init__(self, data, batch_size):
self.batch_data = self.sort_and_pad(data, batch_size)
self.len_data = len(self.batch_data)

def sort_and_pad(self, data, batch_size):
"""
把样本按长度排序,然后分batch,再pad
batch之间的输入长度不同,可以减少zero pad,加速计算
"""


num_batch = int(math.ceil(len(data) / batch_size))
sorted_data = sorted(data, key=lambda x: len(x[0]))

batch_data = list()
for i in range(num_batch):

""" 进行zero pad """
batch_data.append(self.pad_data(
sorted_data[i*int(batch_size): (i+1)*int(batch_size)])
)

return batch_data

@staticmethod
def pad_data(data):
"""
构造一个mask矩阵,对pad进行mask,不参与loss的计算
"""


batch_chars_idx = []
batch_segs_idx = []
batch_tags_idx = []
batch_mask = []

max_length = max([len(sentence[0]) for sentence in data])
for line in data:
chars_idx, segs_idx, tags_idx = line

padding = [0] * (max_length - len(chars_idx))

batch_chars_idx.append(chars_idx + padding)
batch_segs_idx.append(segs_idx + padding)
batch_tags_idx.append(tags_idx + padding)
batch_mask.append([1] * len(chars_idx) + padding)

batch_chars_idx = torch.LongTensor(batch_chars_idx).to(device)
batch_segs_idx = torch.LongTensor(batch_segs_idx).to(device)
batch_tags_idx = torch.LongTensor(batch_tags_idx).to(device)
batch_mask = torch.tensor(batch_mask,dtype=torch.uint8).to(device)

return [batch_chars_idx, batch_segs_idx, batch_tags_idx, batch_mask]

def iter_batch(self, shuffle=True):

if shuffle:
random.shuffle(self.batch_data)

for idx in range(self.len_data):
yield self.batch_data[idx]

另外,由于对batch内不够长的样本进行了 zero pad,训练时,模型会预测每个字包括<pad>的标签,并用发射概率矩阵和转移概率矩阵来计算loss。

那么<pad>的标签概率参与loss计算,会导致loss的计算有偏差,所以我们需要准备一个mask矩阵,把<pad>的标签概率mask掉。

chars:
["神","经","体","液","组","织","<pad>","<pad>","<pad>"]

mask:
[1,1,1,1,1,1,0,0,0]

好了,上篇就介绍数据预处理和batch的生成,下篇介绍模型和训练。

参考资料:

1:《Neural Architectures for Named Entity Recognition》

2:《BiLSTM上的CRF,用命名实体识别任务来解释CRF(2)损失函数》

推荐阅读

AINLP年度阅读收藏清单

薅当当羊毛的机会又!双!!叒!!!叕!!!来了

中文命名实体识别工具(NER)哪家强?

学自然语言处理,其实更应该学好英语

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

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

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

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

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

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

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

关于AINLP

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

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章