1200字范文,内容丰富有趣,写作的好帮手!
1200字范文 > 命名实体识别学习-用lstm+crf处理conll03数据集

命名实体识别学习-用lstm+crf处理conll03数据集

时间:2019-08-12 02:50:59

相关推荐

命名实体识别学习-用lstm+crf处理conll03数据集

title: 命名实体识别学习-用lstm+crf处理conll03数据集

date: -07-18 16:32:31

tags:

命名实体识别学习-用lstm+crf处理conll03数据集

文章目录

命名实体识别学习-用lstm+crf处理conll03数据集一 整合时要解决的问题二 mask和padlstm读入涉及转移分矩阵的计算三 将for循环改为矩阵运算gold_score的计算forward_score的计算结果:总结

一直想写的一篇文章,虽然好像也不是很忙,但是一直拖着没做。就是讲下面两篇文章介绍的数据集和算法做一个整合

命名实体识别学习-数据集介绍-conll03

命名实体识别学习-从基础算法开始(02)lstm+crf序列标注

一 整合时要解决的问题

要为数据和模型读入设计合理的数据结构:即vocab-vecterizer-dataset这个pipeline,几乎所有的nlp任务都要走这个pipeline的模式(看别人的源码发现,真正实现这些数据结构时代码五花八门,不过数据结构本来就是ADT的物理实现,只要把核心功能实现就好了。)

原有的算法是一句一句读入的,我实现的时候要用mini-batch, mini-batch已经被证明了其在深度学习应用的作用和功能。不过应用mini-batch要考虑输入句子长短不一的问题,使用pad和mask的技术,尽量避免模型看到pad的元素。本模型主要有三处用到,第一是lstm读入时,然后是crf的算句子得分,和loss计算的时候。在这三处要用mask的方法避免模型读到pad的元素。

原代码,即pytorch官网上放的教程为了使代码便于理解,使用了很多for循环,不利于cuda对代码的加速,尽量将能够变为矩阵运算的for循环变为矩阵的计算。

要解决的三个问题:数据结构,pad和mask,for循环改为矩阵计算。还有一个使代码可以在GPU上运行。(不过我自己最近没法找到卡,所以代码都是凭感觉debug的,不过这次代码已经在学弟卡上跑过了)

二 mask和pad

变长序列的处理是深度学习框架的一大难题,各个框架都有比较成熟的解决问题,

lstm读入

其中pytorch为RNN的读入专门做了处理。所以对于lstm读入时处理就很简单,只需简单调用torch.nn.utils.rnn.pack_padded_sequence()和torch.nn.utils.rnn.pad_packed_sequence即可:

def _get_lstm_features(self, embedded_vec, seq_len):""":param embedded_vec: [max_seq_len, b_s, e_d]:param seq_len: [b_s]:return:"""# 初始化 h0 和 c0,可以缺省 shape:# ([num_layers * num_directions, batch, hidden_size],[num_layers * num_directions, batch, hidden_size])# self.hidden = self.init_hidden(1, seq_len.size(0))pack_seq = pack_padded_sequence(embedded_vec, seq_len)# 不初始化状态,默认初始状态都为0# lstm_out, self.hidden = self.lstm(pack_seq, self.hidden)lstm_out, self.hidden = self.lstm(pack_seq)lstm_out, _ = pad_packed_sequence(lstm_out, batch_first=True) #[b_s, seq_len, h_d]lstm_feats = self.hidden2tag(lstm_out) #[b_s, seq_len, tag_size]lstm_feats = self.dropout(lstm_feats)return lstm_feats

注意:使用这两个自带函数有个问题,并不能恢复百分百恢复原来的输入,他恢复后的句长是输入最长句子的长度,也就是说如果你输入时最长句子也有一定长度的pad元素,那样是没办法恢复的。

涉及转移分矩阵的计算

第二处mask是在转移分的计算,因为self.transitions给pad元素留了位置,代码如下:

self.transition = nn.Parameter(torch.randn(self.tagset_size, self.tagset_size))

这其实不符合我们尽量避免模型看到pad元素的原则(我尝试不在transition里给pad留位置,但是由于 变长序列总会有pad元素,如果没有pad元素的位置,索引就会报错。)这里我使用折中处理,在涉及到转移分矩阵的运算并直接关联结果的都mask掉,(其实存在于矩阵里无所谓,只要最后计算不影响结果即可。

涉及到转移分的计算,主要是loss的计算,在官网文档里:

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

其中,gold_score的计算和forward_score的计算都需要mask机制。

首先是得到mask:

mask = (token_vec != self.token_vocab.lookup_token(self.pad)).to(self.device) # [b_s, max_seq_len]

这个token_vec就是句子向量,mask是一个布尔值向量,其中不等于pad的位置为true,等于pad的位置为false。

def _score_sentence(self, feats, tags):# Gives the score of a provided tag sequencescore = 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]]return score

这个gold_score的代码相对简单:逻辑就是把真实tag对应的转移分和发射分相加,(其实这里的for循环可以去掉换成矩阵运算)因为feats中每个句子(即每个时间步)都参与一次计算,且可能有pad元素,对mask的处理:

total_score = (score * mask.type(torch.float)).sum(dim=1)

forward_score出的mask的处理,官网关于foward_score的计算比较长,就不放了,简述下逻辑,forward_score的计算本质上就是前向算法,前向算法就是DP。(在前面的博客里介绍的比较详细)在每个时间步里求前向变量,而我们用了mini-batch那么一个时间步计算的就不是一个token了。而是一批token,而由于一个mini-batch里句子是不等长的,可能一个句子还没结束,其他句子就已经运算的pad元素了,所以要用mask机制避免pad元素参与到运算中。

for feat_index in range(1, feats.size(1)):n_unfinish = mask[:, feat_index].sum()d_uf = d[:n_unfinish] #[uf, 1, tag_size]

这里是直接算出非pad元素的个数,因为我们的输入是按句长排列的,所以可以直接取前n_finish个进行计算。

三 将for循环改为矩阵运算

原官网代码为了代码可读性,使用的了几处for循环,是可以用矩阵运算代替的,同时代码里的前向算法和维特比解码算法是动态规划算法,可能是不适合改为矩阵运算(不过也不一定,可能有大神实现了)

哪些可以改为for循环?这个问题我也没有查到,我的理解就是看能不能并行,能并行的话大部分都可以用矩阵运算(这是句废话),当然如何分析能不能并行,这个还有待后续查资料继续学习。

gold_score的计算

在计算neg_log_likelihood时需要计算gold_score,其代码如下:

def _score_sentence(self, feats, tags):# Gives the score of a provided tag sequencescore = 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]]return score

但看这段代码,因为score计算依赖上一个时间步的score,乍一看似乎不能无法更改(不能因为依赖上一步的结果就认为是个dp算法,关键看有没有最优子结构),不过稍加分析,其实这个是最容易改为矩阵计算的,证明如下:

score0=transition[tags[1], tags[0]]+feat[tags[1]]score1=score0+transition[tags[2], tags[1]]+feat[tags[2]]...scorei+1=scorei+transition[tags[i+1], tags[i]]+feats[tags[i+1]]score=score0+score1+...+scorei+1=transition[tags[1], tags[0]]+feat[tags[1]]+...+transition[tags[i+1], tag[i]]+feat[tags[i+1]]

从上面的分析,很容易将这段代码改为矩阵计算,同时加上mask机制。最终代码如下:

def _score_sentence(self, feats, tags, mask):score = torch.gather(feats, dim=2, index=tags.unsqueeze(dim=2)).squeeze(dim=2)score[:, 1:] += self.transition[tags[:, :-1], tags[:, 1:]]total_score = (score * mask.type(torch.float)).sum(dim=1)return total_score

forward_score的计算

原官网的代码:

def _forward_alg(self, feats):# Do the forward algorithm to compute the partition functioninit_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 backpropforward_var = init_alphas# Iterate through the sentencefor feat in feats:alphas_t = [] # The forward tensors at this timestepfor next_tag in range(self.tagset_size):# broadcast the emission score: it is the same regardless of# the previous tagemit_score = feat[next_tag].view(1, -1).expand(1, self.tagset_size)# the ith entry of trans_score is the score of transitioning to# next_tag from itrans_score = self.transitions[next_tag].view(1, -1)# The ith entry of next_tag_var is the value for the# edge (i -> next_tag) before we do log-sum-expnext_tag_var = forward_var + trans_score + emit_score# The forward variable for this tag is log-sum-exp of all the# scores.alphas_t.append(log_sum_exp(next_tag_var).view(1))forward_var = torch.cat(alphas_t).view(1, -1)terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]alpha = log_sum_exp(terminal_var)return alpha

下面简单分析下,假设tag_size=2,值集合为:{0,1}

emit_score=feat[0].expand(1, self.tagset_size) #经过扩展emit_score变为shape为[1,2]的矩阵,因为pytorch的广播机制,其实就是[feat[0], feat[0]]trans_score = self.transitions[0].view(1, -1)# 这个本身就是shape为[1,2]的矩阵,表示tag分别从0,1转为0的转移分。next_tag_var0= forward_var + trans_score + emit_score=forward_var +[feat[0], feat[0]]+self.transitions[0]# 下一个迭代emit_score=feat[1].expand(1, self.tagset_size) #经过扩展emit_score变为shape为[1,2]的矩阵,因为pytorch的广播机制,其实就是[feat[0], feat[0]]trans_score = self.transitions[1].view(1, -1)# 这个本身就是shape为[1,2]的矩阵,表示tag分别从0,1转为0的转移分。next_tag_var1= forward_var + trans_score + emit_score=forward_var +[feat[1], feat[1]]+self.transitions[1]从这个分析很明显可以看出可以转为矩阵计算,大致思路为:next_tag_var = forward_var+feats.unsqueeze(dim=1)+self.transitions其中:feat.unsqueeze(dim=1)+self.transitions # shape为[tag_size, tag_size]forward_var的shape为[1,tag_size], 这里的矩阵计算,会通过广播机制,自动复制其自身的值。

加上batch所处的维度,刚好是三维张量的计算,是一个比较习惯处理的维度,再加去掉for循环,加高维度没有必要。最终结果:

def _forward_alg(self, feats, mask):"""前向算法:param feats: [b_s, seq_len, tag_size]:param mask: [b_s, seq_len]:return:"""# Do the forward algorithm to compute the partition functioninit_alphas = torch.full((feats.size(0), self.tagset_size), -10000., device=self.device) #[b_s, tag_size]# START_TAG has all of the score.along dim=1,init_alphas[:, self.begin_tag_idx]=0.# Wrap in a variable so that we will get automatic backpropforward_var_list=[]forward_var_list.append(init_alphas)d = torch.unsqueeze(feats[:,0], dim=1) #[b_s, 1, tag_size]for feat_index in range(1, feats.size(1)):n_unfinish = mask[:, feat_index].sum()d_uf = d[:n_unfinish] #[uf, 1, tag_size]emit_and_transition = feats[: n_unfinish, feat_index].unsqueeze(dim=1)+self.transition #[uf,tag_size,tag_size]log_sum = d_uf.transpose(1, 2)+emit_and_transition #[uf, tag_size, tag_size]max_v = log_sum.max(dim=1)[0].unsqueeze(dim=1) #[uf, 1, tag_size]log_sum = log_sum - max_v #[uf, tag_size, tag_size]d_uf = max_v + torch.logsumexp(log_sum, dim=1).unsqueeze(dim=1) # [uf, 1, tag_size]d = torch.cat((d_uf, d[n_unfinish:]), dim=0)d = d.squeeze(dim=1) #[b_s, tag_size]max_d = d.max(dim=-1)[0] # [b_s]d = max_d + torch.logsumexp(d - max_d.unsqueeze(dim=1), dim=1) # [b_s]return d

维特比解码算法的修改思路和前向算法大致一致,修改后的代码如下:

def _viterbi_decode(self, feats, mask, seq_len):batch_size = feats.size(0)tags = [[[i] for i in range(len(self.tag_vocab))]] * batch_size # list, shape: (b, K, 1)d = torch.unsqueeze(feats[:, 0], dim=1) # shape: (b, 1, K)for i in range(1, seq_len[0]):n_unfinished = mask[:, i].sum()d_uf = d[: n_unfinished] # shape: (uf, 1, K)emit_and_transition = self.transition + feats[: n_unfinished, i].unsqueeze(dim=1) # shape: (uf, K, K)new_d_uf = d_uf.transpose(1, 2) + emit_and_transition # shape: (uf, K, K)d_uf, max_idx = torch.max(new_d_uf, dim=1)max_idx = max_idx.tolist() # list, shape: (nf, K)tags[: n_unfinished] = [[tags[b][k] + [j] for j, k in enumerate(max_idx[b])] for b in range(n_unfinished)]d = torch.cat((torch.unsqueeze(d_uf, dim=1), d[n_unfinished:]), dim=0) # shape: (b, 1, K)d = d.squeeze(dim=1) # [b_s, tag_sizescore, max_idx = torch.max(d, dim=1) # shape: (b,)max_idx = max_idx.tolist()tags = [tags[b][k] for b, k in enumerate(max_idx)]return score, tags

整个工程代码地址:/SStarLib/NERfromBasic/tree/master/Day03minibatch-lstm%2Bcrfs/conll03Ner

结果:

这个是加入了预训练embedding的结果,比不加稍微好一点。不过好的有限。甚至有的地方还要更差。

总结

整个工程代码花费了接近一天的时间,写代码的时候出现了各种奇奇怪怪的bug,有的是变量命名差个s,结果写的时候没有注意出错,好不容易调通了,结果发现loss一直不变化,我还以为是我的代码实现的有问题,结果把代码改的面目全非,甚至很多地方已经背离了我最初的思路了,几乎是把整个工程推翻重写,最后debug时,发现lstm生成的feats几乎不变化,才开始意识到模型根本没起作用,最终发现是因为没有对模型参数初始化的原因,(真的是太久不写pytorch的代码了,连模型参数初始化都先不起来)虽然最后整个工程项目成功了,但是花费了大量时间,真的,写出bug free的工程代码是一个人素质的体现!

关于加入了预训练embedding的结果不显著的问题,可能是glove,只有小写字母,而字母的大小写本身就是实体的一个重要特征,使用了glove反而可能丢失了这个重要特征,解决办法是加入char embedding ,下次可以在模型中加入,或者一步到位,使用Elmo的embedding,

尽量要使用 mini batch

for循环改为矩阵计算,提高并行性,提升运算速度很重要,一般常用的都是三维张量,包含一维batch,所以尽量改成三维张量的运算为好。

pad和mask机制的使用,有空可以专门写一篇文章,介绍这个,并总结一下。

pipeline的数据结构,有空了再介绍吧,不难,主要是要明确要实现的功能,并抽象出来,然后用代码实现即可。

一直想要系统性的学一下pytorch,可是到现在连本相关的书都没买过,(甚至30分钟的官网视频也没看),甚至不知道pytorch能做啥不能做啥。所以我的coding过程非常痛苦,使用google的频率非常高。pytorch的api,几乎要不停的查。磨刀不误砍柴工,真的应该系统性的学习下这个框架,然后再想着复现论文。

比赛的时候一定不会用pytorch,相比keras的搭积木似的coding过程,pytorch的开发周期确实有点长了,甚至要一直照顾shape,所以我在我的代码里要一直备注shape的变化,和广播机制将要怎么发生。

要好好了解下pytorch的广播机制,这个是代码可读性的天敌,不好好了解,有时候无法看懂别人的代码。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。