Universal Language Model Fine-tuning for Text Classification

今天要分享的方法是另一个迁移学习的非常成功的应用,简称ULMFiT(Universal Language Model Fine-tuning),它来自于fast.ai的创始研究员(founding researcher)Jeremy Howard,该方法可以应用到任意一个NLP任务,它已经outperforms the state-of-the-art on six text classification tasks, reducing the error by 10-24% on the majority of datasets。更有意义的是它用了100多个样本达到了一些模型用一百倍数据才能达到的效果。个人觉得它是一个调节各个layers的学习率的方法,所以它的应用范围确实很广,甚至可以应用于BERT。
论文地址:https://arxiv.org/abs/1801.06146
源代码:http://nlp.fast.ai/category/classification.html

论文的introduction和related work介绍了几点这里备注一下:
1. 精调过得预训练得到的word embeddings是一个简单的迁移学习的应用,仅仅作用于模型第一层,已经在学术界工业界得到了非常广泛的应用,并且很多state-of-the-art的模型都采用了这种方法。
2. 并不是LM fine-tuning阻碍了NLP迁移学习的广泛应用,而是如何去有效的训练它们。LMs对小数据集过拟合并且在fine-tuning到目标分类器时之前学到的参数几乎全部无效了。
3. Multi-task learning需要细心地调节各个任务目标函数的权值。

接下来进入方法细节上,如图所示:

第一部分是General-domain LM pretraining,简单来说就是无监督语言模型预训练,图上所示的是用LSTM预测下一个单词,没什么好说的。
第二部分是Target task LM fine-tuning,基本思想是用领域内的数据去无监督训练这个语言模型,因为general-domain和目标任务的数据是来自不同的分布。这个阶段收敛很快,因为只要学习目标任务数据和general-domain的不同部分。这个阶段有两个核心方法,discriminative fine-tuning和slanted triangular learning rates。
Discriminative fine-tuning的想法是基于每个模型的每一层是被设计用来学习不同类型的信息,所以应该要有不同的学习率。普通的SGD更新方法是这样的:\theta_t=\theta_{t-1}-\eta\triangledown_{\theta}J(\theta),论文对每个层次赋予不同的学习率,就变成了\theta_t^l=\theta_{t-1}^l-\eta^l\triangledown_{\theta^l}J(\theta),其中\eta^{l-1}=\eta^{l}/2.6
而slanted triangular learning rates是针对各个学习阶段对学习率进行的更新,论文造了一个公式使得学习率的更新是如图所示这样:

前一段上升阶段是为了使模型很快收敛到一个合适的区域,下降阶段是为了使模型参数能够在上一阶段收敛到的区域里面进行不断的微调。公式如下:

    \[\begin{split} cut&=\lfloor{T*cut\_frac}\rfloor\\ p&=\left\{\begin{array}{ll}t/cut&\textrm{if $t<{cut}$}\\ 1-\frac{t-cut}{cut*(1/cut\_frac-1)}&\textrm{otherwise}\\ \end{array}\right.\\ \eta_t&=\eta_{max}\frac{1+p*(ratio-1)}{ratio} \end{split}\]

T是迭代次数,cut\_frac是切分比例,这里设置成了0.1,即总迭代次数的前0.1部分是上升,后0.9是下降,ratio控制的是最高点和最低的差值,p就是学习率已经上升了多少(百分比)以及还剩多少需要下降这两部分。论文说这个学习率调节方法是取得好效果的关键。
第三部分加入了目标任务部分,是迁移学习的关键。由于documents可长可短,如果只取最后一个隐层状态信息可能丢失,这里对这个做了个改进:

    \[h_c=[h_T,maxpool(H),meanpool(H)]\]

不仅取最后一个状态,还取了所有状态的maxpool和meanpool。
论文中提到过于激进的fine-tuning会导致之前pre-training的信息丢失,而过于谨慎的fine-tuning又会导致收敛太慢,所以论文用了gradual unfreezing技巧,首先训练最后一层,其他层不训练,训练完一个epoch之后训练最后两层,其他层不训练,以此类推直到收敛。

到这里论文所有的技巧都阐述完了,可以看出它的主要贡献在于针对迁移学习的学习率调节,应用面确实可以很广。论文的数据分析也很强劲,下图展示的是整体效果,还是不错的:

下图展示的是样本数对模型的影响,这种迁移学习方法确实对少量样本的学习具有极大的提升:

下图展示的是ULMFiT各个零部件对整体效果的影响:

2018年总体来看NLP在迁移学习上面的进展还是非常喜人的~

— 2018-12-28 18:32:00

R-Net – Machine Reading Comprehension with Self-Matching Networks

今天要分享的是一篇来自微软的轰动一时的阅读理解任务的AI网络结构R-Net,谷歌工程师Sachin Joglekar就曾经这样评价过这个结构:“人工智能的阅读能力在某些方面已经超越了人类,微软的R-Net就是达到了这一里程碑的人工智能之一。”它也是极少被应用于商业界的阅读理解网络结构,阿里就应用了这个结构去解决活动相关的具有时效性的客户服务问题。
论文下载地址:https://www.microsoft.com/en-us/research/wp-content/uploads/2017/05/r-net.pdf
参考git地址:https://github.com/NLPLearn/R-net.git

首先我们看下问题定义,对于一个段落P和问题Q,机器阅读理解的任务是根据P和Q预测答案A。SQuAD数据集的答案是段落里面的连续的一段,而MS-MARCO数据集的答案是人工生成的,所以不是段落里的一段,为了衡量它,这里用ROUGE-L和BLEU1两个指标。R-Net获得的成就就是在这两个数据集上获得了当时最好的结果。

接下来看一下网络结构,如图所示:

也是相当复杂的一个网络结构,在我看来这个网络结构真是把attention用到了极致,跟BERT有异曲同工之妙。
一层一层来看,输入层左边是Question Q=\{w_t^Q\}_{t=1}^m、右边是Passage P=\{w_t^P\}_{t=1}^n,文中用了word-level embeddings和character-level embeddings(RNN的最后一个隐层输出),主要是为了解决OOV问题。输入层的输出是两个level的embeddings的BiRNN的输出:

    \[\begin{split} u_t^Q=BiRNN_Q(u_{t-1}^Q,[e_t^Q,c_t^Q])\\ u_t^P=BiRNN_P(u_{t-1}^P,[e_t^P,c_t^P]) \end{split}\]

接下来进入Gated Attention-Based RNN,这一层主要是为了找到passage相对于question的敏感区域。它相对于普通的attention来说多了一个额外的门(gate)来确定对于问题(Q),哪一片段落(P)是比较重要的信息,结合attention机制,从而达到上述的目的。网络的输出是:

    \[v_t^P=RNN(v_{t-1}^P,[u_t^P,c_t])\]

可以参考match-LSTM来看下为什么右侧不是单独一个u_t^P,而是两个。Gate加在[u_t^P,c_t]这里:

    \[\begin{split} g_t=sigmoid(W_g[u_t^P,c_t])\\ [u_t^P,c_t]^*=g_t\odot[u_t^P,c_t] \end{split}\]

而其中的c_t=att(u^Q,[u_t^P,v_{t-1}^P]),注意这里引入了Q,公式即:

    \[\begin{split} s_j^t&=v^Ttanh(W_u^Qu_j^Q+W_u^Pu_t^P+W_v^Pv_{t-1}^P)\\ a_i^t&=exp(s_i^t)/\sum_{i=1}^mexp(s_j^t)\\ c_t&=\sum_{i=1}^ma_i^tu_i^Q \end{split}\]

下一层进入到了Self-Matching Attention层,主要是为了纵观全文,检查之前的理解是否有误,同样还是通过attention达到此目的。

    \[\begin{split} h_t^P&=BiRNN(h_{t-1}^P,[v_t^P,c_t])\\ s_j^t&=v^Ttanh(W_v^Pv_j^P+W_v^{\widetilde{P}}v_t^P)\\ a_i^t&=exp(s_i^t)/\sum_{i=1}^nexp(s_j^t)\\ c_t&=\sum_{i=1}^ma_i^tv_i^P \end{split}\]

最后一层到了输出层,输出层主要使用了pointer network,本质上它就是一个seq2seq的过程,只不过只有两级,第一级的输入是question的attention-pooling,而不是其他RNN的随机初始化(但是r^Q的初始状态是随机初始化的,因为总会有一个起始点),输出可以通过argmax获取到答案在段落中的起始地址,第二级的输入是第一级的attention-pooling,输出可以通过argmax得到终止地址。实验中还用了一个小trick来确保终止地址始终大于起始地址而且end-start不会过长。公式如下:

    \[\begin{split} s_j^t&=v^Ttanh(W_h^Ph_j^P+W_h^ah_{t-1}^a)\\ a_i^t&=exp(s_i^t)/\sum_{i=1}^nexp(s_j^t)\\ p^t&=argmax(a_1^t,...,a_n^t)\\ c_t&=\sum_{i=1}^na_i^th_i^P\\ h_t^a&=RNN(h_{t-1}^a,c_t)\\ s_j&=v^Ttanh(W_u^Qu_j^Q+W_v^QV_r^Q)\\ a_i&=exp(s_i)/\sum_{i=1}^mexp(s_j)\\ r^Q&=\sum_{i=1}^ma_iu_i^Q(=h_0^a) \end{split}\]

这里我们要预测的起始位置是p^1,终止位置是p^2
这里肯定会有很多人困惑这个V_r^Q是怎么来的,文中并没有介绍,其实它是RNN的首状态,大部分RNN的第一个输入都是这么处理的。看代码还会发现它就是一个通过tf.contrib.layers.xavier_initializer()初始化的参数,那为什么不把这个乘法写成一个参数呢,因为两个都是可学习的参数完全可以合并,我觉得是因为要和attention的输入保持一致(attention函数有两个输入)。代码如下(来自文首代码的layers.py文件):

def question_pooling(memory, units, weights, memory_len = None, scope = "question_pooling"):
    with tf.variable_scope(scope):
        shapes = memory.get_shape().as_list()
        V_r = tf.get_variable("question_param", shape = (Params.max_q_len, units), initializer = tf.contrib.layers.xavier_initializer(), dtype = tf.float32)
        inputs_ = [memory, V_r]
        attn = attention(inputs_, units, weights, memory_len = memory_len, scope = "question_attention_pooling")
        attn = tf.expand_dims(attn, -1)
        return tf.reduce_sum(attn * memory, 1)

到这里整个结构就介绍完了,按照惯例,这里贴上文章的实验结果,第一个SQuAD数据集:

第二个MS-MARCO数据集:

可以说是相当耀眼。

— 2018-12-18 14:13

Exploring the Limits of Language Modeling

今天要分享的是来自google brain的一篇2016年的论文,当时也是获得了非常好的效果。忽然发现我分享的文章几乎都是来自google,可从侧面稍微反映出google在AI领域的举足轻重的地位:)
下载地址:https://arxiv.org/pdf/1602.02410

按照惯例,先分享它取得的结果,它的single model参数个数在减少到当时最好的模型的参数的二十分之一的情况下,将perplexity从51.3降到30.0,而ensemble model将perplexity从41.0降到23.7,可以说是极大的提升,而完成这项成就的就是字符级别的CNN或者LSTM(character convolutional neural network or long-short term memory)。

论文的第二部分分享了一下相关工作和当时状况,主要包括语言模型(Language Models)、Convolutional Embedding Models、和Softmax Over Large Vocabularies三部分,主要是一些背景,感兴趣的可以根据论文介绍检索相关文献去详细了解下,这里略过,重点放在这篇文章的模型上。

图中a是一个典型的LSTM语言模型,从当前单词预测下一个单词。图中b是本文提出的模型,它不是通过当前词预测下一个词,而是分别将当前词和下一个词都先进行Char CNN产生两个向量,LSTM的输入向量就是当前词的Char CNN,输出就是下个词的Char CNN和LSTM输出的“相似性”z_w=h^Te_w(针对每个w都需要产生一个z_w,然后进行softmax:p(w)=\frac{exp(z_w)}{\sum_{w'\inV}exp(z_{w'})}),这么做的一个好处就是极大降低了参数量,不在需要维护整张vocabulary词表,只需要维护characters的词表,这里称它为CNN Softmax,还有另外一个好处就是基本不用担心OOV了。文中还有一个提升就是修改了上述“相似性”计算公式,变成:

    \[z_w=h^TCNN(chars_w)+h^TMcoor_w\]

其中corr是为了解决相似拼法的单词有较大语义差异的问题,M是将低维corr映射到高维h,对效果提升非常明显。
图c是为了提升计算性能,但效果差一些,LSTM预测目标char不是word,LSTM的输出作为sequence的decoder的输入,所以每个decoder要做softmax的数目就极大减少了,从而极大降低计算量。文中提到的小trick:In order to make the whole process reasonably efficient, we train the standard LSTM model until convergence, freeze its weights, and replace the standard word-level Softmax layer with the aforementioned character-level LSTM。

文中的第四部分针对模型做了很多参数调整,并不难理解,这里略去。强劲的实验以及实验结果也是这篇文章成功的一个重要因素,详情可见论文。

综上,本文的最大贡献在于论证了RNN的语言模型可以用于大规模数据集上,而且可以取得相当好的结果。

— 2018-12-17 20:48

BERT系列3 – transformer

首先声明一下,这里的transformer并不是变形金刚,而是BERT的基本组成单元。这一部分应该是三个系列中最枯燥无味的,不过还是有必要记一下。

论文:Attention Is All You Need(名字起得不错)
git地址1:https://github.com/tensorflow/tensor2tensor
git地址2:https://github.com/google-research/bert

Transformer是BERT的基本组成单元,在系列2中我们把它看做是一个黑盒,这里我们深入理解一下这个黑盒,模型结构图如下:

从图上可以看出,transformer主要分为两部分,encoder和decoder。从全局看,encoder有N个identical layers,每个identical layer又有两个sub-layers,每个sub-layer又有Multi-Head Attention、Add&Norm、Feed Forward、Add&Norm,decoder大体相似,只不过多了一层Multi-Head Attention、Add&Norm。到这里应该还是云里雾里,不知道它到底是啥,那我们从流程来看。

首先,模型的输入首先会经过embedding,这里它使用的是learned embeddings,并且encoder和decoder使用的是同一个learned embeddings。
其次,embeddings会经过一个positional encoding,主要是为了注入位置信息,公式如下:

    \[\begin{split} PE_{pos,2i}=sin(pos/10000^{2i/d_{model}})\\ PE_{pos,2i+1}=cos(pos/10000^{2i/d_{model}}) \end{split}\]

pos是word所在位置,对dim 1 2 3 4 5 blabla可以直接根据i算出一个向量跟输入的embedding进行element-wise的相加,encoder和decoder都是如此。
下一步就到了Multi-Head Attention,文中首先介绍了Attention函数可以看做是Q、K、V到output的映射,具体可以参考论文Key-value Attention Mechanism for Neural Machine Translation part 3.2,本质上就是将一个向量划分为两个相同维度向量进行计算,在这里可以把Q看做weight,K看做key,是向量前半部分用于计算Softmax权重,V是value,是向量后半部分用来跟权重相乘求和。公式如下:

    \[Attention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k}})V\]

这里多了一个scaling factor\sqrt{d_k},文中解释了为什么要加这个因子,因为点乘会使Q、K多了一个数量级,使得softmax进入到极小梯度的地方(类似sigmoid曲线的两端),为了抵消这种影响才加了这个因子。
上文介绍的attention这里叫Scaled Dot-Product Attention,是上图Multi-Head Attention的基本元素,如下图所示:

理解起来不难,直接公式:

    \[\begin{split} MultiHead(Q,K,V)=Concat(head_1,...,head_h)W^O\\ where\ head_i=Attention(QW_i^Q,KW_i^K,VW_i^V) \end{split}\]

这里还要注意一下在decoder里面的第一层有一个mask,主要是为了使当前的decoder输入仅仅依靠左侧的输出,防止右侧的数据流入,它是个技术处理并不算一个结构特征。为了更容易理解,这里提醒一下transformer的是把所有文本一次输入给模型,并不是像RNN一个一个处理,从而获得很高的并行性。
再下一步是Add&Norm,相当于LayerNorm(x+Sublayer(x)),这里引入了residual connection,似乎它为什么有效学术界还没有定论。
在下一步是Feed Forward,公式如下:

    \[FFN(x)=max(0,xW_1+b_1)W_2+b_2\]

然后又是Add&Norm,encoder的identical layer已经完成,重复N次就是encoder了。
Decoder的所有单元都是复用encoder的单元,无需多做解释。

文中还花了一个part去解释为什么self-attention,原因有三,第一个是计算复杂度考虑,第二个是计算并行性,第三个是长依赖问题没了(最长路径)。似乎三个都是在说性能更好,当然实验结果也表现得更好,BERT非常完美地体现了,这里也贴一下论文结果:

还有一个side benefit,就是模型更容易解释。

最后强调一下,BERT里面使用的并不是上文介绍的全部,而仅仅是transformer的encoder,所以可以有较高的并行性,我觉得这也是为啥要加position embedding的原因,具体实现细节可以看BERT代码的transformer的transformer_model函数。

— 2018-12-14 17:16

BERT系列2 – pre-training

BERT:Bidirectional Encoder Representations from Transformers
论文下载地址:https://arxiv.org/abs/1810.04805

BERT如雷贯耳,刷新了很多项NLP记录,极大发挥了transfer learning的效果,google出精品!稍微感受下:它在GLUE数据集上获取7.6%的绝对提升,MultiNLI数据集上5.6%的绝对提升,SQuAD v1.1数据集上1.5%的绝对提升!可以说是万能NLP模型。接下来我们来详细看看BERT pre-training模块,fine-tuning已经在系列1中阐述,不做赘述。

Pre-training就是预训练一个模型,这个模型可以适用于很多NLP任务,譬如文本分类、NER、QA等。Google开源了它的代码,良心的是Google不仅预训练了英文模型,还预训练了中文模型,可以说是节省了我们大量财力(据说在TPU上预训练一把需要花费5w块)。我们可以将模型稍微修改一下就可以应用到很多NLP任务里面,可以说是相当方便,代码地址:https://github.com/google-research/bert

接下来我们进入主题,model architecture。如果我们不管transformer(会在系列3中阐述)是什么,把transformer当做是一个黑盒,那么它的结构就相当简单,一个多层的transformer,层数、宽度等等参数都是可以调节的,详见论文。
第一部分是模型的输入,论文进行了相当多的细节处理,如图所示:

第一个,它用了wordpiece embeddings,第二个它加入了positional embeddings,第三个它还加入了segment embeddings。论文中对后两个embeddings讲的不多,看代码可以知道positional embeddings是个位置编码,通过模型训练出来之后每个位置都会有一个跟word embedding一样维度的向量,加入到word embedding中,如下代码所示:

  if use_position_embeddings:
    assert_op = tf.assert_less_equal(seq_length, max_position_embeddings)
    with tf.control_dependencies([assert_op]):
      full_position_embeddings = tf.get_variable(
          name=position_embedding_name,
          shape=[max_position_embeddings, width],
          initializer=create_initializer(initializer_range))
      # Since the position embedding table is a learned variable, we create it
      # using a (long) sequence length `max_position_embeddings`. The actual
      # sequence length might be shorter than this, for faster training of
      # tasks that do not have long sequences.
      #   
      # So `full_position_embeddings` is effectively an embedding table
      # for position [0, 1, 2, ..., max_position_embeddings-1], and the current
      # sequence has positions [0, 1, 2, ... seq_length-1], so we can just
      # perform a slice.
      position_embeddings = tf.slice(full_position_embeddings, [0, 0], 
                                     [seq_length, -1])
      num_dims = len(output.shape.as_list())

      # Only the last two dimensions are relevant (`seq_length` and `width`), so
      # we broadcast among the first dimensions, which is typically just
      # the batch size.
      position_broadcast_shape = []
      for _ in range(num_dims - 2):
        position_broadcast_shape.append(1)
      position_broadcast_shape.extend([seq_length, width])
      position_embeddings = tf.reshape(position_embeddings,
                                       position_broadcast_shape)
      output += position_embeddings

为什么要加入positional embedding?原因就是多层transformer不像RNN没有包含位置信息。
segment embedding就更简单了,SEP分隔的第一段句子为0,第二段句子为1,还有个二维数组将这个0或者1映射到word embedding的维度,参数可以学习。代码里面还说了为什么有SEP还要segment embedding,它的解释是为了模型更容易学习到序列的概念。

  if use_token_type:
    if token_type_ids is None:
      raise ValueError("`token_type_ids` must be specified if"
                       "`use_token_type` is True.")
    token_type_table = tf.get_variable(
        name=token_type_embedding_name,
        shape=[token_type_vocab_size, width],
        initializer=create_initializer(initializer_range))
    # This vocab will be small so we always do one-hot here, since it is always
    # faster for a small vocabulary.
    flat_token_type_ids = tf.reshape(token_type_ids, [-1])
    one_hot_ids = tf.one_hot(flat_token_type_ids, depth=token_type_vocab_size)
    token_type_embeddings = tf.matmul(one_hot_ids, token_type_table)
    token_type_embeddings = tf.reshape(token_type_embeddings,
                                       [batch_size, seq_length, width])
    output += token_type_embeddings

将以上三个embeddings相加作为多层transformer的输入,整个模型就这样了。

怎么做分类?获取BERT输出第一个label做个LR。怎么做相似性计算?获取BERT输出第一个label做个LR。怎么做QA或者NER?获取所有输出对所有输出做LR。就这么简单。什么?怎么获取输出或者embedding?google给你写好啦:

  def get_pooled_output(self):
    return self.pooled_output

  def get_sequence_output(self):
    """Gets final hidden layer of encoder.

    Returns:
      float Tensor of shape [batch_size, seq_length, hidden_size] corresponding
      to the final hidden of the transformer encoder.
    """
    return self.sequence_output

  def get_all_encoder_layers(self):
    return self.all_encoder_layers

  def get_embedding_output(self):
    """Gets output of the embedding lookup (i.e., input to the transformer).

    Returns:
      float Tensor of shape [batch_size, seq_length, hidden_size] corresponding
      to the output of the embedding layer, after summing the word
      embeddings with the positional embeddings and the token type embeddings,
      then performing layer normalization. This is the input to the transformer.
    """
    return self.embedding_output

  def get_embedding_table(self):
    return self.embedding_table

当然到这里并没有结束,怎么去预训练这个模型?google又花了很多心思在这上面,这可能是这个模型能成功的很大一个原因,也给了我们不少启发。
两个无监督任务做pre-training:Masked LM和Next Sentence Prediction。
第一个任务MLM简单来说就是随机抹去15%的单词去预测这个单词是啥。细节来了,它并不是总是用[MASK]替换那些单词,替换只占了80%,剩余的10%替换成了一个随机单词,最后的10%保留了原句。Transformer并不知道预测哪个单词,所以它需要记住每个单词的上下文分布,并且被替换的单词不多,并没有破坏语义。
第二个任务就是判断句子A是否是B的前一句。这个任务对QA和NLI收益很大。

最后loss的定义就是两个task的loss求和平均。

实验结果这里就不多说了,反正就是牛!

— 2018-12-13 18:41