BilSTM 实体识别_NLP实战-中文命名实体识别

前言:
本文主要以PyTorch为核心工具开发多种模型(如HMM、CRF、Bi-LSTM及Bi-LSTM+CRF)用于解决中文命名实体识别问题。文章将避免过多复杂的数学推导,并从直观角度对各模型的工作原理进行简要说明。代码实现将是全文的核心内容。
本文的目录结构如下:

概览
任务描述
首先,我们明确一下命名实体识别的概念:
命名实体识别(英语:Named Entity Recognition),简称 NER ,是指对文本中具有特定意义的关键信息进行识别,并将其归类汇总;其主要内容包括人名信息(如姓名)、地名信息(如地点)、机构名称(如公司)、专有名词(如品牌)、时间信息(如日期)以及数量值(如金额)等数据类型的信息。
举个例子,假如有这么一句话:
ACM公布深度学习领域的三位先驱者Yoshua Bengio、Yann LeCun以及Geoffrey Hinton赢得了2019年的图灵奖。
那么NER的任务就是从这句话中提取出
- 机构名: Association for Computing Machinery (ACM)
- 人名: Yoshua Bengio , Yann LeCun , Geoffrey Hinton
- 时间: 于2019年
- 专有名词: Turing Award (图灵奖)
目前在NER领域表现较为突出的模型主要采用深度学习或统计学习的方法进行构建,在显著特点上均需要依赖于大规模的数据进行训练。为了便于使读者在查看代码时能对数据的具体形式有一个清晰的理解,在下文将详细介绍本项目所使用的数据集格式。
数据集
该研究的数据集源自论文《ACL 2018 Chinese NER using Lattice LSTM》中的公开资料,并由新浪财经平台收集整理。该数据集的具体格式如下所述:每条记录包含一个汉字及其相应的标签信息(标注),标签体系遵循BIOES模式(其中B代表实体的起始位置、E代表实体的终结位置、I代表实体内部的位置标记、O代表非实体位置)。为了提高数据呈现的清晰度与可读性,在不同段落之间通常会留有单个空行。
美 B-LOC
国 E-LOC
的 O
华 B-PER
莱 I-PER
士 E-PER
我 O
跟 O
他 O
谈 O
笑 O
风 O
生 O
运行结果
以下四种不同的模型同时作为集成这四个模型的基础,在预测结果上表现出较高的准确率(取最好)
- HMM : 91.22%
- CRF: 95.43%
- BiLSTM: 95.44%
- BiLSTM+CRF: 95.75%
- Ensemble: 95.89%
最后一行Ensemble是通过综合这四个模型的预测结果,并采用投票机制来决定最终的预测结果。
下面本文将详细接受每种模型的实现:
统计学习的方法
隐马尔可夫模型(Hidden Markov Model,HMM)
隐马尔可夫模型通过一个隐藏的马尔可夫链来描述不可观察的状态序列,并基于每个状态生成相应的观测序列形成观测序列的过程(李航 统计学习方法)。隐马尔可夫模型由初始状态分布、状态转移概率矩阵以及观测概率矩阵共同决定。
对于过于学术化的定义不必过于焦虑。我们需要了解的是:NER本质上是一种基于序列标注的技术,在运用隐马尔科夫模型处理NER这类序列标注问题时,在观察层面我们看到的是由汉字构成的连续文本(观察序列),而隐藏在背后的则是每个汉字所对应的标签信息(状态序列)。
涉及HMM的三大核心要素的具体说明包括以下几点:首先是一个明确的状态集合;其次是一个完整的状态序列;最后是一个观察到的数据序列;这些特征共同构成了隐马尔可夫模型的基本框架
,那么若前一个词的标注为
,则下一个词的标注为
的概率为
),
观测概率矩阵 指在特定标注条件下生成该词的可能性。基于HMM的基本组成要素,我们能够构建以下形式的HMM模型:
class HMM(object):
def __init__(self, N, M):
"""Args:
N: 状态数,这里对应存在的标注的种类
M: 观测数,这里对应有多少不同的字
"""
self.N = N
self.M = M
# 状态转移概率矩阵 A[i][j]表示从i状态转移到j状态的概率
self.A = torch.zeros(N, N)
# 观测概率矩阵, B[i][j]表示i状态下生成j观测的概率
self.B = torch.zeros(N, M)
# 初始状态概率 Pi[i]表示初始时刻为状态i的概率
self.Pi = torch.zeros(N)
一旦建立起了模型架构(即HMM),自然会涉及到如何进行模型训练)。而HMM的学习过程对应隐马尔可夫模型的学习问题(李航 统计学习方法),其核心便是基于最大似然原理估算出三要素:初始状态分布、状态转移概率矩阵以及观测概率矩阵)。为了更好地理解这一过程,在估算初始状态分布时:假定某个特定标记出现在所有样本的第一位字符位置的次数为k,则其作为第一位字符的概率就可以近似计算为k/N(其中N表示样本总数)。听起来确实是一个直观而简洁的方法!通过这种方法我们可以估算出HMM的所有三要素,并附上相关代码如下:
class HMM(object):
def __init__(self, N, M):
....
def train(self, word_lists, tag_lists, word2id, tag2id):
"""HMM的训练,即根据训练语料对模型参数进行估计,
因为我们有观测序列以及其对应的状态序列,所以我们
可以使用极大似然估计的方法来估计隐马尔可夫模型的参数
参数:
word_lists: 列表,其中每个元素由字组成的列表,如 ['担','任','科','员']
tag_lists: 列表,其中每个元素是由对应的标注组成的列表,如 ['O','O','B-TITLE', 'E-TITLE']
word2id: 将字映射为ID
tag2id: 字典,将标注映射为ID
"""
assert len(tag_lists) == len(word_lists)
# 估计转移概率矩阵
for tag_list in tag_lists:
seq_len = len(tag_list)
for i in range(seq_len - 1):
current_tagid = tag2id[tag_list[i]]
next_tagid = tag2id[tag_list[i+1]]
self.A[current_tagid][next_tagid] += 1
# 一个重要的问题:如果某元素没有出现过,该位置为0,这在后续的计算中是不允许的
# 解决方法:我们将等于0的概率加上很小的数
self.A[self.A == 0.] = 1e-10
self.A = self.A / self.A.sum(dim=1, keepdim=True)
# 估计观测概率矩阵
for tag_list, word_list in zip(tag_lists, word_lists):
assert len(tag_list) == len(word_list)
for tag, word in zip(tag_list, word_list):
tag_id = tag2id[tag]
word_id = word2id[word]
self.B[tag_id][word_id] += 1
self.B[self.B == 0.] = 1e-10
self.B = self.B / self.B.sum(dim=1, keepdim=True)
# 估计初始状态概率
for tag_list in tag_lists:
init_tagid = tag2id[tag_list[0]]
self.Pi[init_tagid] += 1
self.Pi[self.Pi == 0.] = 1e-10
self.Pi = self.Pi / self.Pi.sum()
在模型经过完整训练后,在完成参数优化的基础上
它实现的细节如下:
class HMM(object):
...
def decoding(self, word_list, word2id, tag2id):
"""
使用维特比算法对给定观测序列求状态序列, 这里就是对字组成的序列,求其对应的标注。
维特比算法实际是用动态规划解隐马尔可夫模型预测问题,即用动态规划求概率最大路径(最优路径)
这时一条路径对应着一个状态序列
"""
# 问题:整条链很长的情况下,十分多的小概率相乘,最后可能造成下溢
# 解决办法:采用对数概率,这样源空间中的很小概率,就被映射到对数空间的大的负数
# 同时相乘操作也变成简单的相加操作
A = torch.log(self.A)
B = torch.log(self.B)
Pi = torch.log(self.Pi)
# 初始化 维比特矩阵viterbi 它的维度为[状态数, 序列长度]
# 其中viterbi[i, j]表示标注序列的第j个标注为i的所有单个序列(i_1, i_2, ..i_j)出现的概率最大值
seq_len = len(word_list)
viterbi = torch.zeros(self.N, seq_len)
# backpointer是跟viterbi一样大小的矩阵
# backpointer[i, j]存储的是 标注序列的第j个标注为i时,第j-1个标注的id
# 等解码的时候,我们用backpointer进行回溯,以求出最优路径
backpointer = torch.zeros(self.N, seq_len).long()
# self.Pi[i] 表示第一个字的标记为i的概率
# Bt[word_id]表示字为word_id的时候,对应各个标记的概率
# self.A.t()[tag_id]表示各个状态转移到tag_id对应的概率
# 所以第一步为
start_wordid = word2id.get(word_list[0], None)
Bt = B.t()
if start_wordid is None:
# 如果字不再字典里,则假设状态的概率分布是均匀的
bt = torch.log(torch.ones(self.N) / self.N)
else:
bt = Bt[start_wordid]
viterbi[:, 0] = Pi + bt
backpointer[:, 0] = -1
# 递推公式:
# viterbi[tag_id, step] = max(viterbi[:, step-1]* self.A.t()[tag_id] * Bt[word])
# 其中word是step时刻对应的字
# 由上述递推公式求后续各步
for step in range(1, seq_len):
wordid = word2id.get(word_list[step], None)
# 处理字不在字典中的情况
# bt是在t时刻字为wordid时,状态的概率分布
if wordid is None:
# 如果字不再字典里,则假设状态的概率分布是均匀的
bt = torch.log(torch.ones(self.N) / self.N)
else:
bt = Bt[wordid] # 否则从观测概率矩阵中取bt
for tag_id in range(len(tag2id)):
max_prob, max_id = torch.max(
viterbi[:, step-1] + A[:, tag_id],
dim=0
)
viterbi[tag_id, step] = max_prob + bt[tag_id]
backpointer[tag_id, step] = max_id
# 终止, t=seq_len 即 viterbi[:, seq_len]中的最大概率,就是最优路径的概率
best_path_prob, best_path_pointer = torch.max(
viterbi[:, seq_len-1], dim=0
)
# 回溯,求最优路径
best_path_pointer = best_path_pointer.item()
best_path = [best_path_pointer]
for back_step in range(seq_len-1, 0, -1):
best_path_pointer = backpointer[best_path_pointer, back_step]
best_path_pointer = best_path_pointer.item()
best_path.append(best_path_pointer)
# 将tag_id组成的序列转化为tag
assert len(best_path) == len(word_list)
id2tag = dict((id_, tag) for tag, id_ in tag2id.items())
tag_list = [id2tag[id_] for id_ in reversed(best_path)]
return tag_list
以上就是HMM的实现了,全部代码可见文末。
条件随机场(Conditional Random Field, CRF)
关于HMM模型的基本假设主要包括两点:其一是观察值之间相互独立;其二是当前状态的变化仅依赖于上一个状态。具体而言,在命名实体识别的过程中,该方法认为每个被观测到的文字都是相互独立出现的,并且当前时刻的状态标签不仅与前一个时刻的状态标签相关联,在实际应用中发现当前时刻的状态标签不仅与前一个时刻的状态标签相关联,并且还与其后的某些信息相关联的情况更为常见。然而由于上述假设计入不够全面的原因,在面对像中文这样的自然语言处理任务时会显现出一定的局限性
而条件随机场避免了这一问题,并通过引入自定义的特征函数来实现。不仅能够表达观测之间的依赖关系,还能够表示观测与其前后多个状态之间的复杂依赖关系。从而有效地解决了HMM模型所面临的问题。
下面是条件随机场的数学形式(如果觉得不好理解。也可以直接跳到代码部分):
为了构建一个条件随机场模型,我们需要首先确定一个特征函数集 ,该集合中的每一个特征函数都将标注序列作为输入,并提取相应的特征作为输出结果。假设该集合为:

其中
表示观测序列,
描述状态序列。随后, 条件随机场采用对数线性模型来计算在给定观测序列时的状态序列的条件概率:

其中
是是所有可能的状态序列,
该方法中的变量θ可被视为条件随机场模型中的权值,并具体来说即代表各个特征函数与其对应的权重之间的关系连接。从其核心目标来看,在条件随机场模型中我们需要通过训练过程对其相应的权值进行优化以达到最佳预测效果。
的估计。假设我们有
个已经标注好的数据
,
则其对数似然函数的正则化形式如下:

那么,最优参数
就是:

模型训练结束之后,对给定的观测序列
,它对应的最优状态序列应该是:

解码的时候与HMM类似,也可以采用维特比算法。
下面是代码实现:
from sklearn_crfsuite import CRF # CRF的具体实现太过复杂,这里我们借助一个外部的库
def word2features(sent, i):
"""抽取单个字的特征"""
word = sent[i]
prev_word = "<s>" if i == 0 else sent[i-1]
next_word = "</s>" if i == (len(sent)-1) else sent[i+1]
# 因为每个词相邻的词会影响这个词的标记
# 所以我们使用:
# 前一个词,当前词,后一个词,
# 前一个词+当前词, 当前词+后一个词
# 作为特征
features = {
'w': word,
'w-1': prev_word,
'w+1': next_word,
'w-1:w': prev_word+word,
'w:w+1': word+next_word,
'bias': 1
}
return features
def sent2features(sent):
"""抽取序列特征"""
return [word2features(sent, i) for i in range(len(sent))]
class CRFModel(object):
def __init__(self,
algorithm='lbfgs',
c1=0.1,
c2=0.1,
max_iterations=100,
all_possible_transitions=False
):
self.model = CRF(algorithm=algorithm,
c1=c1,
c2=c2,
max_iterations=max_iterations,
all_possible_transitions=all_possible_transitions)
def train(self, sentences, tag_lists):
"""训练模型"""
features = [sent2features(s) for s in sentences]
self.model.fit(features, tag_lists)
def test(self, sentences):
"""解码,对给定句子预测其标注"""
features = [sent2features(s) for s in sentences]
pred_tag_lists = self.model.predict(features)
return pred_tag_lists
深度学习的方法
Bi-LSTM
除了上述基于概率图模型的两种方法之外

LSTM相对于CRF模型的主要优势在于其简洁明了的设计理念,在无需进行复杂的特征提取与工程优化的情况下即可实现高效的训练过程。相比传统隐马尔可夫模型(HMM),该方法在预测精度方面表现更为突出。
下面是基于双向LSTM的序列标注模型的实现:
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_packed_sequence, pack_padded_sequence
class BiLSTM(nn.Module):
def __init__(self, vocab_size, emb_size, hidden_size, out_size):
"""初始化参数:
vocab_size:字典的大小
emb_size:词向量的维数
hidden_size:隐向量的维数
out_size:标注的种类
"""
super(BiLSTM, self).__init__()
self.embedding = nn.Embedding(vocab_size, emb_size)
self.bilstm = nn.LSTM(emb_size, hidden_size,
batch_first=True,
bidirectional=True)
self.lin = nn.Linear(2*hidden_size, out_size)
def forward(self, sents_tensor, lengths):
emb = self.embedding(sents_tensor) # [B, L, emb_size]
packed = pack_padded_sequence(emb, lengths, batch_first=True)
rnn_out, _ = self.bilstm(packed)
# rnn_out:[B, L, hidden_size*2]
rnn_out, _ = pad_packed_sequence(rnn_out, batch_first=True)
scores = self.lin(rnn_out) # [B, L, out_size]
return scores
def test(self, sents_tensor, lengths, _):
"""解码"""
logits = self.forward(sents_tensor, lengths) # [B, L, out_size]
_, batch_tagids = torch.max(logits, dim=2)
return batch_tagid
Bi-LSTM+CRF
简单LSTM的优势在于通过双向结构学会观测序列(输入字符)间的依存关系。
在训练阶段中,LSTM依据目标(如实体识别任务)自动生成观测序列特征。
其主要缺陷在于无法捕捉状态序列(输出标记)间的关联性。
值得注意的是,在进行命名实体识别时,B型标记通常不会连续出现。
尽管避免了繁琐的手工特征工程步骤,但同时也存在难以捕捉标记间上下文关系的问题。
与之相比,CRF的主要优势在于能够建模隐含状态并学习和刻画状态序列的本质特征,在实际应用中需要人工提取序列特征。因此一般而言,在LSTM模型之后添加一层CRF层能够有效地融合两者的长处。
下面是给Bi-LSTM加一层CRF的代码实现:
class BiLSTM_CRF(nn.Module):
def __init__(self, vocab_size, emb_size, hidden_size, out_size):
"""初始化参数:
vocab_size:字典的大小
emb_size:词向量的维数
hidden_size:隐向量的维数
out_size:标注的种类
"""
super(BiLSTM_CRF, self).__init__()
# 这里的BiLSTM就是LSTM模型部分所定义的BiLSTM模型
self.bilstm = BiLSTM(vocab_size, emb_size, hidden_size, out_size)
# CRF实际上就是多学习一个转移矩阵 [out_size, out_size] 初始化为均匀分布
self.transition = nn.Parameter(
torch.ones(out_size, out_size) * 1/out_size)
# self.transition.data.zero_()
def forward(self, sents_tensor, lengths):
# [B, L, out_size]
emission = self.bilstm(sents_tensor, lengths)
# 计算CRF scores, 这个scores大小为[B, L, out_size, out_size]
# 也就是每个字对应对应一个 [out_size, out_size]的矩阵
# 这个矩阵第i行第j列的元素的含义是:上一时刻tag为i,这一时刻tag为j的分数
batch_size, max_len, out_size = emission.size()
crf_scores = emission.unsqueeze(
2).expand(-1, -1, out_size, -1) + self.transition.unsqueeze(0)
return crf_scores
def test(self, test_sents_tensor, lengths, tag2id):
"""使用维特比算法进行解码"""
start_id = tag2id['<start>']
end_id = tag2id['<end>']
pad = tag2id['<pad>']
tagset_size = len(tag2id)
crf_scores = self.forward(test_sents_tensor, lengths)
device = crf_scores.device
# B:batch_size, L:max_len, T:target set size
B, L, T, _ = crf_scores.size()
# viterbi[i, j, k]表示第i个句子,第j个字对应第k个标记的最大分数
viterbi = torch.zeros(B, L, T).to(device)
# backpointer[i, j, k]表示第i个句子,第j个字对应第k个标记时前一个标记的id,用于回溯
backpointer = (torch.zeros(B, L, T).long() * end_id).to(device)
lengths = torch.LongTensor(lengths).to(device)
# 向前递推
for step in range(L):
batch_size_t = (lengths > step).sum().item()
if step == 0:
# 第一个字它的前一个标记只能是start_id
viterbi[:batch_size_t, step,
:] = crf_scores[: batch_size_t, step, start_id, :]
backpointer[: batch_size_t, step, :] = start_id
else:
max_scores, prev_tags = torch.max(
viterbi[:batch_size_t, step-1, :].unsqueeze(2) +
crf_scores[:batch_size_t, step, :, :], # [B, T, T]
dim=1
)
viterbi[:batch_size_t, step, :] = max_scores
backpointer[:batch_size_t, step, :] = prev_tags
# 在回溯的时候我们只需要用到backpointer矩阵
backpointer = backpointer.view(B, -1) # [B, L * T]
tagids = [] # 存放结果
tags_t = None
for step in range(L-1, 0, -1):
batch_size_t = (lengths > step).sum().item()
if step == L-1:
index = torch.ones(batch_size_t).long() * (step * tagset_size)
index = index.to(device)
index += end_id
else:
prev_batch_size_t = len(tags_t)
new_in_batch = torch.LongTensor(
[end_id] * (batch_size_t - prev_batch_size_t)).to(device)
offset = torch.cat(
[tags_t, new_in_batch],
dim=0
) # 这个offset实际上就是前一时刻的
index = torch.ones(batch_size_t).long() * (step * tagset_size)
index = index.to(device)
index += offset.long()
tags_t = backpointer[:batch_size_t].gather(
dim=1, index=index.unsqueeze(1).long())
tags_t = tags_t.squeeze(1)
tagids.append(tags_t.tolist())
# tagids:[L-1](L-1是因为扣去了end_token),大小的liebiao
# 其中列表内的元素是该batch在该时刻的标记
# 下面修正其顺序,并将维度转换为 [B, L]
tagids = list(zip_longest(*reversed(tagids), fillvalue=pad))
tagids = torch.Tensor(tagids).long()
# 返回解码的结果
return tagids
以上就是这四个模型的具体实现了,模型的效果比较在前面已经给出了。
全部的代码地址在
luopeixiang/named_entity_recognitiongithub.com

(未来会持续更新,并且也会探索采用其他模型来解决这个问题。如果有帮助的话,请考虑 STAR 喜欢这个项目哦!)
代码中一些需要注意的点
- 在HMM模型中处理OOV问题时会遇到挑战,在测试集包含不在训练集中出现过的词汇时无法通过观测概率矩阵查询到这些状态的概率分布。
- HMM模型涉及三个关键参数:状态转移概率矩阵、观测概率矩阵以及初始状态概率矩阵,在使用监督学习方法进行估计时如果某些参数从未被观察到,则其值会被设定为零;而在采用维特比算法进行解码的过程中这些值会被相乘计算路径的概率如果乘积为零则会导致路径的概率消失这一现象称为下溢问题为了应对这两个挑战我们通常会对那些从未出现过的数值赋予极小值如0.00000001并在对数空间内重新表示这三个参数从而避免下溢并简化计算过程。
- 在CRF模型中在构建训练数据集时需要先提取特征以确保特征向量能够准确反映数据中的模式。
- Bi-LSTM与CRF结合而成的深度学习架构在Named Entity Recognition任务中有广泛的应用具体而言该结构通过动态规划算法高效地计算损失函数相比传统的梯度下降方法具有更好的优化效果建议读者深入研究相关的论文资料并结合代码实现以便更好地理解该模型的工作原理。
The CRF Layer atop a BiLSTM Model at -5, created by PENG's createromo.github.io, is utilized for Sequence Labeling tasks.

Pytorch Bi-LSTM + CRF 代码详解blog..net
