前言
本文主要记录学习使用BiLSTM-CRF模型来完成命名实体识别的过程中,对原理和代码的理解。下面会通过推导模型原理,来解释官方示例代码(tutorial)。在学习原理的过程中主要参考了这两篇博客:命名实体识别(NER):BiLSTM-CRF原理介绍+Pytorch_Tutorial代码解析,其中有不少图能帮助我们更好地理解模型;Bi-LSTM-CRF算法详解-1,这篇里的公式推导比较简单易懂。下面的解析会借鉴这两篇博客中的内容,建议在往下看前先读一下这两篇了解原理。在BiLSTM-CRF模型中,我对LSTM模型这部分的理解还不够深入,所以本文对它的介绍会少一些。
源代码
首先贴上官方示例代码,这段代码实现了BiLSTM-CRF模型的训练及预测,语料数据是作者随便想的两句话,最终实现了对语料中每个字进行实体标注。建议将代码贴到IDE中,与之后的原理推导对照着看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | # 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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),,其中:
因为这里的序列是真实序列,所以在这里算出来的路径得分也就是损失函数中的真实路径得分。
1 2 3 4 5 6 7 8 9 10 | 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,不断往后推导。如果能看懂上面的公式推导,下面的代码结合注释也很容易懂了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | 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 算法?,如果能看懂这篇里的内容,那么结合我在代码中写的注释,应该可以很容易理解这个过程。