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

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

[TOC]

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

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

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

一 整合时要解决的问题

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

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

  3. 原代码,即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即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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元素留了位置,代码如下:

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

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

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

1
2
3
4
5
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:

1
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。

1
2
3
4
5
6
7
8
9
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]]
return score

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

1
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元素参与到运算中。

1
2
3
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,其代码如下:

1
2
3
4
5
6
7
8
9
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]]
return score

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

1
2
3
4
5
6
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机制。最终代码如下:

1
2
3
4
5
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的计算

原官网的代码:

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
def _forward_alg(self, feats):
# Do the forward algorithm to compute the partition function
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):
# broadcast the emission score: it is the same regardless of
# the previous tag
emit_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 i
trans_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-exp
next_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}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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循环,加高维度没有必要。最终结果:

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, mask):
"""前向算法
:param feats: [b_s, seq_len, tag_size]
:param mask: [b_s, seq_len]
:return:
"""
# Do the forward algorithm to compute the partition function
init_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 backprop
forward_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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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_size
score, 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

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

结果:

![image-20200719003342138](/Users/wei/Library/Application Support/typora-user-images/image-20200719003342138.png)

这个是加入了预训练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的广播机制,这个是代码可读性的天敌,不好好了解,有时候无法看懂别人的代码。