命名实体识别BiLSTM-CRF模型的Pytorch_Tutorial代码解析和训练自己的中文数据集

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_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 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 模型损失函数

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

在这里插入图片描述
主要包含两个部分,第一个部分是 P R e a l P a t h P_{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是这个句子序列的真实标签序列。用这两个输入,加上状态转移矩阵中的分值,便可得到 P R e a l P a t h P_{RealPath} PRealPath的分值。代码都比较简单,唯一需要说明的是elf.transitions[tags[i + 1], tags[i]] + feat[tags[i + 1]]代表的是当前状态到下一个状态的转移分值,初始状态START_TAG的score值是没有被累加的。

损失函数中另一个组成部分则是 P 1 P_1 P1 + P 2 P_2 P2 + P 3 P_3 P3+ …+ P N P_N PN,这部分是则是所有路径的分值之和。代码实现如下:


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的指数导致计算机上溢
    
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

这里其实有点难理解,所有路径的求和究竟是如何实现的。这个代码里面有两层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)*5
        forward_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]] = 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

整个代码其实一看就能明白了,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在命名实体识别中是如何起作用的?

  • 1
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肥宅程序员aka不会游泳的鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值