结合原理与代码理解BiLSTM-CRF模型(pytorch)

前言

本文主要记录学习使用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模型的关键点在于公式中的三个部分:t_{k}s_{l}Z(x),下面使用一张图来解释这三个部分。

假设有5个字的输入序列(c0、c1、c2、c3、c4),各个字的标签定义为(y0、y1、y2、y3、y4),有5种标签(START、B、I、O、END)。那么每个字都有5种可能的标注,整个句子可能的标签序列共有5^{5}种,也就是图中的黑色路径。假设正确的标签序列是(B、I、O、O、B),在图中标记为红色。下图第一列为START,后面5列依次表示5个字的标注情况,最后一列表示END:

  • Z(x):整个句子存在的标签序列共有5^{5}种,每种序列在图中都有一条唯一的路径,每条路径都设置一个得分,这个得分由路径上每条边与每个点的分数相加得到。所有路径的得分相加就得到了Z(x),也叫配分函数或规范化因子。
  • t_{k}:可以理解为图中连接两个圆圈的边的得分,也就是图中给出的序列标注转移矩阵。s_{l}:可以理解为体重每个圆圈店的得分,也就是图中所给出的Emission Score。(说到这里,其实可以看出BiLSTM-CRF模型本质是一个CRF模型,只不过CRF模型中的s_{l},也就是发射矩阵,是单独通过LSTM模型来生成的)

如果上面公式没看懂也没关系,到这儿只需要理解每个标签序列是一个路径,每个路径都有一个得分,路径得分=边得分+点得分。

刚才说到我们的目标是通过CRF模型得到转移矩阵,那么方法就是随机初始化一个转移矩阵,然后训练CRF模型,在反向传播过程中不断调整转移矩阵。既然是训练,那么肯定会有一个损失函数,其实这里的损失函数就是上面图中的11.10公式,但这个公式太复杂了,我们换一种写法:

分子表示正确路径的得分,分母表示所有路径的得分。路径得分用score()来计算,也就是上面说的点得分+边得分。这个损失函数的分子和分母中都包含了指数运算,那么我们可以给式子两边取个对数,这样即消除了分子的指数运算,又将除法化成了减法:

再回过头来品一下这个式子:损失=真实路径得分-所有路径得分。随着转移矩阵的不断调整,真实路径得分会变得越来越大,损失函数也增大了,这有点不太符合反向传播的工作机制。解决方法也很简单,就是给式子两端加个负号,在后面的代码中可以体现出来。接下来就是CRF模型的重点:路径得分


路径得分

这个公式,无论如何也要搞清楚。其中P_{i,y_{i}}表示序列y中第y_{i}个标签的发射得分,A_{y_{i-1},y_{i}}表示序列y中第y_{i}个标签的转义得分,可以从发射矩阵和转移矩阵中得到。就以上面那张图中的标签序列为例,序列y=(B,I,O,O,B),score(x,y)=\sum EmissionScores + \sum TransitionScores,其中:

\sum EmissionScores=P_{0,START}+P_{1,B}+P_{2,I}+P_{3,O}+P_{4,O}+P_{5,B}+P_{6,END}

\sum TransitionScores=A_{START,B}+A_{B,I}+A_{I,O}+A_{O,O}+A_{O,B}+A_{B,END}

因为这里的序列y是真实序列,所以在这里算出来的路径得分也就是损失函数中的真实路径得分score(x,\bar{y})

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)这个序列的得分时,计算过程如下:

\sum EmissionScores=P_{0,START}+P_{1,B}+P_{2,I}+P_{3,O}+P_{4,O}+P_{5,O}+P_{6,END}

\sum TransitionScores=A_{START,B}+A_{B,I}+A_{I,O}+A_{O,O}+A_{O,O}+A_{B,END}

与上面(B,I,O,O,B)序列得分的计算过程有许多重复的地方,而且标签越多、输入越长,重复的计算就越多。下图可以直观的看出这个问题,如果我们求出所有路径的得分,然后再加起来,计算过程大致是这样的:先算出红色路径的得分,再依次算出绿色、蓝色、黄色和紫色路径的得分。从图中可以看出,这是一种深度优先的方法,过程中重复计算了很多次S列到c3列的得分。如果输入长度为n,有m种标签,那么就会有m^{n}条路径,每条路径长度为n,可以计算出这种方法的时间复杂度为O(nm^{n})

现在我们用一种新的方法来计算所有路径得分,因为我们想要的是所有路径得分,只要那个最后的总分,也就是图中所有边和所有点的累加和,对中间每条路径的得分并不关心,那么我们可以把求这个总分的问题划分成许多子问题。还是上图这个例子,我们要计算所有路径得分,也就是END点的分数,可以先计算出END点前一列,也就是c4这一列5个点的累积得分,再加上这一列的点与END点相连的边的分数和END点的发射得分(0)即可;想要计算c4这一列所有点的累积得分,可以先计算出c3这一列所有点的累积得分,再加上两列之间所有边的得分和c4列5个点的发射得分即可。这种方法利用了动态规划的思想,将问题拆分为多个子问题,而且子问题之间是有关联的,后面的计算利用了前面的计算结果,我们把这种方法成为分数累积。现在我们已经清楚了分数累积的计算过程,先计算出c0列5个点的累积得分,然后求出c1列5个点的累积的分,最后推到END列,就得到了所有路径得分。下面用一个简单的例子来进行公式化推导:

假设输入为(c0,c1,c2),标签只有两种(l1,l2),发射得分用x_{i,label}表示,设置发射矩阵为:

preview

转移得分用t_{l_{i},l_{i+1}}来表示,设置转移矩阵为:

preview

接着从损失函数中找到我们的目标,s_{i}表示每条路径的得分:

在开始前定义两个变量:previous表示前一列的点的累积分数,now表示当前列的点的发射得分。然后开始推导:

(1)从c0列开始,now就是当前c0的发射得分,前面也没有其他矩阵,所以previous为空。

now=[x_{01},x_{02}]previous=None

此时,我们的目标值为:TotalScores(c0))=log(e^{x_{01}}+e^{x_{02}})

(2)c0->c1,此时now就是c1的发射得分,previous就是c0列的得分,这个时候因为发生了标签的转移,所以我们要用到转移矩阵。

now=[x_{11},x_{12}]previois=[x_{01},x_{02}]

需要注意的是,从c0到c1,两个点,两种标签,所以共有四种标签的转移方式:分别是c0(1)->c1(1)、c0(1)->c1(2)、c0(2)->c1(1)、c0(2)->c1(2)。在计算它们时,公式分别是:

x_{01}+x_{11}+t_{11}x_{01}+x_{12}+t_{12}x_{02}+x_{11}+t_{21}x_{02}+x_{12}+t_{22}

分别计算的话肯定费时,在这里我们可以将now和previous扩展成矩阵,这样可以使运算实现矩阵化:

obs=\begin{pmatrix} x_{11} & x_{12}\\ x_{11} & x_{12} \end{pmatrix} previous=\begin{pmatrix} x_{01}& x_{01} \\ x_{02} & x_{02} \end{pmatrix}

然后我们将previous、obs、转移得分加起来:

score=\begin{pmatrix} x_{01} &x_{01} \\x_{02} & x_{02} \end{pmatrix}+\begin{pmatrix} x_{11} &x_{12} \\x_{11} & x_{12} \end{pmatrix}+\begin{pmatrix} t_{11} &t_{12} \\t_{21} & t_{22} \end{pmatrix}=\begin{pmatrix} x_{01}+x_{11}+t_{11} &x_{01}+x_{12}+t_{12} \\ x_{02}+x_{11}+t_{21} & x_{02}+x_{12}+t_{22} \end{pmatrix}

计算结果中第一列就表示c1被标记为第一个标签的两条路径的得分,第二列就表示c1被标记为第二个标签的两条路径的得分。将两列分别整合起来就构成了c1列留给c2列的previous值:

previous=[log(e^{x_{01}+x_{11}+t_{11}}+e^{x_{02}+x_{11}+t_{21}}),log(e^{x_{01}+x_{12}+t_{12}}+e^{x_{02}+x_{12}+t_{22}})]

此时我们的目标值为:

\\\begin{aligned} TotalScores(c0\rightarrow c1) &= log(e^{previous[0]}+e^{previous[1]}) \\ &= log(e^{log(e^{x_{01}+x_{11}+t_{11}}+e^{x_{02}+x_{11}+t_{21}})}+e^{log(e^{x_{01}+x_{12}+t_{12}}+e^{x_{02}+x_{12}+t_{22}})}) \\ &= log(e^{x_{01}+x_{11}+t_{11}}+e^{x_{02}+x_{11}+t_{21}}+e^{x_{01}+x_{12}+t_{12}}+e^{x_{02}+x_{12}+t_{22}}) \end{aligned}

其实到这里,如果没有c2,上面的式子就是最终结果了。只不过是将目标函数中的s_{i}用真实路径代替了:

现在应该对分数累积的过程有了大致的了解,下面只需要重复这个过程,就可以得到最后的结果。

(3)c0->c1->c2

obs=[x_{21},x_{22}] previous=[log(e^{x_{01}+x_{11}+t_{11}}+e^{x_{02}+x_{11}+t_{21}}),log(e^{x_{01}+x_{12}+t_{12}}+e^{x_{02}+x_{12}+t_{22}})]

下面是矩阵的扩展,以及相加化简运算,这里我就不再一个个敲公式了:

preview

直到这里,previous已经计算得到c2到两个标签的路径得分了,然后用这个previous来计算最终结果:

preview

最后一个式子也就是我们的目标值,因为整个输入长度为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中,依然是有m^{n}种可能的标签序列,这里可以借助上面用到的一张图:

图中的所有路径都是一个标签序列,现在我们已经知道了图中每条边和每个点的得分,预测就是从这么多条路径中找到一条边得分+点得分最大的路径,方法就是维特比解码。这个方法相对CRF模型的原理来说简单了不少,可以参考这篇:如何通俗地讲解 viterbi 算法?,如果能看懂这篇里的内容,那么结合我在代码中写的注释,应该可以很容易理解这个过程。