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

前言

本文主要记录学习使用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模型的关键点在于公式中的三个部分: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})

    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,不断往后推导。如果能看懂上面的公式推导,下面的代码结合注释也很容易懂了。

    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 算法?,如果能看懂这篇里的内容,那么结合我在代码中写的注释,应该可以很容易理解这个过程。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值