实战是学习一门技术最好的方式,也是深入了解一门技术唯一的方式。因此,NLP专栏计划推出一个实战专栏,让有兴趣的同学在看文章之余也可以自己动手试一试。
本篇介绍自然语言处理中一种非常重要的任务:命名实体识别。因为最常见的是Bilstm+CRF模型进行实体识别,本文介绍介绍另外一种有效的模型,Dilated-CNN+CRF模型,但是两种模型的代码都会给出。
作者&编辑 | 小Dream哥
1 命名实体识别任务介绍
笔者在这篇文章中,曾经系统的介绍过命名实体识别任务的相关概念和语料标注方式,不了解的同学可以先阅读这篇文章:
关于Bilstm和Dilated-CNN两个模型理论方面的内容,笔者在这篇文章中做了详细的介绍,不了解的同学可以先阅读这篇文章:
话不多说,既然是实战篇,我们就赶紧开始吧。
2 数据预处理
1) 查看数据格式
先了解一下数据格式,方便后面进行处理。如下图所示,语料为标准的BIO标注方式,每个字和标记之间用空格隔开,语料之间用一个空行隔开。
2)读取训练数据
def load_sentences(path, lower, zeros):
"""
加载训练,测试,验证数据的函数
"""
sentences = []
sentence = []
num = 0
for line in codecs.open(path, 'r', 'utf8'):
num+=1
line = zero_digits(line.rstrip()) if zeros else line.rstrip()
if not line:
if len(sentence) > 0:
if 'DOCSTART' not in sentence[0][0]:
sentences.append(sentence)
sentence = []
else:
if line[0] == " ":
line = "$" + line[1:]
word = line.split()
else:
word= line.split( )
assert len(word) == 2
sentence.append(word)
if len(sentence) > 0:
if 'DOCSTART' not in sentence[0][0]:
sentences.append(sentence)
return sentences
上面这个函数是用来读取训练,测试,验证数据的,该函数返回一个列表,如下图所示:
3) 标记格式转化
因为原始语料用的是BIO标注方法,但是BIOES标注形式包含了实体内更多的位置和边界信息,最好将其转化为BIOES的形式,方法如下:
def update_tag_scheme(sentences, tag_scheme):
"""
将IOB格式转化为BIOES格式。
"""
for i, s in enumerate(sentences):
tags = [w[-1] for w in s]
# 保证语料上BIO格式
if not iob2(tags):
s_str = '\n'.join(' '.join(w) for w in s)
##产生异常
if tag_scheme == 'iob':
# 转化为BIOES
for word, new_tag in zip(s, tags):
word[-1] = new_tag
elif tag_scheme == 'iobes':
new_tags = iob_iobes(tags)
for word, new_tag in zip(s, new_tags):
word[-1] = new_tag
else:
raise Exception('Unknown tagging scheme!')
看看效果如何:
4)构造字典
经过上述步骤,虽然我们将数据读到了一个列表里组织了起来,但是一个一个的汉字及其标签,后面的LSTM或者CNN网络是没有办法处理的,需要进行词嵌入。在此之前需要构造2个字典,汉字字典和标签字典,然后将上面的汉字和标签转化成字典里的序号。看看代码是怎么做的:
def char_mapping(sentences, lower):
#生成字典和mapping
chars = [[x[0].lower() if lower else x[0] for x in s] for s in sentences]
dico = create_dico(chars)
dico["<PAD>"] = 10000001
dico['<UNK>'] = 10000000
char_to_id, id_to_char = create_mapping(dico)
#print("Found %i unique words (%i in total)" % (
# len(dico), sum(len(x) for x in chars)
#))
return dico, char_to_id, id_to_char
def create_dico(item_list):
#根据传入的列表,生成一个字典
assert type(item_list) is list
dico = {}
for items in item_list:
for item in items:
if item not in dico:
dico[item] = 1
else:
dico[item] += 1
return dico
def create_mapping(dico):
#生成连个字典,id->word word->id
sorted_items = sorted(dico.items(), key=lambda x: (-x[1], x[0]))
id_to_item = {i: v[0] for i, v in enumerate(sorted_items)}
item_to_id = {v: k for k, v in id_to_item.items()}
return item_to_id, id_to_item
上述过程并不难理解,大家好好看看,我们看看生成的字典:
5) 根据字典组织好训练数据
训练时,应该是输入汉字序列,然后预测出一个BIO序列,所以我们的输入数据还需要整理,将汉字和标记分开,得到汉字序列及其标记序列,并且要将汉字及其标记转换成他们在字典里的序号,转化结果如下:
至此,我们将训练数据结构化的组织了起来,放在一个大列表里,并且列表里的数据通过生成的字典转化成了id。
3 模型实现
1)word embedding
我们将语料中的中文换成了他们在字典里的ID,了解词向量的同学都知道我们还需要对词进行词嵌入,后面的深度学习模型才能进行处理。不了解的同学可以找词向量的文章看一下。
下面看看如何实现:
def embedding_layer(self, char_inputs, seg_inputs, config, name=None):
"""
:param char_inputs: one-hot encoding of sentence
:param seg_inputs: segmentation feature
:param config: wither use segmentation feature
:return: [1, num_steps, embedding size],
"""
embedding = []
with tf.variable_scope("char_embedding" if not name else name), tf.device('/cpu:0'):
# 生成一个表,用于后面查表
self.char_lookup = tf.get_variable(
name="char_embedding",
shape=[self.num_chars, self.char_dim],
initializer=self.initializer)
# 查表,将词向量化
embedding.append(tf.nn.embedding_lookup(self.char_lookup, char_inputs))
# 分词的维度,可先不关注
if config["seg_dim"]:
with tf.variable_scope("seg_embedding"), tf.device('/cpu:0'):
self.seg_lookup = tf.get_variable(
name="seg_embedding",
shape=[self.num_segs, self.seg_dim],
initializer=self.initializer)
embedding.append(tf.nn.embedding_lookup(self.seg_lookup, seg_inputs))
embed = tf.concat(embedding, axis=-1)
return embed
也许有同学会问,这里的词向量表是随机初始化的。如果我之前训练好了一份词向量,该如何使用呢?可以在定义好模型之后,char_lookup.assign函数为该词向量表赋值,参考如下:
通过embedding_layer,就将形状为[bacth_size, seq_length]的输入数据转化为形状为[bacth_size, seq_length,embeding_dim]的矩阵了。词向量化几乎是所有自然语言处理任务所必须要经过的步骤,读者务必弄明白。
2)构建dilated CNN模型
首先,做一些数据的维度变化,并做一次CNN特征提取。
def IDCNN_layer(self, model_inputs,
name=None):
#param idcnn_inputs: [batch_size, num_steps, emb_size]
#return: [batch_size, num_steps, cnn_output_width]
model_inputs = tf.expand_dims(model_inputs, 1)
reuse = False
if self.dropout == 1.0:
reuse = True
with tf.variable_scope("idcnn" if not name else name):
shape=[1, self.filter_width, self.embedding_dim,
self.num_filter]
filter_weights = tf.get_variable(
"idcnn_filter",
shape=[1, self.filter_width, self.embedding_dim,
self.num_filter],
initializer=self.initializer)
#输入的尺寸 = [batch, in_height, in_width, in_channels]
#卷积核尺寸 [filter_height, filter_width, in_channels, out_channels]
layerInput = tf.nn.conv2d(model_inputs,
filter_weights,
strides=[1, 1, 1, 1],
padding="SAME", name="init_layer",
use_cudnn_on_gpu=True)
finalOutFromLayers = []
totalWidthForLastDim = 0
#重复4次增强特征提取能力
for j in range(self.repeat_times):
#每次有3次卷积操作,前两次卷积膨胀系数为
1,后一次膨胀系数为2
for i in range(len(self.layers)):
dilation = self.layers[i]['dilation']
isLast = True if i == (len(self.layers) - 1) else False
with tf.variable_scope("atrous-conv-layer-
%d" % i,
reuse=True
if (reuse or j > 0) else False):
#卷积核
w = tf.get_variable(
"filterW",
shape=[1, self.filter_width,
self.num_filter,
self.num_filter],
initializer=tf.contrib.layers.
xavier_initializer())
b = tf.get_variable("filterB", shape=[self.num_filter])
conv = tf.nn.atrous_conv2d(layerInput,w,
rate=dilation,
padding="SAME")
conv = tf.nn.bias_add(conv, b)
conv = tf.nn.relu(conv)
if isLast:
finalOutFromLayers.append(conv)
totalWidthForLastDim += self.num_filter
layerInput = conv
finalOut = tf.concat(axis=3, values=finalOutFromLayers)
keepProb = 1.0 if reuse else 0.5
finalOut = tf.nn.dropout(finalOut, keepProb)
finalOut = tf.squeeze(finalOut, [1])
finalOut = tf.reshape(finalOut, [-1, totalWidthForLastDim])
self.cnn_output_width = totalWidthForLastDim
return finalOut
重复做卷积操作进行特征提取,膨胀卷积可以扩大卷积核的感受野,提高卷积操作对长序列特征的提取能力。同时加深深度,也可以进一步提高特征提取能力。如下,repeat times为4,dilation数组为[1,1,2]。
3)project层
用一个中间层,将CNN的输出转化为shape为[batch_size, num_steps, num_tags]的输出,后面可以接softmax作为输出,也可以接crf计算loss
4)CRF层计算loss
采用CRF计算损失几乎是序列标注问题的标准做法,关于CRF的相关问题,笔者在机器学习模型介绍条件随机场CRF一文中做了详细介绍,不了解的读者可以出门看看。
4 开始训练
在main文件开头的参数配置中,选择train为True:
flags.DEFINE_boolean("train", True, "Whether train the model")
执行python main.py即可以开始进行训练,学习率,batch_size,dropout等超参数,读者可以自行调整。
训练开始后,会有如下的打印信息,读者可以观察loss下降的趋势。
训练结束后,会在ckpt文件夹生成模型文件。
5 模型测试
在main文件开头的参数配置中,选择train为False:
flags.DEFINE_boolean("train", False, "Whether train the model")
我们看看结果是怎么样的:
输入“明天我想去深圳市”模型将“明天”作为TIME提取出来了,将“深圳市”作为LOC提取出来了。
至此,介绍了如何利用构建DI-CNN模型进行命名实体识别,代码在我们有三AI的github可以下载:https://github.com/longpeng2008/yousan.ai/tree/master/natural_language_processing
找到ner文件夹,执行python3 main.py就可以训练或者测试了。
NER是一个非常基础,但是非常重要的任务,在具体的操作中,相信大家能够更为细致的体会NER任务的真正作用和意涵。
我们也会在知识星球还介绍了基于BERT来做NER的方法,感兴趣扫描下面的二维码了解。
联系客服