前言
本文主要记录学习使用BiLSTM-CRF模型来完成命名实体识别的过程中,对原理和代码的理解。下面会通过推导模型原理,来解释官方示例代码(tutorial)。在学习原理的过程中主要参考了这两篇博客:命名实体识别(NER):BiLSTM-CRF原理介绍+Pytorch_Tutorial代码解析,其中有不少图能帮助我们更好地理解模型;Bi-LSTM-CRF算法详解-1,这篇里的公式推导比较简单易懂。下面的解析会借鉴这两篇博客中的内容,建议在往下看前先读一下这两篇了解原理。在BiLSTM-CRF模型中,我对LSTM模型这部分的理解还不够深入,所以本文对它的介绍会少一些。
源代码
首先贴上官方示例代码,这段代码实现了BiLSTM-CRF模型的训练及预测,语料数据是作者随便想的两句话,最终实现了对语料中每个字进行实体标注。建议将代码贴到IDE中,与之后的原理推导对照着看。
# Author: Robert Guthrie
import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.optim as optim
device=torch.device('cuda:0')
# 为CPU中设置种子,生成随机数
torch.manual_seed(1)
# 得到最大值的索引
def argmax(vec):
# return the argmax as a python int
_, idx = torch.max(vec, 1)
return idx.item()
def prepare_sequence(seq, to_ix):
idxs = [to_ix[w] for w in seq]
return torch.tensor(idxs, dtype=torch.long)
# Compute log sum exp in a numerically stable way for the forward algorithm
# 等同于torch.log(torch.sum(torch.exp(vec))),防止e的指数导致计算机上溢
def log_sum_exp(vec):
max_score = vec[0, argmax(vec)]
max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])
return max_score + \
torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))
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)
# Maps the output of the LSTM into tag space.
self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
# Matrix of transition parameters. Entry i,j is the score of
# transitioning *to* i *from* j.
# 转移矩阵,transaction[i][j]表示从label_j转移到label_i的概率,虽然是随机生成的,但是后面会迭代更新
self.transitions = nn.Parameter(
torch.randn(self.tagset_size, self.tagset_size))
# These two statements enforce the constraint that we never transfer
# to the start tag and we never transfer from the stop tag
# 设置任何标签都不可能转移到开始标签。设置结束标签不可能转移到其他任何标签
self.transitions.data[tag_to_ix[START_TAG], :] = -10000
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
# 随机初始化lstm的输入(h_0,c_0)
self.hidden = self.init_hidden()
# 随机生成输入的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_new(self, feats):
# Do the forward algorithm to compute the partition function
init_alphas = torch.full([self.tagset_size], -10000.)
# START_TAG has all of the score.
init_alphas[self.tag_to_ix[START_TAG]] = 0.
# Wrap in a variable so that we will get automatic backprop
# Iterate through the sentence
forward_var_list = []
forward_var_list.append(init_alphas)
for feat_index in range(feats.shape[0]): # -1
gamar_r_l = torch.stack([forward_var_list[feat_index]] * feats.shape[1])
# gamar_r_l = torch.transpose(gamar_r_l,0,1)
t_r1_k = torch.unsqueeze(feats[feat_index], 0).transpose(0, 1) # +1
aa = gamar_r_l + t_r1_k + self.transitions
# forward_var_list.append(log_add(aa))
forward_var_list.append(torch.logsumexp(aa, dim=1))
terminal_var = forward_var_list[-1] + self.transitions[self.tag_to_ix[STOP_TAG]]
terminal_var = torch.unsqueeze(terminal_var, 0)
alpha = torch.logsumexp(terminal_var, dim=1)[0]
return alpha
# 求所有可能路径得分之和
def _forward_alg(self, feats):
# Do the forward algorithm to compute the partition function
# 输入:发射矩阵,实际就是LSTM的输出————sentence的每个word经LSTM后,对应于每个label的得分
# 输出:所有可能路径得分之和/归一化因子/配分函数/Z(x)
init_alphas = torch.full((1, self.tagset_size), -10000.)
# START_TAG has all of the score.
init_alphas[0][self.tag_to_ix[START_TAG]] = 0.
# 包装到一个变量里以便自动反向传播
# Wrap in a variable so that we will get automatic backprop
forward_var = init_alphas
# Iterate through the sentence
for feat in feats:
alphas_t = [] # The forward tensors at this timestep
for next_tag in range(self.tagset_size):
# 当前层这一点的发射得分要与上一层所有点的得分相加,为了用加快运算,将其扩充为相同维度的矩阵
emit_score = feat[next_tag].view(
1, -1).expand(1, self.tagset_size)
# 前一层5个previous_tags到当前层当前tag_i的transition scors
trans_score = self.transitions[next_tag].view(1, -1)
# 前一层所有点的总得分 + 前一节点标签转移到当前结点标签的得分(边得分) + 当前点的发射得分
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)
# 最后将最后一个单词的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()
embeds = self.word_embeds(sentence).view(len(sentence), 1, -1)
# lstm模型的输出矩阵维度为(seq_len,batch,num_direction*hidden_dim)
# 所以此时lstm_out的维度为(11,1,4)
lstm_out, self.hidden = self.lstm(embeds, self.hidden)
# 把batch维度去掉,以便接入全连接层
lstm_out = lstm_out.view(len(sentence), self.hidden_dim)
# 用一个全连接层将其转换为(seq_len,tag_size)维度,才能生成最后的Emission Score
lstm_feats = self.hidden2tag(lstm_out)
return lstm_feats
def _score_sentence(self, feats, tags):
# Gives the score of a provided tag sequence
# 输入:feats——emission scores;tag——真实序列标注,以此确定转移矩阵中选择哪条路径
# 输出:真是路径得分
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]]
# 最后加上STOP_TAG标签的转移得分,其发射得分为0,可以忽略
score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
return score
def _viterbi_decode(self, feats):
# 预测路径得分,维特比解码,输出得分与路径值
backpointers = []
# Initialize the viterbi variables in log space
# B:0 I:1 O:2 START_TAG:3 STOP_TAG:4
init_vvars = torch.full((1, self.tagset_size), -10000.)
# 维特比解码的开始:一个START_TAG,得分设置为0,其他标签的得分可设置比0小很多的数
init_vvars[0][self.tag_to_ix[START_TAG]] = 0
# forward_var表示当前这个字被标注为各个标签的得分(概率)
# forward_var at step i holds the viterbi variables for step i-1
forward_var = init_vvars
# 遍历每个字,过程中取出这个字的发射得分
for feat in feats:
bptrs_t = [] # holds the backpointers for this step
viterbivars_t = [] # holds the viterbi variables for this step
# 遍历每个标签,计算当前字被标注为当前标签的得分
for next_tag in range(self.tagset_size):
# We don't include the emission scores here because the max
# does not depend on them (we add them in below)
# forward_var保存的是之前的最优路径的值,然后加上转移到当前标签的得分,
# 得到当前字被标注为当前标签的得分(概率)
next_tag_var = forward_var + self.transitions[next_tag]
# 找出上一个字中的哪个标签转移到当前next_tag标签的概率最大,并把它保存下载
best_tag_id = argmax(next_tag_var)
bptrs_t.append(best_tag_id)
# 把最大的得分也保存下来
viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))
# 然后加上各个节点的发射分数,形成新一层的得分
# cat用于将list中的多个tensor变量拼接成一个tensor
forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1)
# 得到了从上一字的标签转移到当前字的每个标签的最优路径
# bptrs_t有5个元素
backpointers.append(bptrs_t)
# 其他标签到结束标签的转移概率
# Transition to 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]
# Follow the back pointers to decode the best path.
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)
# Pop off the start tag (we dont want to return that to the caller)
# 无需返回最开始的start标签
start = best_path.pop()
assert start == self.tag_to_ix[START_TAG] # Sanity check
# 把从后向前的路径正过来
best_path.reverse()
return path_score, best_path
# 损失函数
def neg_log_likelihood(self, sentence, tags):
# len(s)*5
feats = self._get_lstm_features(sentence)
# 规范化因子 | 配分函数 | 所有路径的得分之和
forward_score = self._forward_alg_new(feats)
# 正确路径得分
gold_score = self._score_sentence(feats, tags)
# 已取反
# 原本CRF是要最大化gold_score - forward_score,但深度学习一般都最小化损失函数,所以给该式子取反
return forward_score - gold_score
# 实际上是模型的预测函数,用来得到一个最佳的路径以及路径得分
def forward(self, sentence): # dont confuse this with _forward_alg above.
# 解码过程,维特比解码选择最大概率的标注路径
# 先放入BiLstm模型中得到它的发射分数
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>"
# 标签一共有5个,所以embedding_dim为5
EMBEDDING_DIM = 5
# BILSTM隐藏层的特征数量,因为双向所以是2倍
HIDDEN_DIM = 4
# Make up some training data
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)
# Check predictions before training
# 首先是用未训练过的模型随便预测一个结果
with torch.no_grad():
precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
precheck_tags = torch.tensor([tag_to_ix[t] for t in training_data[0][1]], dtype=torch.long)
print(model(precheck_sent))
# Make sure prepare_sequence from earlier in the LSTM section is loaded
for epoch in range(300):
for sentence, tags in training_data:
# 训练前将梯度清零
optimizer.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 = model.neg_log_likelihood(sentence_in, targets)
# 反向传播计算loss的梯度
loss.backward()
# 通过梯度来更新模型参数
optimizer.step()
# 使用训练过的模型来预测一个序列,与之前形成对比
with torch.no_grad():
precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
print(model(precheck_sent))
模型简介
BiLSTM-CRF模型是由双向LSTM模型以及CRF模型组合而成,模型的输入是字序列,输出是模型给每个字预测的标签,是一个标签序列。
双向LSTM模型用来生成发射矩阵,也就是每个字被标注为某个标签的概率。其实我们用这个发射矩阵也可以进行命名实体识别,只需要从每个字被标注为各个标签的概率中取最大的那个即可,但实际效果却不是这么简单的,BILSTM模型的发射矩阵没有考虑标签之间的约束关系,比如在BIO体系中,I不能在O之后出现。所以我们要对标签的连接顺序有所约束,这个约束将由CRF模型来生成。
CRF模型用来学习标签之间的约束关系,最终生成一个转移矩阵,可以理解为一个标签后面连接另一个标签的概率。
整个模型在预测时,会结合发射矩阵和转移矩阵,使用维特比解码算法来计算出得分最高的标注序列。下面就结合代码分别对LSTM部分和CRF部分进行解释说明。代码的解释顺序可能与编写顺序不同。
LSTM模型
刚才说了LSTM模型的任务就是生成发射矩阵,在代码中只涉及到BiLSTM_CRF类的初始化函数和_get_lstm_feature函数。
(1)BiLSTM_CRF类的初始化函数
在这个函数中完成了LSTM模型的初始参数设定。函数接受的参数有4个:vocab_size表示语料数据的词表大小,tag_to_ix表示标签的索引列表,embedding_dim表示输入词向量的维度,hidden_dim表示BILSTM模型中隐藏层状态的维数。其中embedding_dim的值为5,hidden_dim的值为4。
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)
self.transitions = nn.Parameter(
torch.randn(self.tagset_size, self.tagset_size))
self.transitions.data[tag_to_ix[START_TAG], :] = -10000
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
self.hidden = self.init_hidden()
在函数体中,因为是双向的,LSTM模块的hidden_dim设置为2。self.hiden2tag表示一个全连接层,因为BILSTM模型输出的维度为(seq_len,batch_size,hidden_dim),而我们想要的发射矩阵维度为(seq_len,tag_size),所以要用这个全连接层来将其维度进行变换。self.transitions表示CRF模型生成的转移矩阵,self.transitions[i][j]表示j标签后连接i标签的得分,开始标签之前没有其他标签,结束标签之后也没有其他标签,所以要对这两个初值进行设置。self.hidden表示LSTM模型的初始隐状态。
(2)_get_lstm_feature函数
这个函数接收一个句子的子序列,输出句子的发射矩阵。首先将句子通过Embedding模块生成词向量,再与随机初始化的隐状态一起输入到lstm模型中,得到输出矩阵后,通过全连接层将其变换到(seq_len,tag_size)维度,这样就得到了发射矩阵。具体LSTM的工作过程可以参考这篇:pytorch中LSTM的细节分析理解。得到发射矩阵后,LSTM部分就结束了。
CRF模型
上面说了CRF模型的任务是生成一个转移矩阵,首先介绍一下CRF模型的原理和公式:
选自李航老师的《统计学习方法》第11章
对于一个句子,其中每个字都有一个标签,将这些字的标签连接起来,就得到一个标签序列,我们可以给一个句子标记出很多个不同的标签序列。所以命名实体识别问题也可以看成是一个条件随机场,输入x为字序列,输出y为对每个字标注的标签序列。下图是模型的公式表示:
CRF模型的关键点在于公式中的三个部分:
、
、
,下面使用一张图来解释这三个部分。
假设有5个字的输入序列(c0、c1、c2、c3、c4),各个字的标签定义为(y0、y1、y2、y3、y4),有5种标签(START、B、I、O、END)。那么每个字都有5种可能的标注,整个句子可能的标签序列共有种,也就是图中的黑色路径。假设正确的标签序列是(B、I、O、O、B),在图中标记为红色。下图第一列为START,后面5列依次表示5个字的标注情况,最后一列表示END:
:整个句子存在的标签序列共有
种,每种序列在图中都有一条唯一的路径,每条路径都设置一个得分,这个得分由路径上每条边与每个点的分数相加得到。所有路径的得分相加就得到了
,也叫配分函数或规范化因子。
:可以理解为图中连接两个圆圈的边的得分,也就是图中给出的序列标注转移矩阵。
:可以理解为体重每个圆圈店的得分,也就是图中所给出的Emission Score。(说到这里,其实可以看出BiLSTM-CRF模型本质是一个CRF模型,只不过CRF模型中的
,也就是发射矩阵,是单独通过LSTM模型来生成的)
如果上面公式没看懂也没关系,到这儿只需要理解每个标签序列是一个路径,每个路径都有一个得分,路径得分=边得分+点得分。
刚才说到我们的目标是通过CRF模型得到转移矩阵,那么方法就是随机初始化一个转移矩阵,然后训练CRF模型,在反向传播过程中不断调整转移矩阵。既然是训练,那么肯定会有一个损失函数,其实这里的损失函数就是上面图中的11.10公式,但这个公式太复杂了,我们换一种写法:
分子表示正确路径的得分,分母表示所有路径的得分。路径得分用score()来计算,也就是上面说的点得分+边得分。这个损失函数的分子和分母中都包含了指数运算,那么我们可以给式子两边取个对数,这样即消除了分子的指数运算,又将除法化成了减法:
再回过头来品一下这个式子:损失=真实路径得分-所有路径得分。随着转移矩阵的不断调整,真实路径得分会变得越来越大,损失函数也增大了,这有点不太符合反向传播的工作机制。解决方法也很简单,就是给式子两端加个负号,在后面的代码中可以体现出来。接下来就是CRF模型的重点:路径得分
路径得分
这个公式,无论如何也要搞清楚。其中表示序列
中第
个标签的发射得分,
表示序列
中第
个标签的转义得分,可以从发射矩阵和转移矩阵中得到。就以上面那张图中的标签序列为例,序列
=(B,I,O,O,B),
,其中:
因为这里的序列是真实序列,所以在这里算出来的路径得分也就是损失函数中的真实路径得分
。
def _score_sentence(self, feats, tags):
score = torch.zeros(1)
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]]
# 最后加上STOP_TAG标签的转移得分,其发射得分为0,可以忽略
score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
return score
代码中的_score_sentence函数就用来计算真实路径得分,输入的是句子的发射矩阵feats和真实标签序列tag,输出的是真实路径得分。cat函数将START标签和真实标签序列拼接在一起,enumerate函数用于遍历发射矩阵,每次取出一个字的发射得分。有了上面的介绍,这部分代码也就非常容易理解了。
然后就是CRF模型中的重中之重:所有路径得分
所有路径得分
现在我们已经明白了如何计算路径得分,可以想到的是,要计算所有路径得分,就用上面的方法把每条路径都计算一遍。但实际上这种方法是非常低效的,例如计算(B,I,O,O,O)这个序列的得分时,计算过程如下:
与上面(B,I,O,O,B)序列得分的计算过程有许多重复的地方,而且标签越多、输入越长,重复的计算就越多。下图可以直观的看出这个问题,如果我们求出所有路径的得分,然后再加起来,计算过程大致是这样的:先算出红色路径的得分,再依次算出绿色、蓝色、黄色和紫色路径的得分。从图中可以看出,这是一种深度优先的方法,过程中重复计算了很多次S列到c3列的得分。如果输入长度为n,有m种标签,那么就会有条路径,每条路径长度为n,可以计算出这种方法的时间复杂度为
。
现在我们用一种新的方法来计算所有路径得分,因为我们想要的是所有路径得分,只要那个最后的总分,也就是图中所有边和所有点的累加和,对中间每条路径的得分并不关心,那么我们可以把求这个总分的问题划分成许多子问题。还是上图这个例子,我们要计算所有路径得分,也就是END点的分数,可以先计算出END点前一列,也就是c4这一列5个点的累积得分,再加上这一列的点与END点相连的边的分数和END点的发射得分(0)即可;想要计算c4这一列所有点的累积得分,可以先计算出c3这一列所有点的累积得分,再加上两列之间所有边的得分和c4列5个点的发射得分即可。这种方法利用了动态规划的思想,将问题拆分为多个子问题,而且子问题之间是有关联的,后面的计算利用了前面的计算结果,我们把这种方法成为分数累积。现在我们已经清楚了分数累积的计算过程,先计算出c0列5个点的累积得分,然后求出c1列5个点的累积的分,最后推到END列,就得到了所有路径得分。下面用一个简单的例子来进行公式化推导:
假设输入为(c0,c1,c2),标签只有两种(),发射得分用
表示,设置发射矩阵为:
转移得分用来表示,设置转移矩阵为:
接着从损失函数中找到我们的目标,表示每条路径的得分:
在开始前定义两个变量:previous表示前一列的点的累积分数,now表示当前列的点的发射得分。然后开始推导:
(1)从c0列开始,now就是当前c0的发射得分,前面也没有其他矩阵,所以previous为空。
,
此时,我们的目标值为:
(2)c0->c1,此时now就是c1的发射得分,previous就是c0列的得分,这个时候因为发生了标签的转移,所以我们要用到转移矩阵。
,
需要注意的是,从c0到c1,两个点,两种标签,所以共有四种标签的转移方式:分别是c0(1)->c1(1)、c0(1)->c1(2)、c0(2)->c1(1)、c0(2)->c1(2)。在计算它们时,公式分别是:
、
、
、
分别计算的话肯定费时,在这里我们可以将now和previous扩展成矩阵,这样可以使运算实现矩阵化:
然后我们将previous、obs、转移得分加起来:
计算结果中第一列就表示c1被标记为第一个标签的两条路径的得分,第二列就表示c1被标记为第二个标签的两条路径的得分。将两列分别整合起来就构成了c1列留给c2列的previous值:
此时我们的目标值为:
其实到这里,如果没有c2,上面的式子就是最终结果了。只不过是将目标函数中的用真实路径代替了:
现在应该对分数累积的过程有了大致的了解,下面只需要重复这个过程,就可以得到最后的结果。
(3)c0->c1->c2
下面是矩阵的扩展,以及相加化简运算,这里我就不再一个个敲公式了:
直到这里,previous已经计算得到c2到两个标签的路径得分了,然后用这个previous来计算最终结果:
最后一个式子也就是我们的目标值,因为整个输入长度为3,有两种标签,所以有8条路径,都包含在了结果中。
在代码中,所有路径得分由_forward_alg函数计算得到,该函数接收一个句子的发射矩阵,开始的init_alphas就是可以看做是第一个字的previous。遍历每一个字,得到当前字的obs、previous、转移得分,然后相加生成新的previous,不断往后推导。如果能看懂上面的公式推导,下面的代码结合注释也很容易懂了。
def _forward_alg(self, feats):
init_alphas = torch.full((1, self.tagset_size), -10000.)
# START_TAG has all of the score.
init_alphas[0][self.tag_to_ix[START_TAG]] = 0.
# 包装到一个变量里以便自动反向传播
forward_var = init_alphas
for feat in feats:
alphas_t = [] # The forward tensors at this timestep
for next_tag in range(self.tagset_size):
# 取出obs,扩充维度
emit_score = feat[next_tag].view(
1, -1).expand(1, self.tagset_size)
# 前一层5个previous_tags到当前层当前tag_i的transition scors
trans_score = self.transitions[next_tag].view(1, -1)
# previous + 转移得分 + obs
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))
# 生成新的previous
forward_var = torch.cat(alphas_t).view(1, -1)
# 最后将最后一个单词的previous与转移到stop tag的概率相加
terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
alpha = log_sum_exp(terminal_var)
return alpha
到这里,CRF层的原理已经结束了。然后就是计算损失函数,然后不断训练,优化转移矩阵。代码中的neg_log_likelihood函数就是损失函数,在明白原理后这个函数也不难理解。训练过程也是神经网络训练的常规做法,也不难理解。在训练好模型后,我们得到了发射矩阵和最优化的转移矩阵,接下来就是预测。
对于一个长度为n的新句子,标签有m中,依然是有种可能的标签序列,这里可以借助上面用到的一张图:
图中的所有路径都是一个标签序列,现在我们已经知道了图中每条边和每个点的得分,预测就是从这么多条路径中找到一条边得分+点得分最大的路径,方法就是维特比解码。这个方法相对CRF模型的原理来说简单了不少,可以参考这篇:如何通俗地讲解 viterbi 算法?,如果能看懂这篇里的内容,那么结合我在代码中写的注释,应该可以很容易理解这个过程。