百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分类 > 正文

NER系列:CRF条件随机场原理简介,深入理解CRF源码实现

ztj100 2024-11-14 19:24 21 浏览 0 评论

关键词:CRF条件随机场序列标注命名实体识别

内容摘要

  • NER任务简介
  • NER中引入CRF的目的
  • CRF中的学习参数
  • CRF的损失函数
  • CRF PyTorch源码解析

NER任务简介

命名实体识别(Named Entity Recognition,NER),是指识别文本中有特定意义的实体边界类别,所指的有意义实体包括人名、地名、机构名,或者某种具有特定业务含义的词组等,NER的目标是识别出这些特定词组在文本中的位置,并且给该位置下的词组所属的业务类别。

NER属于序列标注任务,即将输入文本看作分词序列,对序列的每一个元素位置进行分类预测,预测每个元素所属的类别。一般的采用BIO标注作为学习目标,其中B作为实体的词首,I作为实体词中或者结尾,剩余的非命名实体元素全部置为O,在BIO后面可以加入任意业务分类,例如以下句子包含需要识别的人名(PER)和机构组织名(ORG),用BIO标记如下:

>>> "小王在前进小学上小学"
>>> ['B-PER', 'I-PER', 'O', 'B-ORG', 'I-ORG', 'I-ORG', 'I-ORG', 'O', 'O', 'O']

在BIO标注之后将各标记类型转化为数值类型,就可使用任意网络和softmax交叉熵进行损失迭代,即训练一个网络来表征句子,拟合句子中词组到目标标签的关系。


NER中引入CRF的目的

从前文来看只需要引入一个网络对句子进行表征,再结合Softmax交叉熵学习对应的实体类别即可解决NER问题,如下图对每个位置的字词单独预测所属的实体类别。

这种方式忽略了实体类别前后元素之间的关联信息,不同于一般分类模型输出的是一个值,NER输出的是一组值形成的序列,NER中加入CRF的目的是为了学习前后两个连续标注类别的关系,将这种y值的前后迁移关系也加入网络一起进行训练,使得整体网络不仅能够拟合字词和实体类别的关系,也能适配实体类别前后的排列规律

由此引出本文介绍的条件随机场(Conditinal random field, CRF),它用来刻画序列y中上下文的关联,这种关联仅和y内部元素有关,和x无关,而一般的分类网络例如Bert,它仅对句子x到标签的y进行逐位拟合,难以学习到y的上下文关联,一般采用Bert+CRF两者联合训练作为NER任务的Baseline。


CRF中的学习参数

CRF只关心相邻两个类别标签之间的关系,目的是刻画类别A后面紧接着是类别B的可能性大小,我们期望模型能够学习到B-PER后面大概率紧随着I-PER,而B-PER后面不可能是I-ORG,这种模式可以使用得分矩阵来表达


B-PER

I-PER

B-ORG

I-ORG

B-PER

0.2

5

-1

-1

I-PER

0.8

4

0.3

-2

B-ORG

-0.5

-2

0.1

7

I-ORG

0.6

-3

0.3

3

表格中横轴表示前词类别,纵轴表示后词的类别,每个交叉格子中的值代表转移得分,即前词类别后面紧随后词类别的转移得分,转移得分越大这种类别前后搭配的可能性越大,越符合人类的语言习惯。

CRF的学习参数就是这个转移矩阵,以BIO标注为例,如果要做PER,ORG这两类的实体识别,则一共有B-PER,I-PER,B-ORG,I-ORG,O这5类标签,转移矩阵是一个5×5的方阵。

转移矩阵能够刻画前后两个词的情况,而原始句子中的第一个词没有前词,最后一个词没有紧随的词,因此在句子的开头和结尾分别加上一个<start>,<end>的占位符,<start>到词首类别形成一个开始转移向量,用来学习该类别作为词首的合理性,词尾类别到<end>形成一个结束转移向量,用来学习该类别作为词尾的合理性。令一共存在k个BIO类别,CRF层一共需要学习1个转移得分矩阵,和2个转移得分向量,包含k×k+2k个模型参数。


CRF的损失函数

前文提到Bert+CRF作为NER的一般范式,Bert学习每个词在该位置上对应的实体类别的可能性,也叫发射得分矩阵,发射得分矩阵再输入给CRF融入转移得分矩阵一起联合训练,模型采用CRF层的损失来训练迭代发射得分矩阵与转移得分矩阵。

CRF层的损失聚焦实体类别的完整路径,期望那条唯一正确的标记路径在所有可能的路径下得分最高,且占比越大越好最大,我们称之为归一化路径得分。设所有类别的排列组合会生成n条路径y_n,其中正确的标记路径为y_true,则

其中S是一条路径的最终得分,它等于该路径各位置下发射矩阵和转移矩阵得分的累加,其中T表示转移矩阵,E表示发射矩阵。

P(y|x)是一个0~1之间的数,越大代表标记正确的类别路径在所有路径中得分越高,则模型识别实体越准,因此引入负的对数似然构成CRF层的损失函数使其最小化

该公式中第二项Syn是所有路径的得分总和,它采用递归的方式计算,即逐位记录下每个位置以各个标签作为终点的得分,再以此得分作为下一个位置的发射得分,在下文源码解析中对该部分做详细介绍。

相比于softmax交叉熵损失关注的是每个位置的分类对错,CRF的损失关注的是整个序列的合理性,它是由每个位置分类对错和分类上下文对错综合考量而来的。令句子长度为n,实体类别数为k,前者将序列标注看成是n个k分类问题,后者将序列标注看成是1个k*n分类问题。


PyTorch源码解析

采用torchcrf实现的CRF做源码解析,它采用PyTorch框架封装了CRF的损失和维特比求解的过程,本文只涉及CRF的损失部分。可以通过pip很方便的安装torchcrf

pip install torchcrf

1.定义CRF参数

作者在构造器中定义了CRF的三个转移矩阵作为模型的学习参数

class CRF(nn.Module):
 def __init__(self, num_tags: int, batch_first: bool = False) -> None:
        if num_tags <= 0:
            raise ValueError(f'invalid number of tags: {num_tags}')
        super().__init__()
        # TODO {"O": 0, "B-PER": 1, "I-PER": 2, "B-ORG": 3, "I-ORG": 4, "B-LOC": 5, "I-LOC": 6}
        self.num_tags = num_tags
        self.batch_first = batch_first  # False
        # TODO 增加start到下一个的转移矩阵 
        self.start_transitions = nn.Parameter(torch.empty(num_tags))
        # TODO 增加最后一个字到end的转移矩阵 
        self.end_transitions = nn.Parameter(torch.empty(num_tags))
        # TODO 除start和end之外的各tag之间的转移矩阵
        self.transitions = nn.Parameter(torch.empty(num_tags, num_tags))
        # 三大转移矩阵初始化,全部-0.1~0.1均匀分布
        self.reset_parameters()

其中start_transitions,end_transitions,transitions是转移矩阵,令类别数量为k,则start_transitions,end_transitions是k维向量,transitions是k×k的矩阵。

2.计算正确路径的得分

在初始化模块,torchcrf会自己维护转移矩阵,而在forward前向传播部分,需要额外输入前层网络输出的发射矩阵,根据发射矩阵emissions,转移矩阵,正确的标签tags计算出正确路径的得分,numerator形成了P(y|x)的分子

numerator = self._compute_score(emissions, tags, mask)

计算过程和注解如下

        # TODO 金标准第一个元素,调用对应的转移矩阵
        # TODO 拿到从开始到第一个字的转移概率
        # TODO [batch_size] 计算每条样本第一个位置的得分(start => token的转移得分)
        score = self.start_transitions[tags[0]]
        # emissions: (seq_length, batch_size, num_tags)
        # TODO 该批次下所有第一个字的发射概率 => [batch_size, ]
        # TODO torch.arange(batch_size)是横坐标,tags[0]是纵坐标,通过横纵坐标把该批次下每个样本和金标准符合的位置的值拿出来作为发射概率
        score += emissions[0, torch.arange(batch_size), tags[0]]

        # TODO 下一个位置的值只和上一个位置的值的转移矩阵和该位置的发射矩阵有关
        for i in range(1, seq_length):
            # TODO 字和字之间的转移矩阵登场,这个转移矩阵只和label有关,tags[i - 1], tags[i]指定了横纵坐标
            # TODO mask和转移概率对应位置相乘element-wise
            # TODO tags[i - 1]是前一个位置tag的全部横坐标,tags[i]是后一个位置tag的全部纵坐标,拿到对应的转移得分
            # TODO 和mask对应位置相乘,如果某条样本该位置值是0,则该条样本的路径已经结束,后续所有不计入计算得分
            score += self.transitions[tags[i - 1], tags[i]] * mask[i]
            # TODO 对应的发射概率也和mask相乘,score的形状一直维护在(batch_size,),每条样本之后自己做+=
            score += emissions[i, torch.arange(batch_size), tags[i]] * mask[i]

        # TODO 每条样本的真实长度-1,-1是因为索引从0开始否则越界
        seq_ends = mask.long().sum(dim=0) - 1
        # TODO 拿到每个句子最后一个字所对应的金标准y
        last_tags = tags[seq_ends, torch.arange(batch_size)]
        # TODO 加上最后的 =>end 的转移概率
        score += self.end_transitions[last_tags]

注意所有计算以一个batch为单位进行,一个batch下的所有样本并行计算,互不干扰,从句首开始分别调用对应的转移矩阵和发射矩阵,通过横纵的索引位置拿到对应的转移得分和发射得分,一直相加到句尾,最终形成一个batch size长度的得分向量,代表该批次下每条样本的正确路径的得分。

3.计算所有路径的得分

根据发射矩阵emissions,转移矩阵来计算所有路径得分,在计算所有路径得分的时候并不需要标签y,denominator形成了P(y|x)的分母

denominator = self._compute_normalizer(emissions, mask)

重点对以下三行做讲解

# TODO 笛卡尔积相加,broadcast_score为前分,dim=1, broadcast_emissions为后分,dim=2
next_score = broadcast_score + self.transitions + broadcast_emissions
# TODO 将所有结尾tag相同的得分相加
next_score = torch.logsumexp(next_score, dim=1)
# TODO 如果mask=1,则分数更新,否则分数不变
score = torch.where(mask[i].unsqueeze(1), next_score, score)

全部路径的得分和计算正确标签单个路径得分是一样的,一个批次下从句首开始逐位相加到句尾,其中broadcast_score为该位置之前的路径的整体在每个标签下的发射得分,broadcast_emissions为该位置的发射得分,而transitions构成了前后位置之间的转移得分,broadcast_score和broadcast_emissions形成笛卡尔积交叉的全部相加,相当于穷举了下一个位置的转移和发射的全部可能得分情况,累加到之前路径上,示意图如下

由于next_score是一个(batch_size, num_tags, num_tags)的矩阵,下一步需要对结果进行聚合,将所有结尾相同类别的路径做求和合并,形成新的每个类别为发射得分再递归计算,作者采用logsumexp第1维进行聚合,我们知道第1维是前词,第2维是后词,对前词进行聚合相加代表后词的类别不动,所有前词不同的情况相加,示意图如红色线条所示

至于为什么是logsumexp而不是sum,举个简单的例子,路径a有a1,a2两种可能,路径b有b1,b2两种可能,从P(y|x)原始公式出发则全部路径得分计算如下

它等价于使用logsumexp先对a和b进行聚合,log和e可以约掉

聚合后得到新的位置的综合发射得分,给到下一个位置使用,而需要注意mask=1,则分数更新,否则分数不变,mask=0代表该批次下这个句子已经结束,计算句子得分不需要再增长。

这个递归过程一直持续到该批次下所有样本达到句尾,最终得到了每条样本以各个分类标签为结尾的得分,若标签分类数有k种,则输出(batch_size, k)的二维矩阵,紧接着我们将各标签结尾的情况对应的得分再做一次汇总即可得到最终所有路径的得分,_compute_normalizer函数最终的返回如下

return torch.logsumexp(score, dim=1)

4.计算归一化正确路径的得分

在求得分子和分母之后,作者直接套用公式,经过化简即可得到最终的归一化得分

numerator = self._compute_score(emissions, tags, mask)
denominator = self._compute_normalizer(emissions, mask)
# TODO llh => Log likelihood, 两个log相减,指数相除、
llh = numerator - denominator

此处llh指的是对数似然,最终forward前向传播返回CRF层的损失函数。

因此在计算损失时需要对返回额外取一个负号。到此torchcrf中关于CRF的损失计算部分介绍完毕。

相关推荐

如何将数据仓库迁移到阿里云 AnalyticDB for PostgreSQL

阿里云AnalyticDBforPostgreSQL(以下简称ADBPG,即原HybridDBforPostgreSQL)为基于PostgreSQL内核的MPP架构的实时数据仓库服务,可以...

Python数据分析:探索性分析

写在前面如果你忘记了前面的文章,可以看看加深印象:Python数据处理...

CSP-J/S冲奖第21天:插入排序

...

C++基础语法梳理:算法丨十大排序算法(二)

本期是C++基础语法分享的第十六节,今天给大家来梳理一下十大排序算法后五个!归并排序...

C 语言的标准库有哪些

C语言的标准库并不是一个单一的实体,而是由一系列头文件(headerfiles)组成的集合。每个头文件声明了一组相关的函数、宏、类型和常量。程序员通过在代码中使用#include<...

[深度学习] ncnn安装和调用基础教程

1介绍ncnn是腾讯开发的一个为手机端极致优化的高性能神经网络前向计算框架,无第三方依赖,跨平台,但是通常都需要protobuf和opencv。ncnn目前已在腾讯多款应用中使用,如QQ,Qzon...

用rust实现经典的冒泡排序和快速排序

1.假设待排序数组如下letmutarr=[5,3,8,4,2,7,1];...

ncnn+PPYOLOv2首次结合!全网最详细代码解读来了

编辑:好困LRS【新智元导读】今天给大家安利一个宝藏仓库miemiedetection,该仓库集合了PPYOLO、PPYOLOv2、PPYOLOE三个算法pytorch实现三合一,其中的PPYOL...

C++特性使用建议

1.引用参数使用引用替代指针且所有不变的引用参数必须加上const。在C语言中,如果函数需要修改变量的值,参数必须为指针,如...

Qt4/5升级到Qt6吐血经验总结V202308

00:直观总结增加了很多轮子,同时原有模块拆分的也更细致,估计为了方便拓展个管理。把一些过度封装的东西移除了(比如同样的功能有多个函数),保证了只有一个函数执行该功能。把一些Qt5中兼容Qt4的方法废...

到底什么是C++11新特性,请看下文

C++11是一个比较大的更新,引入了很多新特性,以下是对这些特性的详细解释,帮助您快速理解C++11的内容1.自动类型推导(auto和decltype)...

掌握C++11这些特性,代码简洁性、安全性和性能轻松跃升!

C++11(又称C++0x)是C++编程语言的一次重大更新,引入了许多新特性,显著提升了代码简洁性、安全性和性能。以下是主要特性的分类介绍及示例:一、核心语言特性1.自动类型推导(auto)编译器自...

经典算法——凸包算法

凸包算法(ConvexHull)一、概念与问题描述凸包是指在平面上给定一组点,找到包含这些点的最小面积或最小周长的凸多边形。这个多边形没有任何内凹部分,即从一个多边形内的任意一点画一条线到多边形边界...

一起学习c++11——c++11中的新增的容器

c++11新增的容器1:array当时的初衷是希望提供一个在栈上分配的,定长数组,而且可以使用stl中的模板算法。array的用法如下:#include<string>#includ...

C++ 编程中的一些最佳实践

1.遵循代码简洁原则尽量避免冗余代码,通过模块化设计、清晰的命名和良好的结构,让代码更易于阅读和维护...

取消回复欢迎 发表评论: