参考文章:
https://www.jiqizhixin.com/articles/2018-10-24-13  
http://colah.github.io/posts/2015-08-Understanding-LSTMs/  
 
一、介绍 1.1 文章组织 本文简要介绍了BiLSTM的基本原理,并以句子级情感分类任务为例介绍为什么需要使用LSTM或BiLSTM进行建模。在文章的最后,我们给出在PyTorch下BiLSTM的实现代码,供读者参考。
1.2 情感分类任务 自然语言处理中情感分类任务是对给定文本进行情感倾向分类的任务,粗略来看可以认为其是分类任务中的一类。对于情感分类任务,目前通常的做法是先对词或者短语进行表示,再通过某种组合方式把句子中词的表示组合成句子的表示。最后,利用句子的表示对句子进行情感分类。
举一个对句子进行褒贬二分类的例子。
句子:我爱赛尔 情感标签:褒义
 
1.3 什么是LSTM和BiLSTM? LSTM的全称是Long Short-Term Memory,它是RNN(Recurrent Neural Network)的一种。LSTM由于其设计的特点,非常适合用于对时序数据的建模,如文本数据。BiLSTM是Bi-directional Long Short-Term Memory的缩写,是由前向LSTM与后向LSTM组合而成。两者在自然语言处理任务中都常被用来建模上下文信息。
1.4 为什么使用LSTM与BiLSTM? 将词的表示组合成句子的表示,可以采用相加的方法,即将所有词的表示进行加和,或者取平均等方法,但是这些方法没有考虑到词语在句子中前后顺序。如句子”我不觉得他好”。”不”字是对后面”好”的否定,即该句子的情感极性是贬义。使用LSTM模型可以更好的捕捉到较长距离的依赖关系。因为LSTM通过训练过程可以学到记忆哪些信息和遗忘哪些信息。
但是利用LSTM对句子进行建模还存在一个问题:无法编码从后到前的信息。在更细粒度的分类时,如对于强程度的褒义、弱程度的褒义、中性、弱程度的贬义、强程度的贬义的五分类任务需要注意情感词、程度词、否定词之间的交互。举一个例子,”这个餐厅脏得不行,没有隔壁好”,这里的”不行”是对”脏”的程度的一种修饰,通过BiLSTM可以更好的捕捉双向的语义依赖。
二、BiLSTM原理简介 2.1 LSTM介绍 2.1.1 总体框架 LSTM模型是由t时刻的输入词$Xt$,细胞状态$C_t$,临时细胞状态$\tilde{C_t}$,隐层状态$h_t$,遗忘门$f_t$,记忆门$\dot{i_t}$,输出门$o_t$组成。LSTM的计算过程可以概括为,通过对细胞状态中信息遗忘和记忆新的信息使得对后续时刻计算有用的信息得以传递,而无用的信息被丢弃,并在每个时间步都会输出隐层状态$h {t-1}$,其中遗忘,记忆与输出由通过上个时刻的隐层状态$h_{t-1}$和当前输入$X_t$计算出来的遗忘门$f_t$,记忆门$\dot{i_t}$,输出门$o_t$来控制。
总体框架如图1所示。
图1. LSTM总体框架 
2.1.2 详细介绍计算过程 计算遗忘门,选择要遗忘的信息。 
输入:前一时刻的隐层状态 $h_{t-1}$,当前时刻的输入词 $X_t$ 输出:遗忘门的值$f_t$
 
图2. 计算遗忘门 
2. 计算记忆门,选择要记忆的信息。 
输入:前一时刻的隐层状态 $h_{t-1}$,当前时刻的输入词 $X_t$ 输出:记忆门的值$\dot{i_t}$,临时细胞状态$\tilde{C_t}$
 
图3. 计算记忆门和临时细胞状态 
3. 计算当前时刻细胞状态 
输入:记忆门的值$\dot{it}$,遗忘门的值$f_t$,临时细胞状态$\tilde{C_t}$,上一刻细胞状态$C {t-1}$ 输出:当前时刻细胞状态$C_t$
 
图4. 计算当前时刻细胞状态 
计算输出门和当前时刻隐层状态 
输入:前一时刻的隐层状态$h_{t-1}$,当前时刻的输入词$X_t$ ,当前时刻细胞状态$C_t$ 输出:输出门的值$o_t$,隐层状态$h_t$
 
图5. 计算输出门和当前时刻隐层状态 
最终,我们可以得到与句子长度相同的隐层状态序列{$ h0, h_1, … , h {n-1}$}。
2.2 BiLSTM介绍 前向的LSTM与后向的LSTM结合成BiLSTM。比如,我们对”我爱中国”这句话进行编码,模型如图6所示。
图6. 双向LSTM编码句子前向的$LSTML$依次输入”我”,”爱”,”中国”得到三个向量{$h {L0}$, $h{L1}$,$h {L2}$}。后向的$LSTMR$依次输入”中国”,”爱”,”我”得到三个向量{$h {R0}$, $h{R1}$,$h {R2}$}。最后将前向和后向的隐向量进行拼接得到{[$h{L0}$, $h {R2}$],[$h{L1}$, $h {R1}$],[$h{L2}$, $h {R0}$]},即{$h_0, h_1 , h_2$}。
对于情感分类任务来说,我们采用的句子的表示往往是[$h{L2}$, $h {R2}$]。因为其包含了前向与后向的所有信息,如图7所示。
图7. 拼接向量用于情感分类 
三、BiLSTM代码实现样例 3.1 模型搭建 使用PyTorch搭建BiLSTM样例代码。代码地址为  https://github.com/albertwy/BiLSTM/。 
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 class  BLSTM (nn.Module) :  """      Implementation of BLSTM Concatenation for sentiment classification task   """   def  __init__ (self, embeddings, input_dim, hidden_dim, num_layers, output_dim, max_len=40 , dropout=0.5 ) :       super(BLSTM, self).__init__()       self.emb = nn.Embedding(num_embeddings=embeddings.size(0 ),embedding_dim=embeddings.size(1 ),padding_idx=0 )       self.emb.weight = nn.Parameter(embeddings)       self.input_dim = input_dim       self.hidden_dim = hidden_dim       self.output_dim = output_dim              self.sen_len = max_len       self.sen_rnn = nn.LSTM(input_size=input_dim,hidden_size=hidden_dim,num_layers=num_layers,dropout=dropout,batch_first=True ,bidirectional=True )       self.output = nn.Linear(2  * self.hidden_dim, output_dim)   def  bi_fetch (self, rnn_outs, seq_lengths, batch_size, max_len) :       rnn_outs = rnn_outs.view(batch_size, max_len, 2 , -1 )              fw_out = torch.index_select(rnn_outs, 2 , Variable(torch.LongTensor([0 ])).cuda())       fw_out = fw_out.view(batch_size * max_len, -1 )       bw_out = torch.index_select(rnn_outs, 2 , Variable(torch.LongTensor([1 ])).cuda())       bw_out = bw_out.view(batch_size * max_len, -1 )       batch_range = Variable(torch.LongTensor(range(batch_size))).cuda() * max_len       batch_zeros = Variable(torch.zeros(batch_size).long()).cuda()       fw_index = batch_range + seq_lengths.view(batch_size) - 1        fw_out = torch.index_select(fw_out, 0 , fw_index)         bw_index = batch_range + batch_zeros       bw_out = torch.index_select(bw_out, 0 , bw_index)       outs = torch.cat([fw_out, bw_out], dim=1 )       return  outs   def  forward (self, sen_batch, sen_lengths, sen_mask_matrix) :       """        :param sen_batch: (batch, sen_length), tensor for sentence sequence       :param sen_lengths:       :param sen_mask_matrix:       :return:       """       ''' Embedding Layer | Padding | Sequence_length 40'''        sen_batch = self.emb(sen_batch)       batch_size = len(sen_batch)       ''' Bi-LSTM Computation '''        sen_outs, _ = self.sen_rnn(sen_batch.view(batch_size, -1 , self.input_dim))       sen_rnn = sen_outs.contiguous().view(batch_size, -1 , 2  * self.hidden_dim)         ''' Fetch the truly last hidden layer of both sides        '''       sentence_batch = self.bi_fetch(sen_rnn, sen_lengths, batch_size, self.sen_len)         representation = sentence_batch       out = self.output(representation)       out_prob = F.softmax(out.view(batch_size, -1 ))       return  out_prob 
 
init ()函数中对网络进行初始化,设定词向量维度,前向/后向LSTM中隐层向量的维度,还有要分类的类别数等。
bifetch()函数的作用是将$h {L2}$与$h_{R2}$拼接起来并返回拼接后的向量。由于使用了batch,所以需要使用句子长度用来定位开始padding时前一个时刻的输出的隐层向量。
forward()函数里进行前向计算,得到各个类别的概率值。
3.2 模型训练 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 def  train (model, training_data, args, optimizer, criterion) :    model.train()     batch_size = args.batch_size     sentences, sentences_seqlen, sentences_mask, labels = training_data          assert  batch_size == len(sentences) == len(labels)     ''' Prepare data and prediction'''      sentences_, sentences_seqlen_, sentences_mask_ = \         var_batch(args, batch_size, sentences, sentences_seqlen, sentences_mask)     labels_ = Variable(torch.LongTensor(labels))     if  args.cuda:         labels_ = labels_.cuda()     assert  len(sentences) == len(labels)     model.zero_grad()     probs = model(sentences_, sentences_seqlen_, sentences_mask_)     loss = criterion(probs.view(len(labels_), -1 ), labels_)     loss.backward()     optimizer.step() 
 
代码中training_data是一个batch的数据,其中包括输入的句子sentences(句子中每个词以词下标表示),输入句子的长度sentences_seqlen,输入的句子对应的情感类别labels。 训练模型前,先清空遗留的梯度值,再根据该batch数据计算出来的梯度进行更新模型。
1 2 3 4 5 6 7 8 9 model.zero_grad()    probs = model(sentences_, sentences_seqlen_, sentences_mask_)    loss = criterion(probs.view(len(labels_), -1 ), labels_)    loss.backward()    optimizer.step() 
 
3.3 模型测试 以下是进行模型测试的代码。
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 def  test (model, dataset, args, data_part="test" ) :    """      :param model:     :param args:     :param dataset:     :param data_part:     :return:     """     tvt_set = dataset[data_part]     tvt_set = yutils.YDataset(tvt_set["xIndexes" ],                               tvt_set["yLabels" ],                               to_pad=True , max_len=args.sen_max_len)     test_set = tvt_set     sentences, sentences_seqlen, sentences_mask, labels = test_set.next_batch(len(test_set))     assert  len(test_set) == len(sentences) == len(labels)     tic = time.time()     model.eval()     ''' Prepare data and prediction'''      batch_size = len(sentences)     sentences_, sentences_seqlen_, sentences_mask_ = \         var_batch(args, batch_size, sentences, sentences_seqlen, sentences_mask)     probs = model(sentences_, sentences_seqlen_, sentences_mask_)     _, pred = torch.max(probs, dim=1 )     if  args.cuda:         pred = pred.view(-1 ).cpu().data.numpy()     else :         pred = pred.view(-1 ).data.numpy()     tit = time.time() - tic     print  "  Predicting {:d} examples using {:5.4f} seconds" .format(len(test_set), tit)     labels = numpy.asarray(labels)     ''' log and return prf scores '''      accuracy = test_prf(pred, labels)     return  accuracy 
 
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 def  cal_prf (pred, right, gold, formation=True, metric_type="" ) :    """      :param pred: predicted labels     :param right: predicting right labels     :param gold: gold labels     :param formation: whether format the float to 6 digits     :param metric_type:     :return: prf for each label     """     num_class = len(pred)     precision = [0.0 ] * num_class     recall = [0.0 ] * num_class     f1_score = [0.0 ] * num_class     for  i in  xrange(num_class):         ''' cal precision for each class: right / predict '''          precision[i] = 0  if  pred[i] == 0  else  1.0  * right[i] / pred[i]         ''' cal recall for each class: right / gold '''          recall[i] = 0  if  gold[i] == 0  else  1.0  * right[i] / gold[i]         ''' cal recall for each class: 2 pr / (p+r) '''          f1_score[i] = 0  if  precision[i] == 0  or  recall[i] == 0  \             else  2.0  * (precision[i] * recall[i]) / (precision[i] + recall[i])         if  formation:             precision[i] = precision[i].__format__(".6f" )             recall[i] = recall[i].__format__(".6f" )             f1_score[i] = f1_score[i].__format__(".6f" )     ''' PRF for each label or PRF for all labels '''      if  metric_type == "macro" :         precision = sum(precision) / len(precision)         recall = sum(recall) / len(recall)         f1_score = 2  * precision * recall / (precision + recall) if  (precision + recall) > 0  else  0      elif  metric_type == "micro" :         precision = 1.0  * sum(right) / sum(pred) if  sum(pred) > 0  else  0          recall = 1.0  * sum(right) / sum(gold) if  sum(recall) > 0  else  0          f1_score = 2  * precision * recall / (precision + recall) if  (precision + recall) > 0  else  0      return  precision, recall, f1_score 
 
四、总结 本文中,我们结合情感分类任务介绍了LSTM以及BiLSTM的基本原理,并给出一个BiLSTM样例代码。除了情感分类任务,LSTM与BiLSTM在自然语言处理领域的其它任务上也得到了广泛应用,如机器翻译任务中使用其进行源语言的编码和目标语言的解码,机器阅读理解任务中使用其对文章和问题的编码等。
五、参考资料 http://colah.github.io/posts/2015-08-Understanding-LSTMs/