摘要:CRF
,条件随机场
,序列标注
,命名实体识别
内容摘要
- NER任务简介
- NER中引入CRF的目的
- CRF中的学习参数
- CRF的损失函数
- CRF PyTorch源码解析
NER任务简介
命名实体识别(Named Entity Recognition,NER),是指识别文本中有特定意义的实体的边界和类别,所指的实体包括人名、地名、机构名,或者某种具有特定业务含义的词组等,NER的目标是识别出这些特定词组在文本中的位置,并且给该位置下的词组标记出正确的实体业务含义类别。
NER属于序列标注任务,即将输入文本分词后转化为序列,对序列的每一个元素位置进行分类预测,预测该元素所属的类别。一般的NER使用BIO标注作为学习目标,其中B作为实体的词首,I作为实体词中或者结尾,剩余的非命名实体元素词全部置为O,对于多种实体类别使用任意字符串加在B,I后面进行表示,例如以下句子包含一个需要识别的人名(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>到词1类别形成一个开始转移矩阵,用来学习该类别作为词首的合理性,词n类别到<end>形成一个结束转移矩阵,用来学习该类别作为词尾的合理性。还是以PER,ORG这两类实体任务为例,开始转移矩阵为长度是5的向量,结束转移矩阵也是长度为5的向量,令一共存在k个BIO类别,CRF层一共需要学习1个转移得分矩阵2个转移得分向量,包含k×k+2k个模型参数。
CRF的损失函数
前文提到Bert+CRF作为NER的一般范式,Bert学习每个词在该位置上对应的实体类别的可能性,也叫发射得分矩阵,发射得分矩阵再输入给CRF融入转移得分矩阵一起联合训练,模型采用CRF层的损失来训练Bert层的发射得分矩阵与CRF层的转移得分矩阵。
CRF层的损失聚焦实体类别的完整路径,期望n那条唯一正确的标记路径在所有可能的路径下得分最高,占比最大,我们称之为归一化路径得分。设所有类别的排列组合会生成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):
# shape: (batch_size,)
# 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]
# shape: (batch_size,)
# TODO 对应的发射概率也和mask相乘,score的形状一直维护在(batch_size,),每条样本之后自己做+=
score += emissions[i, torch.arange(batch_size), tags[i]] * mask[i]
# shape: (batch_size,)
# TODO 每条样本的真实长度-1,-1是因为索引从0开始否则越界
seq_ends = mask.long().sum(dim=0) - 1
# shape: (batch_size,)
# TODO 拿到每个句子最后一个字所对应的金标准y
last_tags = tags[seq_ends, torch.arange(batch_size)]
# shape: (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维进行聚合,我们知道第一维是前词,第二维是后词,对前词进行聚合相加代表后词的类别不动,所有前词不同的情况相加,示意图如红色线条所示
对后词类别相同的路径做聚合至于为什么是logsumexp而不是sum,举个简单的例子,路径a有a1,a2两种可能,路径b有b1,b2两种可能,从P(y|x)原始公式出发则全部路径得分计算如下
全部路径得分计算1他等价于使用logsumexp先对a和b进行聚合,log和e可以约掉
全部路径得分计算2聚合后得到新的位置的综合发射得分,给到下一个位置使用,而需要注意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的损失计算部分介绍完毕。
网友评论