本文旨在通过pytorch源码理解CRF在NER中的实现,由于是源码的程序,更多讲的是公式的实现而不是具体的应用。
一、为什么要用CRF?
首先,句中的每个单词是一条包含词嵌入和字嵌入的词向量,词嵌入通常是事先训练好的,字嵌入则是随机初始化的。所有的嵌入都会随着训练的迭代过程被调整。其次,BiLSTM-CRF的输入是词嵌入向量,输出是每个单词对应的预测标签。即使没有CRF层,我们照样可以训练一个基于BiLSTM的命名实体识别模型。但是CRF层可以加入一些约束来保证最终预测结果是有效的。这些约束可以在训练数据时被CRF层自动学习得到。
可能的约束条件有:
- 句子的开头应该是“B-”或“O”,而不是“I-”。
- “B-label1 I-label2 I-label3…”,在该模式中,类别1,2,3应该是同一种实体类别。比如,“B-Person I-Person” 是正确的,而“B-Person I-Organization”则是错误的。
-
“O I-label”是错误的,命名实体的开头应该是“B-”而不是“I-”。
有了这些有用的约束,错误的预测序列将会大大减少。
二、CRF如何融合BiLSTM中
BiLSTM 模型(加上一个线性层)的输出维度是tagset_size,这就相当于是每个词 映射到的发射分数,设BiLSTM的输出矩阵为,其中代表词 映射到的非归一化分数。对于CRF来说,我们假定存在一个转移矩阵,则代表转移到的转移分数。
对于输入序列 对应的输出序列,定义分数为:
利用Softmax函数,我们为每一个正确的序列定义一个概率值(代表所有的序列,包括不可能出现的)因而在训练中,我们只需要最大化似然概率即可,这里我们利用对数似然所以我们将损失函数定义为,就可以利用梯度下降法来进行网络的学习了。
损失函数主要包括两部分(1)真实路径的得分和所有可能路径的概率值。
三、CRF源码程序实现
程序的主体结构其实就是一个BiLSTM层接上一个Linear层self.hidden2tag,并且需要定义一个转移矩阵self.transitions,这里需要注意self.transitions的含义(self.transitions[i]表示所有可能的标签值到第i个标签的转移分数)
class BiLSTM_CRF(nn.Module):
def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
super(BiLSTM_CRF, self).__init__()
self.vocab_size = vocab_size
self.tag_to_ix = tag_to_ix # 包括了表示开始开始和结束的标签
self.embedding_dim = embedding_dim
self.hidden_dim = hidden_dim
self.tagset_size = len(tag_to_ix)
self.word_embeds = nn.Embedding(self.vocab_size, self.embedding_dim)
self.BiLSTM = nn.LSTM(embedding_dim, hidden_size=hidden_dim//2,
bidirectional=True, num_layers=1)
# hidden_size除以2是为了使BiLSTM的输出维度依然是hidden_size,而不用乘以2
# 通过将BiLSTM的输出接上nn.Linear得到发射分数hidden2tag: [seq, 1, tagset_size]
# batch_size在整个程序中是1
self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
# CRF层学习的就是一个转移分数transitions: [tagset_size, tagset_size]
# transitions[i]表示的是**从j=1,2,...,tagset_size**转移到第i个标签的分数
# 而不能理解为**从第i个标签注意到j=1,2,...,tagset_size**的分数
self.transitions = nn.Parameter(torch.randn([self.tagset_size, self.tagset_size]))
# 用表示开始和结束的特殊字符找到需要识别的句子的开始和结束,用一个负无穷的数约束,这样exp
# 一个负无穷的数,其得分就是0
self.transitions.data[tag_to_ix[START_TAG], :] = -10000
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
# 初始化BiLSTM的隐层单元,可以不在这里初始化,因为forward函数内又写了一句
self.hidden = self.init_hidden()
def init_hidden(self):
# 隐层单元的维度为:
# [num_layers * num_directions, batch, hidden_size]
return (torch.randn([2, 1, self.hidden_dim//2]),
torch.randn([2, 1, self.hidden_dim//2]))
通过二中的分析可以知道,实现CRF的重点就是实现(1)真实路径的得分和所有可能路径的概率值。首先实现中的发射分数。发射分数其实就是BiLSTM的输出接上一个线性层,线性层的维度就是tagset_size。
# 得到发射分数
def _get_lstm_features(self, sentence):
self.hidden = self.init_hidden()
embeds = self.word_embeds(sentence).view([len(sentence), 1, -1])
BiLSTM_out, self.hidden = self.BiLSTM(embeds, self.hidden)
# BiLSTM_out的输出本来是[seq_len, batch_size, hidden_dim]
# 由于batch_size为1,view成了二维的
BiLSTM_out = BiLSTM_out.view([len(sentence), self.hidden_dim])
BiLSTM_feats = self.hidden2tag(BiLSTM_out)
return BiLSTM_feats
其次,实现较为简单的,细节的地方直接看程序就行,程序中都注释的很清楚了。
# 得到正确路径的分数,即公式中的S(X, y)
# 只需要:
# (1)依次得到标签tags对应的转移分数
# (2)加上feats对应的发射分数就行了
def _score_sentence(self, feats, tags):
# feats: [seq_len, tagset_size]
# tags: [tagset_size]
score = torch.zeros([1])
# 由于tags里不含表示开始和结束的特殊字符,而转移分数的矩阵内是有的
# 因此首先在tags添加了表示开始的特殊字符
tags = torch.cat([torch.tensor([self.tag_to_ix[START_TAG]],
dtype=torch.long), tags])
for i, feat in enumerate(feats):
# transitions[tags[i+1], tags[i]]表示第i个标签转移到第i+1个标签的转移分数
score = score + self.transitions[tags[i+1], tags[i]] + feat[tags[i+1]]
score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
return score
接着实现复杂的。这里采用动态规划的思想,强烈推荐知乎和CSDN上写的,程序中的相关注释请看程序中写的。
# 得到所有路径的分数,即公式中的$\log\sum_{\tilde{y}\in Y_X}e^{S(X, \tilde{y})}$
# 这里采用类似动态规划的做法,因为要求解出所有可能路径的分数再求和时间复杂度太高了
# 可以依次从前往后计算出每一条路径上的分数,当计算下一条路径时,直接加上前一次计算得到的路径分数就行
# 因此在前向计算的过程中需要保存前一次计算的路径得分,程序中用forward_var表示的,维度为:[1 tagset_size]
def _forward_alg(self, feats):
# feats: [seq_len, tagset_size]
# 初始化forward_var, 并且开始位置的分数为0, 迫使转移矩阵学到START_TAG的得分最高
forward_var = torch.full([1, self.tagset_size], -10000.)
forward_var[0][self.tag_to_ix[START_TAG]] = 0.
# 前向过程计算分数的前向是针对seq_len而言的,每一次存储都是在每一次seq_len的结束存储的
for feat in feats:
# forward_var_t表示每一次前向过程中的分数
# forward_var_t与forward_var不同,forward_var_t每一次前向过程中需要更新,但是
# forward_var是累加的
forward_var_t = []
# 这个for循环计算的是在t时刻前向计算过程中,所有标签到某个具体标签的得分
# x0 | x1 x2 x3
# ------>---->----->----->------->
# START | START START START
# y1 | y1 y1 y1
# y2 | y2 y2 y2
# y3 | y3 y3 y3
# STOP | STOP STOP STOP
# 假设feats = [[x1, x2, x3]]
# 可能的标签为{START, y1, y2, y3, STOP}
# 假如此时feat = x1
# 则此时下面的for循环需要依次计算:
# (1) {START, y1, y2, y3, STOP}到START的总分数作为forward的**第0个元素**
# (2) {START, y1, y2, y3, STOP}到y1的总分数作为forward的*****第1个元素**
# ............................................................
# (5) {START, y1, y2, y3, STOP}到STOP的总分数作为forward的***第4个元素**
# ======================== 细节: 如何采用动态规划思想 ================
# 对于计算(2) **{START, y1, y2, y3, STOP}到y1的总分数作为forward的第1个元素** 时
# 需要分别加上forward在前一时刻(t-1时刻)的得分,举例: 计算START到y1的分数S(START, y1):
# S(START, y1) = forward[0] + E(x1, y1) + T(START, y1)
# 其中forward[0]表示t-1时刻所有到达START所有路径的得分
# E(x1, y1)与T(START, y1)分别表示发射分数和转移分数
for next_tag in range(self.tagset_size):
# 复制emit_score的目的是因为对于t-1时刻无论何种方式到达标签next_tag,其对应的发射分数不会变
# 变的是转移分数
emit_score = feat[next_tag].view([1, -1]).expand([1, self.tagset_size])
trans_score = self.transitions[next_tag].view([1, -1])
# ===================== 这里计算的就是前面说的细节处的计算=======================
next_tag_var = forward_var + trans_score + emit_score
forward_var_t.append(log_sum_exp(next_tag_var).view([1]))
forward_var = torch.cat(forward_var_t).view([1, -1])
# 计算最后到达STOP_END的得分,此时只有转移分数
forward_var += self.transitions[self.tag_to_ix[STOP_TAG]]
forward_var = log_sum_exp(forward_var)
return forward_var
最后实现的就是损失函数,损失函数的实现其实就是将前两步中的公式相减。
# 计算CRF的损失函数
# $ Loss = -(S(X, y) - \log\sum_{\tilde{y}\in Y_X}e^{S(X, \tilde{y})} $
# = self._forward_alg(feats) - self._score_sentence(feats, tags)
def neg_log_likelihood(self, sentence, tags):
feats = self._get_lstm_features(sentence)
forward_score = self._forward_alg(feats)
gold_score = self._score_sentence(feats, tags)
return forward_score - gold_score
四:采用维特比算法解码
# viterbi解码时,也是运用了动态规划的思想,其实和self._forward_alg类似,
def _viterbi_decode(self, feats):
# 初始化forward_var,并且开始位置的分数为0,确保一定是从START_TAG开始的,
# 因为 $e^{-10000}<---->----->----->------->
# START | START START START
# y1 | y1 y1 y1
# y2 | y2 y2 y2
# y3 | y3 y3 y3
# STOP | STOP STOP STOP
# 当feat=x2时,假如在t-1时刻START到{START, y1, y2, y3, STOP}的路径得分最大
# 此时,需要求t时刻达到{START, y1, y2, y3, STOP}的路径得分
# 由于此时发射分数都是一样的,因此只要比较转移分数就行
# 举例:计算{START, y1, y2, y3, STOP}到START的最大路径
# 计算max(T(START, START)+forward_var[0], T(y1, START)+forward_var[1], T(y2, START), ...)
# 假设T(y1, START)+forward_var[1]最大,此时y1对应的索引(也就是1)被记录在backpointers_t中,
# 值T(y1, START)+forward_var[1]+E(x2, y1)被记录在forward_var_t中
for next_tag in range(self.tagset_size):
next_tag_var = forward_var + self.transitions[next_tag]
best_tag_id = argmax(next_tag_var)
backpointers_t.append(best_tag_id)
forward_var_t.append(next_tag_var[0][best_tag_id].view([1]))
# 更新forward_var
forward_var = (torch.cat(forward_var_t) + feat).view([1, -1])
# 添加backpointers
backpointers.append(backpointers_t)
# 计算到STOP_TAG的最优路径,其得分也就是最优路径的得分
forward_var += self.transitions[self.tag_to_ix[STOP_TAG]]
best_tag_id = argmax(forward_var)
path_score = forward_var[0][best_tag_id]
# 通过backpointers逆序找到最佳路径
best_path = [best_tag_id]
for backpointers_t in reversed(backpointers):
best_tag_id = backpointers_t[best_tag_id]
best_path.append(best_tag_id)
# 弹出START_TAG
start = best_path.pop()
assert self.tag_to_ix[START_TAG] == start
best_path.reverse()
return path_score, best_path
五、所有程序的实现和注释
# -*- coding: utf-8 -*-
import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(1)
# ========================= 相关函数 ==========================
def argmax(vec):
# 得到最大值的标签
# vec: [1, tagset_size]
_, idx = torch.max(vec, 1)
return idx.item()
def prepare_sequence(seq, to_ix):
# 将一句话中的字转换为索引
# seq: [vocab_size]
# to_ix: dict
idxs = [to_ix[word] for word in seq]
return torch.tensor(idxs, dtype=torch.long)
def log_sum_exp(vec):
# 计算$\log\sum{e^{vec_i}}$
# vec: [1, tagset_size]
# 为防止指数运算溢出,先将vec中元素减去最大值,最后在结果中加上最大值
max_score = vec[0, argmax(vec)]
# max_score的维度可以不扩展,直接利用广播机制也行
max_score_broadcast = max_score.view([1, -1]).expand([1, vec.size()[1]])
# 最后return返回的是一个一维的tensor
return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))
# ====================== BILSTM实现 ==================================
class BiLSTM_CRF(nn.Module):
def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
super(BiLSTM_CRF, self).__init__()
self.vocab_size = vocab_size
self.tag_to_ix = tag_to_ix # 包括了表示开始开始和结束的标签
self.embedding_dim = embedding_dim
self.hidden_dim = hidden_dim
self.tagset_size = len(tag_to_ix)
self.word_embeds = nn.Embedding(self.vocab_size, self.embedding_dim)
self.BiLSTM = nn.LSTM(embedding_dim, hidden_size=hidden_dim//2,
bidirectional=True, num_layers=1)
# hidden_size除以2是为了使BiLSTM的输出维度依然是hidden_size,而不用乘以2
# 通过将BiLSTM的输出接上nn.Linear得到发射分数hidden2tag: [seq, 1, tagset_size]
# batch_size在整个程序中的维度是1
self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
# CRF层学习的就是一个转移分数transitions: [tagset_size, tagset_size]
# transitions[i]表示的是**从j=1,2,...,tagset_size**转移到第i个标签的分数
# 而不能理解为**从第i个标签注意到j=1,2,...,tagset_size**的分数
self.transitions = nn.Parameter(torch.randn([self.tagset_size, self.tagset_size]))
# 用表示开始和结束的特殊字符找到需要识别的句子的开始和结束,用一个负无穷的数约束,这样exp
# 一个负无穷的数,其得分就是0
self.transitions.data[tag_to_ix[START_TAG], :] = -10000
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
# 初始化BiLSTM的隐层单元,可以不在这里初始化,因为forward函数内又写了一句
self.hidden = self.init_hidden()
def init_hidden(self):
# 隐层单元的维度为:
# [num_layers * num_directions, batch, hidden_size]
return (torch.randn([2, 1, self.hidden_dim//2]),
torch.randn([2, 1, self.hidden_dim//2]))
# 得到发射分数
def _get_lstm_features(self, sentence):
self.hidden = self.init_hidden()
embeds = self.word_embeds(sentence).view([len(sentence), 1, -1])
BiLSTM_out, self.hidden = self.BiLSTM(embeds, self.hidden)
# BiLSTM_out的输出本来是[seq_len, batch_size, hidden_dim]
# 由于batch_size为1,view成了二维的
BiLSTM_out = BiLSTM_out.view([len(sentence), self.hidden_dim])
BiLSTM_feats = self.hidden2tag(BiLSTM_out)
return BiLSTM_feats
# 得到正确路径的分数,即公式中的S(X, y)
# 只需要:
# (1)依次得到标签tags对应的转移分数
# (2)加上feats对应的发射分数就行了
def _score_sentence(self, feats, tags):
# feats: [seq_len, tagset_size]
# tags: [tagset_size]
score = torch.zeros([1])
# 由于tags里不含表示开始和结束的特殊字符,而转移分数的矩阵内是有的
# 因此首先在tags添加了表示开始的特殊字符
tags = torch.cat([torch.tensor([self.tag_to_ix[START_TAG]],
dtype=torch.long), tags])
for i, feat in enumerate(feats):
# transitions[tags[i+1], tags[i]]表示第i个标签转移到第i+1个标签的转移分数
score = score + self.transitions[tags[i+1], tags[i]] + feat[tags[i+1]]
score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
return score
# 得到所有路径的分数,即公式中的$\log\sum_{\tilde{y}\in Y_X}e^{S(X, \tilde{y})}$
# 这里采用类似动态规划的做法,因为要求解出所有可能路径的分数再求和时间复杂度太高了
# 可以依次从前往后计算出每一条路径上的分数,当计算下一条路径时,直接加上前一次计算得到的路径分数就行
# 因此在前向计算的过程中需要保存前一次计算的路径得分,程序中用forward_var表示的,维度为:[1 tagset_size]
def _forward_alg(self, feats):
# feats: [seq_len, tagset_size]
# 初始化forward_var, 并且开始位置的分数为0, 迫使转移矩阵学到START_TAG的得分最高
forward_var = torch.full([1, self.tagset_size], -10000.)
forward_var[0][self.tag_to_ix[START_TAG]] = 0.
# 前向过程计算分数的前向是针对seq_len而言的,每一次存储都是在每一次seq_len的结束存储的
for feat in feats:
# forward_var_t表示每一次前向过程中的分数
# forward_var_t与forward_var不同,forward_var_t每一次前向过程中需要更新,但是
# forward_var是累加的
forward_var_t = []
# 这个for循环计算的是在t时刻前向计算过程中,所有标签到某个具体标签的得分
# x0 | x1 x2 x3
# ------>---->----->----->------->
# START | START START START
# y1 | y1 y1 y1
# y2 | y2 y2 y2
# y3 | y3 y3 y3
# STOP | STOP STOP STOP
# 假设feats = [[x1, x2, x3]]
# 可能的标签为{START, y1, y2, y3, STOP}
# 假如此时feat = x1
# 则此时下面的for循环需要依次计算:
# (1) {START, y1, y2, y3, STOP}到START的总分数作为forward的**第0个元素**
# (2) {START, y1, y2, y3, STOP}到y1的总分数作为forward的*****第1个元素**
# ............................................................
# (5) {START, y1, y2, y3, STOP}到STOP的总分数作为forward的***第4个元素**
# ======================== 细节: 如何采用动态规划思想 ================
# 对于计算(2) **{START, y1, y2, y3, STOP}到y1的总分数作为forward的第1个元素** 时
# 需要分别加上forward在前一时刻(t-1时刻)的得分,举例: 计算START到y1的分数S(START, y1):
# S(START, y1) = forward[0] + E(x1, y1) + T(START, y1)
# 其中forward[0]表示t-1时刻所有到达START所有路径的得分
# E(x1, y1)与T(START, y1)分别表示发射分数和转移分数
for next_tag in range(self.tagset_size):
# 复制emit_score的目的是因为对于t-1时刻无论何种方式到达标签next_tag,其对应的发射分数不会变
# 变的是转移分数
emit_score = feat[next_tag].view([1, -1]).expand([1, self.tagset_size])
trans_score = self.transitions[next_tag].view([1, -1])
# ===================== 这里计算的就是前面说的细节处的计算=======================
next_tag_var = forward_var + trans_score + emit_score
forward_var_t.append(log_sum_exp(next_tag_var).view([1]))
forward_var = torch.cat(forward_var_t).view([1, -1])
# 计算最后到达STOP_END的得分,此时只有转移分数
forward_var += self.transitions[self.tag_to_ix[STOP_TAG]]
forward_var = log_sum_exp(forward_var)
return forward_var
# 计算CRF的损失函数
# $ Loss = -(S(X, y) - \log\sum_{\tilde{y}\in Y_X}e^{S(X, \tilde{y})} $
# = self._forward_alg(feats) - self._score_sentence(feats, tags)
def neg_log_likelihood(self, sentence, tags):
feats = self._get_lstm_features(sentence)
forward_score = self._forward_alg(feats)
gold_score = self._score_sentence(feats, tags)
return forward_score - gold_score
# viterbi解码时,也是运用了动态规划的思想,其实和self._forward_alg类似,
def _viterbi_decode(self, feats):
# 初始化forward_var,并且开始位置的分数为0,确保一定是从START_TAG开始的,
# 因为 $e^{-10000}<---->----->----->------->
# START | START START START
# y1 | y1 y1 y1
# y2 | y2 y2 y2
# y3 | y3 y3 y3
# STOP | STOP STOP STOP
# 当feat=x2时,假如在t-1时刻START到{START, y1, y2, y3, STOP}的路径得分最大
# 此时,需要求t时刻达到{START, y1, y2, y3, STOP}的路径得分
# 由于此时发射分数都是一样的,因此只要比较转移分数就行
# 举例:计算{START, y1, y2, y3, STOP}到START的最大路径
# 计算max(T(START, START)+forward_var[0], T(y1, START)+forward_var[1], T(y2, START), ...)
# 假设T(y1, START)+forward_var[1]最大,此时y1对应的索引(也就是1)被记录在backpointers_t中,
# 值T(y1, START)+forward_var[1]+E(x2, y1)被记录在forward_var_t中
for next_tag in range(self.tagset_size):
next_tag_var = forward_var + self.transitions[next_tag]
best_tag_id = argmax(next_tag_var)
backpointers_t.append(best_tag_id)
forward_var_t.append(next_tag_var[0][best_tag_id].view([1]))
# 更新forward_var
forward_var = (torch.cat(forward_var_t) + feat).view([1, -1])
# 添加backpointers
backpointers.append(backpointers_t)
# 计算到STOP_TAG的最优路径,其得分也就是最优路径的得分
forward_var += self.transitions[self.tag_to_ix[STOP_TAG]]
best_tag_id = argmax(forward_var)
path_score = forward_var[0][best_tag_id]
# 通过backpointers逆序找到最佳路径
best_path = [best_tag_id]
for backpointers_t in reversed(backpointers):
best_tag_id = backpointers_t[best_tag_id]
best_path.append(best_tag_id)
# 弹出START_TAG
start = best_path.pop()
assert self.tag_to_ix[START_TAG] == start
best_path.reverse()
return path_score, best_path
def forward(self, sentence):
# 得到发射分数
BiLSTM_feats = self._get_lstm_features(sentence)
# 通过viterbi找出最佳路径
score, best_path = self._viterbi_decode(BiLSTM_feats)
return score, best_path
if __name__ == '__main__':
START_TAG = ""
STOP_TAG = ""
EMBEDDING_DIM = 5
HIDDEN_DIM = 4
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_to_ix.get(word) is None:
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(vocab_size=len(word_to_ix),
tag_to_ix=tag_to_ix,
embedding_dim=EMBEDDING_DIM,
hidden_dim=HIDDEN_DIM, )
optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)
# 仅仅为了print
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))
# 开始训练
for epoch in range(10):
# 注意: 这里的batch_size为1
for sentence, tags in training_data:
model.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.backward()
optimizer.step()
# 预测
with torch.no_grad():
precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
print(model(precheck_sent))