命名实体识别BiLSTM-CRF模型的Pytorch_Tutorial代码解析和训练自己的中文数据集
创始人
2025-05-31 09:38:53
0

0、前言

命名实体识别(NER)属于自然语言处理中的最常见的也是最基础的任务,是指从文本中识别出特定命名指向的词,比如人名、地名和组织机构名等。命名实体识别任务做标签方法有很多,包括BIO、BIOSE、IOB、BILOU、BMEWO、BMEWO+等,最常见的是 BIO 与 BIOES 这两种。不同做标签的方法会对模型效果有些许影响,例如有些时候用BIOES会比BIO有些许优势。

在BIO和BIOSE中,Beginning 表示某个实体词的开始,Inside表示某个实体词的中间,Outside表示非实体词,End表示某个实体词的结尾,Single表示这个实体词仅包含当前这一个字。

传统的NER算法主要就是CRF和HMM这两种,后续在LSTM出来之后,LSTM+CRF在很长一段时间里都是做NER任务的首选算法。而在2019年BERT出现之后,NER的首选算法又变成了 BERT-CRF(或者 BERT-LSTM-CRF)。

以上简单介绍了NER的定义,标注方式和模型算法发展史,但这都不是本篇博客的重点内容,本篇博客主要聚焦于BiLSTM-CRF的代码详细解析,将代码与BiLSTM-CRF原理对应起来。

1、BiLSTM-CRF模型大体结构

以前言中最为简单的BIO的标签方式为例,同时加入START和END来使转移矩阵更加健壮,其中,START表示句子的开始,END表示句子的结束。这样,标注标签共有5个:[B, I, O, START, END]。

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

在这里插入图片描述
图上的C0,C1, C2,C3,C4是输入的句子拆分的一个个单字(中文),它们被输入到LSTM之前,还需要进行Embedding操作(就是将其变成一个向量),然后就被送到双向的LSTM中学习。双向的LSTM是NLP中最最常用的模型之一,关于它的结构细节和原理介绍,网上已经有很多很多了,这里就不做过多的介绍了。

CRF(条件随机场)的原理可参见李航老师的《统计学习方法》第11章,线性链条件随机场这里面的介绍。李航老师介绍了很多,列举了 很多的公式,看着确实让人头疼,但是总结起来其实就是一句话:CRF层可以加入一些约束来保证最终预测结果是有效的。这些约束可以在训练数据时被CRF层自动学习得到。

可能的约束条件有:

  • 句子的开头应该是“B-”或“O”,而不是“I-”

  • “B-label1 I-label2 I-label3…”,在该模式中,类别1,2,3应该是同一种实体类别。比如,“B-Person I-Person” 是正确的,而“B-Person I-Organization”则是错误的

  • “O I-label”是错误的,命名实体的开头应该是“B-”而不是“I-”

有了这些有用的约束,错误的预测序列将会大大减少。

那CRF是如何实现对模型输出结果的约束的呢?主要是通过两个分数矩阵,一个是发射分数矩阵(Emission score),另外一个则是状态转移分数矩阵(Transition Score)。

发射分数矩阵(Emission score)是模型输入C0, C1, C2等经过双向LSTM之后得到的概率矩阵,比如为[1.5, 0.9, 0.1, 0.08, 0.05], 这是单字C0的预测为B-person, I-person, B-local, I-local, O的结果。当然,再加上单字C1, C2等其他单字的预测结果,那整个发射分数矩阵的尺寸就是N*5, 其中N是句子的单字个数。

而状态转移分数矩阵内存储的是每个预测结果转为另一个预测结果的分数,当然也包括转为其本身自己的分数。本质上说,就是对发射分数矩阵的结果加上一个权重,从而影响其最终的输出。
在这里插入图片描述
如上图的状态转移分数第一行第二列的0.8,则代表从START状态转为B-person状态的分数为0.8,其他的以此类推。

2、代码详解

完整的代码参考ADVANCED: MAKING DYNAMIC DECISIONS AND THE BI-LSTM CRF, 是pytorch官方给的一个BI-LSTM CRF算法实现,这里就不贴出来了,下面,依次来讲解一下代码的关键步骤的实现。

2.1、模型的定义

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_dimself.hidden_dim = hidden_dimself.vocab_size = vocab_sizeself.tag_to_ix = tag_to_ixself.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 forward(self, sentence):'''解码过程,维特比解码选择最大概率的标注路径'''lstm_feats = self._get_lstm_features(sentence)score, tag_seq = self._viterbi_decode(lstm_feats)return score, tag_seq

整个模型定义比较清晰,包含了双向的LSTM和CRF的部分,CRF的初始化就是随机初始一个状态转移矩阵,后面再_viterbi_decode的时候会详细介绍状态转移矩阵是如何使用的。

2.2 模型损失函数

模型损失函数的定义如下:

在这里插入图片描述
主要包含两个部分,第一个部分是PRealPathP_{RealPath}PRealPath​指的是标签的分值,就比如某句话的标签BIOOOBI,这个序列的分值,在代码中实现如下所示:

    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

这里求得RealPath的分值,用到了feats和tags,feats是句子序列经过LSTM之后的预测结果,tags是这个句子序列的真实标签序列。用这两个输入,加上状态转移矩阵中的分值,便可得到PRealPathP_{RealPath}PRealPath​的分值。代码都比较简单,唯一需要说明的是elf.transitions[tags[i + 1], tags[i]] + feat[tags[i + 1]]代表的是当前状态到下一个状态的转移分值,初始状态START_TAG的score值是没有被累加的。

损失函数中另一个组成部分则是P1P_1P1​ + P2P_2P2​ + P3P_3P3​+ …+ PNP_NPN​,这部分是则是所有路径的分值之和。代码实现如下:


def log_sum_exp(vec):max_score = vec[0, argmax(vec)]  # max_score的维度为1max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])  # 维度为1*5return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))#等同于torch.log(torch.sum(torch.exp(vec))),防止e的指数导致计算机上溢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_alphasfor feat in feats: # w_ialphas_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 scorstrans_score = self.transitions[next_tag].view(1, -1)  # 维度是1*5next_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

这里其实有点难理解,所有路径的求和究竟是如何实现的。这个代码里面有两层for循环,第一层很好理解,就是对一句话中的每个单字进行遍历循环;第二个for循环里面,不仅对状态转移矩阵进行遍历,还对每个单字的预测结果进行了扩充。这里怎么理解呢,其实就是,比如,某个字预测为B的概率为0.8,那么 emit_score = feat[next_tag].view(1, -1).expand(1, self.tagset_size)这行代码就是将[0.8] * self.tagset_size。这样扩充emit_score之后,和forward_var,trans_score相加,一则继承了前面的单字的预测为B的score值,同时把当前状态的B的所有score值的求和一步到位完成。当第二层for循环完成之后,基本上当前单字的所有路径分值的求和就完成了,达到了下图的第二幅中彩色线条的求和的效果:
在这里插入图片描述

最后,模型的损失值就是由上面两个部分求得的score值相减得到

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

2.3 模型推理

模型推理的主要流程代码已经在2.1、模型的定义中给出了,主要就是先获取到LSTM特征,然后再进行一个viterbi解码得到路径和路径的score值。关于viterbi算法的原理,可以看一下这篇文章如何通俗地讲解 viterbi 算法?,讲的比较清晰。

其实viterbi原理,通俗一点的讲解就是:如果需要求解一句话的最大分值路径,那么可以把这个问题分解成求解从第一个字开始到倒数第二个字的最大路径 + 倒数第二个字到最后一个值的最大路径分值,而第一个字开始到倒数第二个字也同样看成是一个句子的话,它的最大分值路径也可以拆解成这个句子里面的第一个字开始到倒数第二个字的最大路径 + 倒数第二个字到最后一个值的最大路径分值,以此类推,这其实就是动态规划的思想。本质上说,就是从第一个字开始,把每个字求得的最大分值路径保存起来,这样可以避免一部分重复计算,用空间换时间。当然,关于viterbi算法更详细的解释还是看上面的链接,作者讲的会更加清楚一些。

回到这些代码里面来:

def _viterbi_decode(self, feats):# 预测序列的得分,维特比解码,输出得分与路径值backpointers = []init_vvars = torch.full((1, self.tagset_size), -10000.)init_vvars[0][self.tag_to_ix[START_TAG]] = 0forward_var = init_vvarsfor 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)  # 返回最大值对应的那个tagbptrs_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

整个代码其实一看就能明白了,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),这里就是把当前字的最大路径值和当前LSTM输出结果进行相加,这样就可以用于下个字的计算。

其他还有一些模型训练的代码就不做注释了,都是很常规的代码。

3、训练自己的数据集

原始代码中数据是这样呈现的:
[([‘the’, ‘wall’, ‘street’, ‘journal’, ‘reported’, ‘today’, ‘that’, ‘apple’, ‘corporation’, ‘made’, ‘money’], [‘B’, ‘I’, ‘I’, ‘I’, ‘O’, ‘O’, ‘O’, ‘B’, ‘I’, ‘O’, ‘O’]), ([‘georgia’, ‘tech’, ‘is’, ‘a’, ‘university’, ‘in’, ‘georgia’], [‘B’, ‘I’, ‘O’, ‘O’, ‘O’, ‘O’, ‘B’])]
每句话由单字列表和标签列表组成一个元组,多句话则是由元组列表组成。

那自己的数据集,同样可以按照这样的格式来处理。

首先自己的数据集标注方式如下,每个句子之前用空格隔开:
在这里插入图片描述
那么数据load代码替换一下即可用于模型训练:

def load_sentences(path):sentences = []words = []labels = []for line in codecs.open(path, 'r', 'utf8'):line = line.rstrip()# print(list(line))if not line:if len(words) > 0:if 'DOCSTART' not in words[0]:sentences.append((words, labels))words = []labels = []else:if line[0] == " ":line = "$" + line[1:]word = line.split()# word[0] = " "else:word= line.split()assert len(word) >= 2, print([word[0]])words.append(word[0])labels.append(word[1])if len(words) > 0:if 'DOCSTART' not in words[0]:sentences.append((words, labels))return sentences

4、参考

命名实体识别(NER):BiLSTM-CRF原理介绍+Pytorch_Tutorial代码解析
CRF Layer on the Top of BiLSTM - 5
流水的NLP铁打的NER:命名实体识别实践与探索
一步步解读pytorch实现BiLSTM CRF代码
最通俗易懂的BiLSTM-CRF模型中的CRF层介绍
CRF在命名实体识别中是如何起作用的?

相关内容

热门资讯

Phalcon 安装问题总结 问题一 :  Fatal error: Uncaught Error: Class '...
leetcode:2500. ... 难度:简单 给你一个 m x n 大小的矩阵 grid ,由若干正整数组...
原创 九... 魏晋南北朝时期的官员选拔制度具有其独特性,在这段历史中,官场大多由门阀世家把控和主导。 那么,什...
Java学习总结 Java中list为什么有序? 作者:麦穗星 链接:htt...
MySQL OCP888题解0... 文章目录1、原题1.1、英文原题1.2、中文翻译1.3、答案2、题目解析2.1、题干解析2.2、选项...
一文教会你Jenkins 主从... 目录 背景 添加Slave节点 连接Slave节点 Jenkins任务配置Slave节点执行 可能...
Apache common工具... 这里写目录标题1.commons-lang31.1 SpringUtils字符串工具类1.1.1 包...
问法预告丨家庭纠纷别愁,律师为... 在社会的各个角落,家庭作为社会的基础单元,宛如承载爱与希望的温馨港湾。然而,当婚姻的庄重誓言遭遇现实...
江苏扬州:从“企业找政策”到“... 流程长、手续繁、盖章多,一直是困扰企业享受惠企政策的难题。今年以来,由扬州市财政局牵头,联合数据、工...
智慧轨道交通运维监控解决方案         交通作为国民经济和社会发展的基础性、先行性产业,在整个社会经济、民生发...