0%

NLP|NER-LatticeLSTM模型

近期会更新一系列NER的paper解读,计划2周时间将NER的重要的论文刷完,有一个想做的事情嘻嘻😁。这篇博客主要讲解一下《Chinese NER Using Lattice LSTM》论文,即:LatticeLSTM模型。

LatticeLSTM模型来源于2018年ACL上的《Chinese NER Using Lattice LSTM》论文。非常经典,而且它release的code非常的规范,很值得一读~

LatticeLSTM模型提出的背景

目前在NER中,主要分为两大类:基于char level的model与基于word level的model。对于目前的baseline—LSTM+CRF,很多实验中都证明了:对于中文,基于word level的model要比基于char level的model要好。但是两者也都有各自的优劣势。char level model最大的缺点在于:无法很好的利用字与字之间的有效信息;word level model最大的缺点在于:在中文中,一般都需要分词,分词的准确率直接影响到最终的NER结果,所以不好的分词会带来不好的NER结果,而目前word level model还无法很好地处理这个问题。基于这个背景,一个很自然地想法就是:如果能将词汇的信息融入到char level model中,那么就能够提高模型的效果,这就是LatticeLSTM模型。

注意:在中英文中,char与word的含义是不一样的。在中文中:char指的是单个字,word指的是词语。

LatticeLSTM模型介绍

个人认为这篇文章数学符号较多,而且整体逻辑并不是很清晰,所以不是很好理解(虽然公式倒是挺简单的),先放模型图吧~

  • 模型输入:假设输入的序列为$\{c_1,c_2,c_3,…,c_n\}$,其中$c_i$表示第$i$个字,那么整个模型的输入有两部分:$\{c_1,c_2,c_3,…,c_n\}$以及与词典$\cal D$相匹配的所有词语。

  • embedding+LSTM:这一部分就是将词汇信息融入到char level model中。有三大部分:字本身的char embedding及LSTM部分、以当前字为结尾的相关的词语的embedding信息以及LSTM部分、两者的融合。

    注意,在论文里面,演示的是单向的LSTM,但是如果去看代码的话,可以看到也可以使用双向的LSTM,只要把forward和backward的结果拼接在一起,然后输送到CRF中即可。

    • 字$c_j$的char embedding较为简单,具体表示如下:
    • 对于以当前字为结尾的相关的词语的embedding信息的计算,相对比较复杂一些,但是总体思想还是简单的。以为例,与它相关的词语信息有:长江大桥大桥。具体计算公式如下:

      这个其实和普通的LSTM很像,只不过不更新输出。其中,$x_{b,e}^{w}$表示是index从b到e的词语的embedding,公式是:$x_{b,e}^{w}=e^w(w_{b,e}^{d})$,$h_{b}^{c}$表示的是词语首个字的LSTM的输出的hidden state,$c_{b}^{c}$表示的是词语首个字的LSTM的输出的cell state,通过这一步,得到的就是模型图中的红色部分的结果,也就是词汇的cell值:$c_{b,e}^{w}$。

    • 字本身的信息与词汇信息进行融合。以为例,有三部分的信息:长江大桥大桥。融合的方式其实就是softmax。具体如下:

    这一步我们最终得到的是$c_{j}^{c}$,那么最终的输入到CRF中的是$h_{j}^{c}$,其计算公式与LSTM是一样的,为:$h_{j}^{c}=o_{j}^{c}*tanh(c_j^c)$。后面的就是CRF模型了,比较简单,不再赘述。需要注意的是,最后的loss,加了L2正则防止过拟合。

实验结果

  • 数据集:MSRA、OntoNotes、Weibo、resume(released by this paper)

  • 评价指标:P/R/F1(exact match)
  • 使用了pretrained word embedding,在大规模的无监督的语料中使用word2vec算法,得到字向量与词向量,并在训练过程中fine-tune。

  • 参数设置如下:

  • 结果:

从实验结果来看,LatticeLSTM在四个数据集上都达到了SOTA,并且相比于base(LSTM+CRF)来说,提升效果是非常显著的。

  • 有意思的结论

paper最后,还做了一些有意思的实验,总结起来有如下结论:

  1. 随着序列长度越来越长,NER的结果F1值也是逐渐下跌的,但是LatticeLSTM下跌的速度要慢一些,所以更加鲁棒。
  2. lexicon的词语会影响到最终的结果,所以制作lexicon要更加小心谨慎一些,确保得到尽量准确的词语。

核心代码走读

原始代码挺清晰的,读起来应该不难,最核心的代码是latticelstm.py,后续的bilstm.py以及bilstmcrf.py都有调用latticelstm.py中的latticelstm部分。所以,在这里我就放一下latticelstm的代码部分吧,要是感兴趣的话,直接去读完整的原始代码就可以了~code link

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
class MultiInputLSTMCell(nn.Module):

"""A basic LSTM cell."""

def __init__(self, input_size, hidden_size, use_bias=True):
"""
Most parts are copied from torch.nn.LSTMCell.
"""

super(MultiInputLSTMCell, self).__init__()
self.input_size = input_size
self.hidden_size = hidden_size
self.use_bias = use_bias
self.weight_ih = nn.Parameter(
torch.FloatTensor(input_size, 3 * hidden_size))
self.weight_hh = nn.Parameter(
torch.FloatTensor(hidden_size, 3 * hidden_size))
self.alpha_weight_ih = nn.Parameter(
torch.FloatTensor(input_size, hidden_size))
self.alpha_weight_hh = nn.Parameter(
torch.FloatTensor(hidden_size, hidden_size))
if use_bias:
self.bias = nn.Parameter(torch.FloatTensor(3 * hidden_size))
self.alpha_bias = nn.Parameter(torch.FloatTensor(hidden_size))
else:
self.register_parameter('bias', None)
self.register_parameter('alpha_bias', None)
self.reset_parameters()

def reset_parameters(self):
"""
Initialize parameters following the way proposed in the paper.
"""
init.orthogonal(self.weight_ih.data)
init.orthogonal(self.alpha_weight_ih.data)

weight_hh_data = torch.eye(self.hidden_size)
weight_hh_data = weight_hh_data.repeat(1, 3)
self.weight_hh.data.set_(weight_hh_data)

alpha_weight_hh_data = torch.eye(self.hidden_size)
alpha_weight_hh_data = alpha_weight_hh_data.repeat(1, 1)
self.alpha_weight_hh.data.set_(alpha_weight_hh_data)

# The bias is just set to zero vectors.
if self.use_bias:
init.constant(self.bias.data, val=0)
init.constant(self.alpha_bias.data, val=0)

def forward(self, input_, c_input, hx):
"""
Args:
batch = 1
input_: A (batch, input_size) tensor containing input
features.
c_input: A list with size c_num,each element is the input ct from skip word (batch, hidden_size).
hx: A tuple (h_0, c_0), which contains the initial hidden
and cell state, where the size of both states is
(batch, hidden_size).
Returns:
h_1, c_1: Tensors containing the next hidden and cell state.
"""

h_0, c_0 = hx # 初始的hidden state与cell value,shape=[batch_size,hidden_size]
batch_size = h_0.size(0) # 记住pytorch中这种获取指定维度的方式
assert(batch_size == 1)
bias_batch = (self.bias.unsqueeze(0).expand(batch_size, *self.bias.size()))
wh_b = torch.addmm(bias_batch, h_0, self.weight_hh) #[batch_size,3*hidden_size]
wi = torch.mm(input_, self.weight_ih) #[batch_size,3*hidden_size]
i, o, g = torch.split(wh_b + wi, split_size=self.hidden_size, dim=1) #element size:[batch_size,hidden_size]
g = torch.tanh(g) # totile g
o = torch.sigmoid(o) # 输出门
c_num = len(c_input) # 词汇的数目
if c_num == 0:
f = 1 - i # 遗忘门
c_1 = f*c_0 + i*g # cell值
h_1 = o * torch.tanh(c_1) # 新的 hidden state
else:
c_input_var = torch.cat(c_input, 0) #[c_num,hidden_size]
alpha_bias_batch = (self.alpha_bias.unsqueeze(0).expand(batch_size, *self.alpha_bias.size())) # [batch_size,hidden_size]
c_input_var = c_input_var.squeeze(1) ## [c_num, hidden_size]
alpha_wi = torch.addmm(self.alpha_bias, input_, self.alpha_weight_ih).expand(c_num, self.hidden_size)
alpha_wh = torch.mm(c_input_var, self.alpha_weight_hh)#[batch_size,hidden_size]
alpha = torch.sigmoid(alpha_wi + alpha_wh) #[batch_size,hidden_size]
## alpha = i concat alpha
alpha = torch.exp(torch.cat([i, alpha],0))
alpha_sum = alpha.sum(0)
## alpha = softmax for each hidden element
alpha = torch.div(alpha, alpha_sum)
merge_i_c = torch.cat([g, c_input_var],0)
c_1 = merge_i_c * alpha
c_1 = c_1.sum(0).unsqueeze(0)
h_1 = o * torch.tanh(c_1)
return h_1, c_1

def __repr__(self):
s = '{name}({input_size}, {hidden_size})'
return s.format(name=self.__class__.__name__, **self.__dict__)


class LatticeLSTM(nn.Module):

"""A module that runs multiple steps of LSTM."""

def __init__(self, input_dim, hidden_dim, word_drop, word_alphabet_size, word_emb_dim, pretrain_word_emb=None, left2right=True, fix_word_emb=True, gpu=True, use_bias = True):
super(LatticeLSTM, self).__init__()
skip_direction = "forward" if left2right else "backward"
print "build LatticeLSTM... ", skip_direction, ", Fix emb:", fix_word_emb, " gaz drop:", word_drop
self.gpu = gpu
self.hidden_dim = hidden_dim
self.word_emb = nn.Embedding(word_alphabet_size, word_emb_dim)
if pretrain_word_emb is not None:
print "load pretrain word emb...", pretrain_word_emb.shape
self.word_emb.weight.data.copy_(torch.from_numpy(pretrain_word_emb))

else:
self.word_emb.weight.data.copy_(torch.from_numpy(self.random_embedding(word_alphabet_size, word_emb_dim)))
if fix_word_emb:
self.word_emb.weight.requires_grad = False

self.word_dropout = nn.Dropout(word_drop)

self.rnn = MultiInputLSTMCell(input_dim, hidden_dim)
self.word_rnn = WordLSTMCell(word_emb_dim, hidden_dim)
self.left2right = left2right
if self.gpu:
self.rnn = self.rnn.cuda()
self.word_emb = self.word_emb.cuda()
self.word_dropout = self.word_dropout.cuda()
self.word_rnn = self.word_rnn.cuda()

def random_embedding(self, vocab_size, embedding_dim):
pretrain_emb = np.empty([vocab_size, embedding_dim])
scale = np.sqrt(3.0 / embedding_dim)
for index in range(vocab_size):
pretrain_emb[index,:] = np.random.uniform(-scale, scale, [1, embedding_dim])
return pretrain_emb

def forward(self, input, skip_input_list, hidden=None):
"""
input: variable (batch, seq_len), batch = 1
skip_input_list: [skip_input, volatile_flag]
skip_input: three dimension list, with length is seq_len. Each element is a list of matched word id and its length.
example: [[], [[25,13],[2,3]]] 25/13 is word id, 2,3 is word length .
"""
volatile_flag = skip_input_list[1]
skip_input = skip_input_list[0]
if not self.left2right:
skip_input = convert_forward_gaz_to_backward(skip_input)
input = input.transpose(1,0)
seq_len = input.size(0)
batch_size = input.size(1)
assert(batch_size == 1)
hidden_out = []
memory_out = []
if hidden:
(hx,cx)= hidden
else:
hx = autograd.Variable(torch.zeros(batch_size, self.hidden_dim))
cx = autograd.Variable(torch.zeros(batch_size, self.hidden_dim))
if self.gpu:
hx = hx.cuda()
cx = cx.cuda()

id_list = range(seq_len)
if not self.left2right:
id_list = list(reversed(id_list))
input_c_list = init_list_of_objects(seq_len)
for t in id_list:
(hx,cx) = self.rnn(input[t], input_c_list[t], (hx,cx))
hidden_out.append(hx)
memory_out.append(cx)
if skip_input[t]:
matched_num = len(skip_input[t][0])
word_var = autograd.Variable(torch.LongTensor(skip_input[t][0]),volatile = volatile_flag)
if self.gpu:
word_var = word_var.cuda()
word_emb = self.word_emb(word_var)
word_emb = self.word_dropout(word_emb)
ct = self.word_rnn(word_emb, (hx,cx))
assert(ct.size(0)==len(skip_input[t][1]))
for idx in range(matched_num):
length = skip_input[t][1][idx]
if self.left2right:
# if t+length <= seq_len -1:
input_c_list[t+length-1].append(ct[idx,:].unsqueeze(0))
else:
# if t-length >=0:
input_c_list[t-length+1].append(ct[idx,:].unsqueeze(0))
# print len(a)
if not self.left2right:
hidden_out = list(reversed(hidden_out))
memory_out = list(reversed(memory_out))
output_hidden, output_memory = torch.cat(hidden_out, 0), torch.cat(memory_out, 0)
#(batch, seq_len, hidden_dim)
# print output_hidden.size()
return output_hidden.unsqueeze(0), output_memory.unsqueeze(0)

顺便记录一下另外看的一片IDCNN+CRF的论文,比较简单,而且效果一般,主要的卖点就是快,但实际上我感觉没有快多少。

IDCNN+CRF模型

在NER中,RNN系列的模型成为了标准的提取文本表示的模型,但是RNN一个很大的缺陷是:无法并行化,从而导致整个模型运行非常慢,计算复杂度与序列长度成正相关。而CNN系列的模型的一大优势就是可以并行化,且计算复杂度与序列长度无关,只与层数有关。但是CNN应用于NER的一个非常大的问题在于:每一次的卷积感受野较小,对于NER这种非常看重句子长依赖的NLP任务,具有很大的劣势,一种办法是通过不断叠加卷积层来扩大感受野,但是这样就会导致模型的计算复杂度与序列长度成正相关。所以,如何能够让整个模型并行化,同时又能够较好地捕捉到整个句子的长依赖关系呢?这就是IDCNN+CRF模型的由来。至于什么是空洞卷积,请见link.

参考文献

《Chinese NER Using Lattice LSTM》

《Fast and Accurate Entity Recognition with Iterated Dilated Convolutions》

https://zhuanlan.zhihu.com/p/143272435

Would you like to buy me a cup of coffee☕️~