命名实体识别(NER):BiLSTM-CRF原理介绍+Pytorch_Tutorial代码解析

2020/10/12 KG

本文较全面的介绍了命名实体识别(NER),包括NER定义、BiLSTM-CRF模型、Pytorch代码实现,未来将继续完善本文,以求涵盖NER众多方面。

[TOC]

命名实体识别任务(NER)定义

命名实体识别属于自然语言处理中的序列标注任务,是指从文本中识别出特定命名指向的词,比如人名、地名和组织机构名等。具体而言,输入自然语言序列 ,给出对应标签序列 ,见下图。

序列标注里标记法有很多,包括BIO、BIOSE、IOB、BILOU、BMEWO、BMEWO+等,最常见的是 BIOBIOES 这两种。不同标注方法会对模型效果有些许影响,例如有些时候用BIOES会比BIO有些许优势。

在BIO和BIOSE中,Beginning 表示某个实体词的开始,Inside表示某个实体词的中间,Outside表示非实体词,End表示某个实体词的结尾,Single表示这个实体词仅包含当前这一个字。IOB与BIO字母对应的含义相同,其不同点是IOB中,标签B仅用于两个连续的同类型命名实体的边界区分。BILOU 等价于 BIOES,Last等同于End,Unit等同于Single。BMEWO 等价于 BIOES,Middle等同于Inside,Whole等同于Single。BMEWO+是在命名实体边界外的标注,即‘O plus‘。

图1

命名实体识别的常用方法是BiLSTM-CRF和BERT-CRF,可以完美的匹配该任务。

BiLSTM-CRF模型

下文,我们使用BIO标注进行解析,同时加入START和END来使转移矩阵更加健壮,其中,START表示句子的开始,END表示句子的结束。这样,标注标签共有5个:[B, I, O, START, END]

BiLSTM-CRF模型主体由双向长短时记忆网络(Bi-LSTM)和条件随机场(CRF)组成,模型输入是字符特征,输出是每个字符对应的预测标签。

模型输入

对于输入的自然语言序列,可通过特征工程的方法定义序列字符特征,如词性特征、前后词等,将其输入模型。但现在多数情况下,可以直接选择句中每个字符的字嵌入或词嵌入向量,可以是事先训练好的或是随机初始化。对于中文,我个人倾向于将字符向量和其所属的词向量进行拼接,词嵌入使用预训练好的,字嵌入随机初始化。

LSTM

BiLSTM接收每个字符的embedding,并预测每个字符的对5个标注标签的概率。

LSTM是一种特殊的循环神经网络,可以解决RNN的长期依赖问题,其关键就是细胞状态,见下图中贯穿单元结构上方的水平线。细胞状态在整个链上运行,只有一些少量的线性交互,从而保存长距离的信息流。具体而言,LSTM一共有三个门来维持和调整细胞状态,包括遗忘门,输入门,输出门。

遗忘门接收$h_{t-1}$和$x_t$,通过公式1输出一个在 0 到 1 之间的数值$f_t$,该数值会作用于上一个细胞状态$C_{t-1}$,1 表示“完全保留”,0 表示“完全忘记”;输入门接收$h_{t-1}$和$x_t$,通过公式2输出一个在 0 到 1 之间的数值,已控制当前候选状态$\hat{C_t}$有多少信息需要保留,至于候选状态$\hat{C_t}$,则通过公式3由tanh 层创建一个新的候选值向量,然后根据上一个细胞状态$C_{t-1}$和遗忘值$f_t$、新的细胞状态$C_{t}$和输入值$i_t$,由公式4更新细胞状态;输出门接收$h_{t-1}$和$x_t$,通过公式5输出一个在 0 到 1 之间的数值$o_t$,最后公式6决定了当前状态$C_t$有多少信息需要输出。

题外话:LSTM的理解

一般来说,循环神经网络是MLP增加了一个时间维度,那么该怎样理解这个时间维度呢?可以参考以下两张图可视化LSTM。

torch.nn.LSTM()中,有以下几个重要的参数:input_size,在这里就是每个字符嵌入的维度;hidden_size,经过一个LSTM单元后输入$h$的维度;num_layers,即上图中depth的深度,若干个LSTMcell的堆叠;bidirectional,默认False,在实验中将其设为True。

LSTM输入包括$input$和$(h_0, c_0)$两部分,其中$input$大小为 (seq_len, batch, input_size),$h_0$和$c_0$大小均为(num_layers * num_directions, batch, hidden_size)。输出包括$output$和$(h_n, c_n)$两部分,其中$output$大小为(seq_len, batch, num_directions * hidden_size),$h_n$和$c_n$大小均为(num_layers * num_directions, batch, hidden_size)。

我们知道,CNN在空间上共享参数,而RNN是在时间上共享参数。也就是在每一层depth中只有一个LSTMcell,即上面第二张图中每一层只有一个LSTM单元。

在BiLSTM-CRF中,一般使用一层的双向LSTM是足够的。因此,BiLSTM对输入embeddings的特征提取过程如下图。

本部分一开始就说过,BiLSTM接收每个字符的embedding,并预测每个字符的对5个标注标签的概率。但是,我们也知道上图得到的拼接向量维度大小为num_directions * hidden_size。为将输入表示为字符对应各个类别的分数,需要在BiLSTM层加入一个全连接层,通过softmax将向量映射为一个5维的分布概率。

到了这一步,似乎我们通过BiLSTM已经找到每个单词对应的最大标签类别,但实际上,直接选择该步骤最大概率的标签类别得到的结果并不理想。原因在于,尽管LSTM能够通过双向的设置学习到观测序列之间的依赖,但softmax层的输出是相互独立的,输出相互之间并没有影响,只是在每一步挑选一个最大概率值的label输出,这样的模型无法学习到输出的标注之间的转移依赖关系(标签的概率转移矩阵)以及序列标注的约束条件,如句子的开头应该是“B”或“O”,而不是“I”等。为此,引入CRF层学习序列标注的约束条件,通过转移特征考虑输出label之间的顺序性,确保预测结果的有效性。

CRF

CRF层将BiLSTM的Emission_score作为输入,输出符合标注转移约束条件的、最大可能的预测标注序列。

在开始之前,我们先了解一下线性链条件随机场。见李航老师的《统计学习方法》第11章。

赫然发现,NER问题就是条件随机场,即给定自然语言序列X,最大概率的标注序列Y用来表示NER标注结果。至于$P(y x)$,由公式(11.10)和(11.11)求得。齐活!

上图中标出,公式(11.10)中有三个值得注意的部分:$t_k$,$s_l$和$Z(x)$,理解这三个部分是理解BiLSTM-CRF模型中CRF的关键,以下面这张图进行说明。

在我们的例子中,输入$x$为$c_0,c_1,c_2,c_3,c_4$,理想输出$y$ 为$B,I,O,O,B$,上图中红色线路。

  • $Z(x)$,称规范化因子或配分函数。在公式(11.10)中,“$Z(x)$是规范化因子,求和是在所有可能的输出序列上进行的”。对应到我们的图中,其实就是图中所有可能的路径组合,由于输入序列长度为5,标注类型也为5,因此上图中共有$5^5$条不同路径。每条路径根据$exp(*)$计算该路径的得分,加和得到$Z(x)$。
  • $s_t$是节点上的状态特征,取决于当前节点;$t_k$是边上的转移特征,取决于当前和前一个节点。根据它们的定义,可以很自然的将它们与BiLSTM-CRF中的Emission Score和Transition Score匹配:Emission Score是由BiLSTM生成的、对当前字符标注的概率分布;Transition Score是加入CRF约束条件的、字符标注之间的概率转移矩阵。从这个意义上讲,BiLSTM-CRF其实就是一个CRF模型,只不过用BiLSTM得到状态特征值$s_t$,用反向传播算法更新转移特征值$t_k$。

在模型训练过程中,模型损失函数定义如下:

\[P(\bar{y}|x)=\frac{exp(\operatorname{score}(x,\bar{y}))}{\sum_y{exp(\operatorname{score}(x,y))}}\] \[\operatorname{score}(x,y)=\sum_{i=1}^{n} P_{i, y_{i}}+\sum_{i=0}^{n} A_{y_{i-1}, y_{i}}\]

其中,$P_{i, y_{i}}$和$A_{y_{i-1}, y_{i}}$分别表示标注序列$y$中$y_i$的Emission Score和Transition Score,通过查找上图中的”BiLSTM的Emission Score“和”序列标注转移矩阵“可以得到每个字符位置的得分,整个序列相加得到$\operatorname{score}(x,y)$。

模型训练过程中最大化对数似然函数:

\[\log P\left(\bar{y}\mid x\right)=\operatorname{score}\left(x, \bar{y}\right)-\log \left(\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right)\right)\]

真实路径得分

在我们的例子中,真实路径$\bar{y}=(B,I,O,O,B)$,$\operatorname{score}(x,\bar{y})=\sum\operatorname{EmissionScores}+\sum\operatorname{TransitionScores}$,其中:

\[\sum\operatorname{EmissionScores}=P_{0,START}+P_{1, B}+P_{2, I}+P_{3,O}+P_{4, O}+P_{5,B}+P_{6,END}\] \[\sum\operatorname{TransitionScores}=A_{START,B}+A_{B,I}+A_{I,O}+A_{O,O}+A_{O,B}+A_{B,END}\]

EmissionScores来自BiLSTM层的输出,至于$P_{0,START}$和$P_{6,END}$,则设为0;TransitionScores来自于CRF层;将真实路径中这两类分数加和,即可得到真实路径得分,上图中红色路线。

所有路径得分

对于所有路径的总分$\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right)$,一种直接的想法是列举出所有可能的路径,然后查找每条路径的Emission Score和Transition Score,计算出每条路径的得分,之后加和。

这种方法显然效率低下,在我们的例子中,仅有5个字符和5个标注序列,就已经有了约$5^5$种路径组合,在实际工作中,我们显然会有更长的序列和更多的标注标签,提高计算效率是必要的。于是,我们以分数累积的方法计算所有路径得分,即先计算出到达$c_0$的所有路径的总得分,然后,计算$c_0{->}c_1$的所有路径的得分,依次类推,直到计算出$c_0->c_1\dots->c_n$的所有路径的得分,这就是我们需要的结果。通过下图可以看出这两种计算方式的不同。

上面的图是直观的、计算所有路径得分加和的示意图,这种方法思想上是深度优先的;下面的图是改进的、分数累计求和的示意图,这种方法思想上是广度优先的。

在第一种方法中,图示以$[(S,S,S,S,S),(S,S,S,S,B),(S,S,S,S,I),(S,S,S,S,O),(S,S,S,S,E)]$五条可能的标注路径为例,每一条路径得分由该路径上节点得分(EmissionScore)和边得分(TransitionScore)相加得到,可以发现,如果计算分别计算每一条路径的话,存在大量的冗余,如对于上述五条路径,前四个标注是相同的,理论上只需分别计算最后一项不同标注即可。

分数累积的方法更为激进。要知道,在计算$Z(x)$时我们并不是要排列组合出所有可能路径,只是要一个All值,即图中所有边和节点的总值。因此,我们可以先计算出到达某一字符的路径得分之和$(s_S^i,s_B^i,s_I^i,s_O^i,s_E^i)$,然后依次计算下一字符的路径得分之和$(s_S^{i+1},s_B^{i+1},s_I^{i+1},s_O^{i+1},s_E^{i+1})$,这类似于动态规划的思想。如在上图示例中,我们在得知到达$y_1$的路径得分之和$(s_S^1,s_B^1,s_I^1,s_O^1,s_E^1)$后,根据$s_{tag’}^2=\sum_{tags}{s_{tag}^1+t_{(tag,tag’)}}$可分别计算出$y_2$的路径得分之和$(s_S^2,s_B^2,s_I^2,s_O^2,s_E^2)$,图中红/绿/蓝/黄/紫分别根据$y_1$各标注的得分计算到达$y_2$的$(S,B,I,O,E)$各标注的得分之和。依次类推,计算出整个图中所有路径得分之和。

在图中可以直观看到上述两种方法是等价的,其数学上的等价性见下:

\[\log \left(\sum e^{\log \left(\Sigma e^{x}\right)+y}\right)=\log \left(\sum \sum e^{x+y}\right)\]

证明过程:

\[\begin{aligned} \log \left(\sum e^{\log \left(\Sigma e^{x}\right)+y}\right) &=\log (\sum e^{\log (\Sigma e^{x})+\log e^y}) \\ &=\log (\sum e^{\log (\Sigma e^{x})*e^y})\\ &=\log (\sum e^{\log (\Sigma e^{x+y})})\\ &=\log \left(\sum \sum e^{x+y}\right) \end{aligned}\]

第二种方法有效减少了计算冗余。第一种方法的计算复杂度为$O(nm^n)$,其中,$m$为标注标签类别数,第二种方法的计算复杂度为$O(nm^2)$。当然,第二种方法还可以以下图这种方式计算,下文Pytorch Tutorial中的实现_forward_alg()就是如此,但本质上就是一回事。

建议推荐参照Bi-LSTM-CRF算法详解-1中的推导过程进行理解或自行推导。

最终BiLSTM-CRF模型如下:

Pytorch Tutorial NER代码解析

网上多数Pytorch NER解析来自官方示例,见ADVANCED: MAKING DYNAMIC DECISIONS AND THE BI-LSTM CRF,以下代码添加有个人备注解析。以下几点需要注意:

  • 前文提到,模型目标是最大化对数似然函数$\log P\left(\bar{y}\mid x\right)=\operatorname{score}\left(x, \bar{y}\right)-\log \left(\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right)\right)$,但在深度学习中,我们一般通过最小化损失函数优化模型参数。因此,取负得到损失函数$Loss=\log (\sum_{y} \exp (\operatorname{score}(x, y)))-\operatorname{score}(x, \bar{y})$。
  • 在NER问题中,训练过程与测试过程是不同的。在训练中,我们需要计算所有路径的得分以正确更新转移矩阵,但在测试过程中,我们以然得到合理的标签概率转移矩阵,因此只需要结合Emission Score选择一条概率最大的路径。该过程为解码过程,采用Vertibe算法,参见如何通俗地讲解 viterbi 算法?
  • Pytorch Tutorial仅提供最基本的代码帮助理解BiLSTM-CRF,具体的到实践,该代码还有很大的优化空间。如批次训练、GPU训练、维特比解码和配分函数计算优化等。
import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.optim as optim

torch.manual_seed(1)

def prepare_sequence(seq, to_ix):
    idxs = [to_ix[w] for w in seq]
    return torch.tensor(idxs, dtype=torch.long)

def argmax(vec):
	# 得到最大的值的索引
	_, idx = torch.max(vec, 1)
	return idx.item()

def log_sum_exp(vec):
	max_score = vec[0, argmax(vec)]  # max_score的维度为1
	max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])  # 维度为1*5
	return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))
	#等同于torch.log(torch.sum(torch.exp(vec))),防止e的指数导致计算机上溢

class BiLSTM_CRF(nn.Module):
	def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
		super(BiLSTM_CRF, self).__init__()
		self.embedding_dim = embedding_dim
		self.hidden_dim = hidden_dim
		self.vocab_size = vocab_size
		self.tag_to_ix = tag_to_ix
		self.tagset_size = len(tag_to_ix)

		self.word_embeds = nn.Embedding(vocab_size, embedding_dim)
		self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2, num_layers=1, bidirectional=True)
		self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
		# 转移矩阵,transitions[i][j]表示从label_j转移到label_i的概率,虽然是随机生成的但是后面会迭代更新
		self.transitions = nn.Parameter(torch.randn(self.tagset_size, self.tagset_size))

		self.transitions.data[tag_to_ix[START_TAG], :] = -10000  # 从任何标签转移到START_TAG不可能
		self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000  # 从STOP_TAG转移到任何标签不可能

		self.hidden = self.init_hidden() # 随机初始化LSTM的输入(h_0, c_0)

	def init_hidden(self):
		return (torch.randn(2, 1, self.hidden_dim // 2),
				torch.randn(2, 1, self.hidden_dim // 2))

	def _forward_alg(self, feats):
		'''
		输入:发射矩阵(emission score),实际上就是LSTM的输出——sentence的每个word经BiLSTM后,对应于每个label的得分
		输出:所有可能路径得分之和/归一化因子/配分函数/Z(x)
		'''
		init_alphas = torch.full((1, self.tagset_size), -10000.)
		init_alphas[0][self.tag_to_ix[START_TAG]] = 0.

		# 包装到一个变量里面以便自动反向传播
		forward_var = init_alphas
		for feat in feats: # w_i
			alphas_t = []
			for next_tag in range(self.tagset_size): # tag_j
				# t时刻tag_i emission score(1个)的广播。需要将其与t-1时刻的5个previous_tags转移到该tag_i的transition scors相加
				emit_score = feat[next_tag].view(1, -1).expand(1, self.tagset_size) # 1*5
				# t-1时刻的5个previous_tags到该tag_i的transition scors
				trans_score = self.transitions[next_tag].view(1, -1)  # 维度是1*5

				next_tag_var = forward_var + trans_score + emit_score
				# 求和,实现w_(t-1)到w_t的推进
				alphas_t.append(log_sum_exp(next_tag_var).view(1))
			forward_var = torch.cat(alphas_t).view(1, -1) # 1*5

		# 最后将最后一个单词的forward var与转移 stop tag的概率相加
		terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
		alpha = log_sum_exp(terminal_var)
		return alpha

	def _get_lstm_features(self, sentence):
		'''
		输入:id化的自然语言序列
		输出:序列中每个字符的Emission Score
		'''
		self.hidden = self.init_hidden() # (h_0, c_0)
		embeds = self.word_embeds(sentence).view(len(sentence), 1, -1)
		lstm_out, self.hidden = self.lstm(embeds, self.hidden)
		lstm_out = lstm_out.view(len(sentence), self.hidden_dim)
		lstm_feats = self.hidden2tag(lstm_out) # len(s)*5
		return lstm_feats

	def _score_sentence(self, feats, tags):
		'''
		输入:feats——emission scores;tags——真实序列标注,以此确定转移矩阵中选择哪条路径
		输出:真实路径得分
		'''
		score = torch.zeros(1)
		# 将START_TAG的标签3拼接到tag序列最前面
		tags = torch.cat([torch.tensor([self.tag_to_ix[START_TAG]], dtype=torch.long), tags])
		for i, feat in enumerate(feats):
			score = score + \
					self.transitions[tags[i + 1], tags[i]] + feat[tags[i + 1]]
		score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
		return score

	def _viterbi_decode(self, feats):
		# 预测序列的得分,维特比解码,输出得分与路径值
		backpointers = []

		init_vvars = torch.full((1, self.tagset_size), -10000.)
		init_vvars[0][self.tag_to_ix[START_TAG]] = 0

		forward_var = init_vvars
		for feat in feats:
			bptrs_t = []
			viterbivars_t = []

			for next_tag in range(self.tagset_size):
				next_tag_var = forward_var + self.transitions[next_tag]  # forward_var保存的是之前的最优路径的值
				best_tag_id = argmax(next_tag_var)  # 返回最大值对应的那个tag
				bptrs_t.append(best_tag_id)
				viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))

			forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1)
			backpointers.append(bptrs_t)  # bptrs_t有5个元素

		# 其他标签到STOP_TAG的转移概率
		terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
		best_tag_id = argmax(terminal_var)
		path_score = terminal_var[0][best_tag_id]

		best_path = [best_tag_id]
		for bptrs_t in reversed(backpointers):
			best_tag_id = bptrs_t[best_tag_id]
			best_path.append(best_tag_id)
		
		# 无需返回最开始的START位
		start = best_path.pop()
		assert start == self.tag_to_ix[START_TAG]
		best_path.reverse()  # 把从后向前的路径正过来
		return path_score, best_path

	def neg_log_likelihood(self, sentence, tags):  # 损失函数
		feats = self._get_lstm_features(sentence)  # len(s)*5
		forward_score = self._forward_alg(feats)  # 规范化因子/配分函数
		gold_score = self._score_sentence(feats, tags) # 正确路径得分
		return forward_score - gold_score  # Loss(已取反)

	def forward(self, sentence):
		'''
		解码过程,维特比解码选择最大概率的标注路径
		'''
		lstm_feats = self._get_lstm_features(sentence)

		score, tag_seq = self._viterbi_decode(lstm_feats)
		return score, tag_seq

START_TAG = "<START>"
STOP_TAG = "<STOP>"
EMBEDDING_DIM = 5 # 由于标签一共有B\I\O\START\STOP 5个,所以embedding_dim为5
HIDDEN_DIM = 4 # 这其实是BiLSTM的隐藏层的特征数量,因为是双向所以是2倍,单向为2

training_data = [(
	"the wall street journal reported today that apple corporation made money".split(),
	"B I I I O O O B I O O".split()
), (
	"georgia tech is a university in georgia".split(),
	"B I O O O O B".split()
)]

word_to_ix = {}
for sentence, tags in training_data:
	for word in sentence:
		if word not in word_to_ix:
			word_to_ix[word] = len(word_to_ix)

tag_to_ix = {"B": 0, "I": 1, "O": 2, START_TAG: 3, STOP_TAG: 4}

model = BiLSTM_CRF(len(word_to_ix), tag_to_ix, EMBEDDING_DIM, HIDDEN_DIM)
optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)

for epoch in range(300):
	for sentence, tags in training_data:
		model.zero_grad()
		
        # 输入
		sentence_in = prepare_sequence(sentence, word_to_ix)
		targets = torch.tensor([tag_to_ix[t] for t in tags], dtype=torch.long)
		
        # 获取loss
		loss = model.neg_log_likelihood(sentence_in, targets)
		
        # 反向传播
		loss.backward()
		optimizer.step()

with torch.no_grad():
	precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
	print(model(precheck_sent))

#TODO

  • NER实战
  • BERT-CRF

参考

流水的NLP铁打的NER:命名实体识别实践与探索

序列标注方法BIO、BIOSE、IOB、BILOU、BMEWO、BMEWO+的异同

BiLSTM-CRF模型理解

Understanding LSTM Networks

pytorch中LSTM的细节分析理解

Bi-LSTM-CRF算法详解-1

ADVANCED: MAKING DYNAMIC DECISIONS AND THE BI-LSTM CRF

如何通俗地讲解 viterbi 算法?

《统计学习方法(第二版)》

Search

    欢迎关注我的微信公众号

    博士的沙漏

    Table of Contents