代码来源于命名实体识别(NER):BiLSTM-CRF原理介绍+Pytorch_Tutorial代码解析
第一部分:导包
1.torch.nn包中主要包含了用来搭建各个层的模块(Modules),比如全连接、二维卷积、池化等;torch.nn包中还包含了一系列有用的loss函数。
2.torch.optim包则主要包含了用来更新参数的优化算法,比如SGD、AdaGrad、RMSProp、 Adam等。
3.import torch.autograd as autograd这是求自动微分的
第二部分
用于设置随机初始化的种子,即上述的编号,编号固定,每次获取的随机数固定。
1.设置标签与BILSTM隐藏层
首先确定标签个数:SBIOE
BILSTM的隐藏特征个数4,单向为2。
START_TAG = ""
STOP_TAG = ""
EMBEDDING_DIM = 5 # 由于标签一共有B\I\O\START\STOP 5个,所以embedding_dim为5
HIDDEN_DIM = 4 # 这其实是BiLSTM的隐藏层的特征数量,因为是双向所以是2倍,单向为2
2.数据集
这里用两个句子来测试,每一个句子对应的词性都有正确的标签。
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()
)]
3.处理数据集中句子的词,不重复的将句子中的词拿出来并标号。
设置一个word_to_ix存储句子中每一个单词。
首先我们拿出每一个句子跟其对应的标签,用sentence与tag循环每一个句子,比如第0次抽出来的就是第一个句子"the wall …money",与他的标签"B I I …0"。
现在我们只存储出现过的单词(不管标签),下面代码解释:
word_to_ix = {}
for sentence, tags in training_data:
for word in sentence:
if word not in word_to_ix: #word_to_ix是否包含左边的内容
word_to_ix[word] = len(word_to_ix)
if word not in word_to_ix:
word_to_ix[word] = len(word_to_ix)
这句就是说word_to ix没有word这句单词时候,就存储进去,并且给每一个新的word赋值,赋的值就是长度。
最终的结果为都在word_to_ix中:
word_to_ix: {‘the’: 0, ‘wall’: 1, ‘street’: 2, ‘journal’: 3, ‘reported’: 4, ‘today’: 5, ‘that’: 6, ‘apple’: 7, ‘corporation’: 8, ‘made’: 9, ‘money’: 10, ‘georgia’: 11, ‘tech’: 12, ‘is’: 13, ‘a’: 14, ‘university’: 15, ‘in’: 16}
4.将5个标签存到tag_to_ix的字典中。
tag_to_ix = {"B": 0, "I": 1, "O": 2, START_TAG: 3, STOP_TAG: 4}
5.几个实用的小函数
def prepare_sequence(seq, to_ix):
idxs = [to_ix[w] for w in seq] # 将句子”w1 w2 w3”(词序列)[w1,w2,w3..]中的词w转化为在词表中的索引值 to_ix [w]
return torch.tensor(idxs, dtype=torch.long)
def argmax(vec):
# 得到最大的值的索引
_, idx = torch.max(vec, 1)
return idx.item()
def log_sum_exp(vec):
max_score = vec[0, argmax(vec)] # max_score的维度为1
max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1]) # 维度为1*5
return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))
6.接下来,我们将我们的句子输入到BILSTM-CRF模型,
model = BiLSTM_CRF(len(word_to_ix), tag_to_ix, EMBEDDING_DIM, HIDDEN_DIM)
第三部分.重点讲解BILSTM-CRF模型
那么我们先看一下BILSTM_CRF模型:
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) # 双向LSTM
self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
# 转移矩阵,transitions[i][j]表示从label_j转移到label_i的概率,虽然是随机生成的但是后面会迭代更新
self.transitions = nn.Parameter(torch.randn(self.tagset_size, self.tagset_size))
self.transitions.data[tag_to_ix[START_TAG], :] = -10000 # 从任何标签转移到START_TAG不可能
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000 # 从STOP_TAG转移到任何标签不可能
self.hidden = self.init_hidden() # 随机初始化LSTM的输入(h_0, c_0)
1.定义模型def_init_():
class BiLSTM_CRF(nn.Module):
def init(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
描述class XX(nn.Module):def _init_的操作,可以看这个链接。
vocab_size就是len(word_to_ix)的长度,也就是所有的词(不重复)。将tag_to_ix也穿过来,embedding_dim就是标签数量,hidden_dim是bilstm层隐藏数量。
super(BiLSTM_CRF, self).init()
和自定义模型一样,这句话就是调用父类的构造函数。
(2)定义一些模型参数,下面解释一点自己不会的
①
self.word_embeds = nn.Embedding(vocab_size, embedding_dim) # 嵌入层
这里是nn.Embedding的理解
简单来说就是把词向量放入网络,词向量的输出应该是什么样子,输出是:(vocab_size,embedding_dim)。
②
self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2, num_layers=1, bidirectional=True)
这是nn.LSTM的理解
输入的是词向量纬度embedding_dim=5,hidden_dim的纬度为4/2=2,循环神经网络的层数为1,bidirecton代表使用双向LSTM。
③
同理,PyTorch的nn.Linear()是用于设置网络中的全连接层的。
④
下面这行代码就是看转移概率的,也就是从一个tag->tag:
transition矩阵是随机生成的,他会进行更新迭代。
self.transitions = nn.Parameter(torch.randn(self.tagset_size, self.tagset_size))
self.transitions.data[tag_to_ix[START_TAG], :] = -10000 # 从任何标签转移到START_TAG不可能
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000 # 从STOP_TAG转移到任何标签不可能
(1)定义def init_hidden()函数:
def init_hidden(self):
return (torch.randn(2, 1, self.hidden_dim // 2),
torch.randn(2, 1, self.hidden_dim // 2))
这应该就是双层lstm隐藏层,因为是双向的,所以生成2个,每一个纬度为(1,self.hidden_dim//2),随机初始化隐藏层的输入,这个函数用self.hidden = self.init_hidden()之后:
(tensor([[[ 1.1456, -0.0378]],[[ 0.9321, 0.5295]]]),
tensor([[[-1.5295, 0.2228]],[[-1.4066, -0.2268]]]))
(2)应该是计算总路径的分数
def _forward_alg(self, feats): #前向算法,feats是LSTM所有时间步的输出
'''
输入:发射矩阵(emission score),实际上就是LSTM的输出——sentence的每个word经BiLSTM后,对应于每个label的得分
输出:所有可能路径得分之和/归一化因子/配分函数/Z(x)
'''
init_alphas = torch.full((1, self.tagset_size), -10000.)#初始化得分值全部为-10000,并置start_tag的值为0,这个思想类似于dijkstra计算最短路径的第一步
init_alphas[0][self.tag_to_ix[START_TAG]] = 0. # start位置的alpha为0
# 包装到一个变量里面以便自动反向传播
forward_var = init_alphas
for feat in feats: # w_i
alphas_t = []
for next_tag in range(self.tagset_size): # tag_j next_tag有target_size个可能的取值
# t时刻tag_i emission score(1个)的广播。需要将其与t-1时刻的5个previous_tags转移到该tag_i的transition scors相加
emit_score = feat[next_tag].view(1, -1).expand(1, self.tagset_size) # 1*5: 遍历标签集 发射分数emission_score[i][j]是词i被标注为词性j的概率 两层for循环里第一层为词序列中的第i个特征(词性),第二层为标签集中的第j种标签(词性)
# t-1时刻的5个previous_tags到该tag_i的transition scors
trans_score = self.transitions[next_tag].view(1, -1) # 维度是1*5 转移分数transitions_socre[m1][m2]是序列中当前词的前一个词被标注为m1下,当前词被标注为m2的概率 . 即由m1转移到下一个状态为m2的概率
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) # 1*5 forward_var size:[1][标签集大小]
# 每个时间步标注为词性j j->[1-len(tags)] 的分数 每个时间步都更新 for循环遍历结束后 forward_var就是遍历词序列末尾时的最后一个分数
# 最后将最后一个单词的forward var与转移 stop tag的概率相加
terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
alpha = log_sum_exp(terminal_var)
return alpha
这个函数,只是根据 随机的transitions ,前向传播算出的一个score,用到了动态规划的思想,但是因为用的是随机的转移矩阵,算出的值很大 score>20。这是计算总路径之和,这篇知乎介绍了方法,看懂这个方法才能读懂代码。BiLSTM-CRF中CRF计算细节
或者看这篇博客的代码解析:pytorch中biLSTM_CRF模型源码学习
或者BILSTM_CRF代码解析
分析代码,:
init_alphas = torch.full((1, self.tagset_size), -10000.)
torch.full就是说,我创建一个(1,self.tagset_size)的纬度的矩阵,每一个都是-10000:
这个意思可能是:初始化得分值全部为-10000,并置start_tag的值为0,这个思想类似于dijkstra计算最短路径的第一步。
for feat in feats:
遍历句子 feats是一个[句长][标签集大小]的矩阵,总时间步==句长,这个循环就是循环11个词。(第一句话有11个词),第一个词的5个标签概率如下图:
tensor([-0.2095, 0.1737, -0.3876, 0.4378, -0.3475]
for next_tag in range(self.tagset_size):
再循环5个标签中的第一个标签,在上面链接的知乎可以知道,我们需要将1个标签概率扩成(1,5)纬度。计算
next_tag_var = forward_var + trans_score + emit_score
纬度如下:
tensor([[-10000., -10000., -10000., 0., -10000.]])
trans_score: tensor([[-1.1811e-01, -1.4420e+00, -1.1108e+00, -1.1187e+00, -1.0000e+04]],grad_fn=)
emit_score: tensor([[-0.2095, -0.2095, -0.2095, -0.2095, -0.2095]],grad_fn=)
alphas_t.append(log_sum_exp(next_tag_var).view(1))
将每一个Pmt(1,5)维(3个1,5维矩阵加起来的之和),经过log_sum_exp变成一个数字,逐步添加到alphas_t,最终aphps是(1,5)维。
将alphas_t是保存Pmt,(m是标签个数,t是第几个词),这个其实就到第t个词的路径分数。m个标签,他当前就有m个路径。
forward_var = torch.cat(alphas_t).view(1, -1)
这时候已经出标签循环了,第t个词所有m个标签概率已经循环结束,f
forward_var size:[1][标签集大小]每个时间步标注为词性j j->[1-len(tags)] 的分数 每个时间步都更新 for循环遍历结束后 forward_var就是遍历词序列末尾时的最后一个分数。
紧接着进入第二个词的循环,不断的用forward_var_size,当一个句子所有的词循环结束,跳出循环。
terminal_var = forward_var +self.transitions[self.tag_to_ix[STOP_TAG]]
alpha = log_sum_exp(terminal_var)
最后加上句子最后一个词转移到STOP_TAG的分数,得到所有可能路径的得分总和。
(3)其中得到lstm层得到feats:
def _get_lstm_features(self, sentence):
self.hidden = self.init_hidden()#初始化状态向量
embeds = self.word_embeds(sentence) #获取句向量:用相应的词向量替换每一个词
embeds = embeds.unsqueeze(1)#在1的位置加上一个维数为1的维度
lstm_out, self.hidden = self.lstm(embeds, self.hidden)#lstm层 输入句向量和状态向量,输出一个输出向量和状态向量
lstm_out = lstm_out.view(len(sentence), self.hidden_dim)#reshape 输出向量扁平化为[句长,隐藏层维度]
lstm_feats = self.hidden2tag(lstm_out) #全连接层 将扁平化的词向量映射到类别的编号
return lstm_feats
可以看出,函数里经过了embedding,lstm,linear层,是根据LSTM算出的一个矩阵。这里是11x5的一个tensor,而这个11x5的tensor,就是发射矩阵!!!发射矩阵!!!发射矩阵!!!(emission matrix)
(4)计算标签序列在当前模型的得分
def _score_sentence(self, feats, tags):
# Gives the score of a provided tag sequence
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]] #递推计算路径分数:转移分数+发射分数
score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]#加上句子最后一个词转移到STOP_TAG的转移分数
return score
根据真实的标签算出的一个score,这与上面的def _forward_alg(self, feats)有什么不同的地方嘛?共同之处在于,两者都是用的随机的转移矩阵算的score,但是不同地方在于,上面那个函数算了一个最大可能路径,但是实际上可能不是真实的 各个标签转移的值。例如说,真实的标签 是 N V V,但是因为transitions是随机的,所以上面的函数得到的其实是N N N这样,两者之间的score就有了差距。而后来的反向传播,就能够更新transitions,使得转移矩阵逼近真实的“转移矩阵”。(个人理解)。
(5)维特比算法
#解码,得到预测的序列,以及预测序列的得分
def _viterbi_decode(self, feats):
backpointers = []
# Initialize the viterbi variables in log space
init_vvars = torch.Tensor(1, self.tagset_size).fill_(-10000.)
init_vvars[0][self.tag_to_ix[START_TAG]] = 0
# forward_var at step i holds the viterbi variables for step i-1
forward_var = autograd.Variable(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):
# next_tag_var[i] holds the viterbi variable for tag i at the
# previous step, plus the score of transitioning
# from tag i to next_tag.
# We don't include the emission scores here because the max
# does not depend on them (we add them in below)
next_tag_var = forward_var + self.transitions[next_tag] #其他标签(B,I,E,Start,End)到标签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))
# Now add in the emission scores, and assign forward_var to the set
# of viterbi variables we just computed
forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1)#从step0到step(i-1)时5个序列中每个序列的最大score
backpointers.append(bptrs_t) #bptrs_t有5个元素
# Transition to STOP_TAG
terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]#其他标签到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路径
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 = best_path.pop()
assert start == self.tag_to_ix[START_TAG] # Sanity check
best_path.reverse()# 把从后向前的路径正过来
return path_score, best_path
维特比解码,实际上就是在预测的时候使用了,输出得分与路径值。
(6)loss函数-非常重要!!!这就是利用了用"真实路径”-总路径的loss,从而迭代更新!应该就是下面这个公式:
def neg_log_likelihood(self, sentence, tags):
feats = self._get_lstm_features(sentence)#11*5 经过了LSTM+Linear矩阵后的输出,之后作为CRF的输入。
forward_score = self._forward_alg(feats) #0维的一个得分,20.*来着
gold_score = self._score_sentence(feats, tags)#tensor([ 4.5836])
return forward_score - gold_score #这是两者之间的差值,后来直接根据这个差值,反向传播
(7)forward函数
def forward(self, sentence):
'''
解码过程,维特比解码选择最大概率的标注路径
'''
lstm_feats = self._get_lstm_features(sentence)
score, tag_seq = self._viterbi_decode(lstm_feats)
return score, tag_seq
def forward(self, sentence):forward函数只是用来预测了,train的时候没用调用它。
第四部分.训练数据集
(1)梯度下降
optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)
for epoch in range(300):
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
loss = model.neg_log_likelihood(sentence_in, targets)
# 反向传播
loss.backward()
optimizer.step()