GPT解读(论文 + TensorFlow实现)

GPT这篇论文,我还是在GPT-2出来了之后,被它能续写《红楼梦》这一事件而震惊,所以才统一看了一下这两篇论文。这俩都是OpenAI出的,也是用pretrain+fintune的套路进行处理。

文章目录

    • 一. GPT原理
      • 1. 无监督pretrain
      • 2. 有监督finetune
      • 3. 变换到其他任务
    • 二. 实验
      • 1. 无监督pretrain
      • 2. 有监督finetune
      • 3. 一些分析
    • 三. TensorFlow实现
      • 1. 无监督pretrain
      • 2. 有监督finetune
    • 四. 总结
      • 优势
      • 不足
    • 五. 一些思考
    • 传送门

一. GPT原理

GPT的训练分为两个阶段:1)无监督预训练语言模型;2)各个任务的微调。

1. 无监督pretrain

这一步论文里面用的是Transformer的decoder作为LM。它的目的是优化如下的损失函数:

L 1 ( U ) = ∑ i l o g P ( u i ∣ u i − k , . . . , u i − 1 ; Θ ) L_1(U) = \sum_i logP(u_i | u_{i-k}, ..., u_{i-1}; \Theta) L1(U)=ilogP(uiuik,...,ui1;Θ)

对于transformer的decoder,可以简写为如下的样子:

h 0 = U W e + W p h_0 = UW_e + W_p h0=UWe+Wp

h l = t r a n s f o r m e r _ b l o c k ( h l − 1 ) ∀ i ∈ [ 1 , n ] h_l = transformer\_block(h_{l-1}) \forall i \in [1, n] hl=transformer_block(hl1)i[1,n]

P ( u ) = s o f t m a x ( h n W e T ) P(u) = softmax(h_n W_e^T) P(u)=softmax(hnWeT)

熟悉Transformer的读者应该都知道,这里就不再赘述,不熟悉的可以看笔者之前的博客

2. 有监督finetune

以分类任务为例,在用前面的LM得到最后一个timestep的输出之后,可以用如下的方式去进行finetune:

P ( y ∣ x 1 , . . . , x m ) = s o f t m a x ( h l m W y ) P(y|x^1, ..., x^m) = softmax(h_l^m W_y) P(yx1,...,xm)=softmax(hlmWy)

可以优化如下的损失函数:

L 2 ( C ) = ∑ x , y l o g P ( y ∣ x 1 , . . . , x m ) L_2(C) = \sum_{x, y} log P(y|x^1, ..., x^m) L2(C)=x,ylogP(yx1,...,xm)

那么最终的损失函数就可以是优化:

L 3 ( C ) = L 2 ( C ) + λ ∗ L 1 ( C ) L_3(C) = L_2(C) + \lambda * L_1(C) L3(C)=L2(C)+λL1(C)

可以发现,这样在进行finetune的时候,唯一需要添加的就是 W y W_y Wy这个参数,因此添加的新参数量很少。

3. 变换到其他任务

前面的finetune是针对分类任务的,那么同样也可以通过一些变换,应用到其他类型的任务上,见下图:

GPT解读(论文 + TensorFlow实现)_第1张图片

比如对于Entailment任务,可以将Premise和Hypothesis打包在一起,而后一起经过这个transformer,进行编码,然后当成分类任务来处理。

对于Similarity任务,因为不像是Entailment是有序的,所以应有两种句子拼接方式,分别是Text1+Text2和Text2+Text1,这样分别经过transformer得到最后一个编码结果,然后逐元素相加,再进行最后的Linear层进行分类。

对于Multiple Choice任务,则将Context(包括文章和问题)分别与多个answer进行拼接,然后分别送入transformer,得到各个choice的向量表示,最后再分别经过各自的Linear得到分数,而后经过softmax计算概率。

二. 实验

1. 无监督pretrain

作者首先将这里用到的数据集与ELMo进行了对比,用了BooksCorpus作为数据集,也有用到ELMo的那个数据集,但指出ELMo在进行LM的训练过程中,将其切分成了句子,并且做了shuffle,所以句子普遍都比较短。但GPT这里,则用原始的连续长句子进行训练,最后的ppl比ELMo在该数据集上的要低很多。

在模型上,与transformer不同的是,使用了GELU作为激活函数,并使用了可学习的position embedding。

2. 有监督finetune

作者在很多数据集上进行了评估,如下:

GPT解读(论文 + TensorFlow实现)_第2张图片

下面是在各个数据集上的表现:

GPT解读(论文 + TensorFlow实现)_第3张图片
GPT解读(论文 + TensorFlow实现)_第4张图片
GPT解读(论文 + TensorFlow实现)_第5张图片

其实笔者感觉,在作者对比各个不同model的时候,还是挺机智的。因为作者的模型可能会对长句子处理得较好,但这里选择对比的model可能侧重点不在长句子上,而这个任务可能是长句子的,所以比较起来,还是有一些优势的!这其实也给了我们写论文一些启发,可以找一些不同寻常的切入点,说不定你就是SoTA了呢。。

3. 一些分析

  1. finetune层数对结果的影响

见下图:

GPT解读(论文 + TensorFlow实现)_第6张图片

结论就是每一层都有有用的信息

  1. ZSL的表现

对于一些完全没有见过的任务的评估,有助于分析为什么pretrain是有用的,一种解释就是:pretrain的这个model在学习LM的时候,也自然学到了要评估的这些任务所需要的信息来辅助建立语言模型,这也是GPT2的切入点和主推的思路,可见切入点也是比较清奇。

结果如下:

GPT解读(论文 + TensorFlow实现)_第7张图片
  1. 一些ablation study
GPT解读(论文 + TensorFlow实现)_第8张图片

结论就是:1)大数据集,在finetune的时候使用LM的obj作为辅助obj的时候,效果比较明显,可能是大数据集如果自己finetune的话,前面LM会产生灾难性遗忘,但对于小数据,就不会这样;2)LSTM的效果在大部分情况下都比Transformer的要差;3)pretrain很重要!

三. TensorFlow实现

通过阅读它的源码,发现openai没有给出无监督pretrain具体的训练代码,只给了模型结构及预训练好的参数。同时在finetune部分,也仅仅是放出了一个Story Cloze任务的源码。不过,从实用角度来考虑的话,这些内容已经完全足够了!这里,笔者将按照论文的思路,将源码里面的内容拆分为pretrain的模型部分和finetune的模型及训练部分这两块进行剖析。

1. 无监督pretrain

前面提到过,这里pretrain的模型主要是transformer的decoder,具体代码如下:

# 1. input
X = tf.reshape(X, [-1, n_ctx, 2])

# 2. embedding
we = tf.get_variable("we", [n_vocab+n_special+n_ctx, n_embd], initializer=tf.random_normal_initializer(stddev=0.02))
we = dropout(we, embd_pdrop, train)
h = embed(X, we)

# 3. decoder of transformer
for layer in range(n_layer):
    h = block(h, 'h%d'%layer, train=train, scale=True)
    
# 4. loss
lm_h = tf.reshape(h[:, :-1], [-1, n_embd])
lm_logits = tf.matmul(lm_h, we, transpose_b=True)
lm_losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=lm_logits, labels=tf.reshape(X[:, 1:, 0], [-1]))
lm_losses = tf.reshape(lm_losses, [shape_list(X)[0], shape_list(X)[1]-1])
lm_losses = tf.reduce_sum(lm_losses*M[:, 1:], 1)/tf.reduce_sum(M[:, 1:], 1)

这是模型的整体架构,与前面原理部分介绍的一样。分为如下几个部分:

  1. 首先是输入,将其reshape[-1, n_ctx, 2],这里的-1batch_size * sentence_num,即先对所有的句子分别进行encode,n_ctx是句子长度,2分别代表的是句子中词的id,以及位置的id。
  2. 接着的embedding部分,这里的词表大小为n_vocab + n_special + n_ctx,前面的n_vocab + n_special是正常embed的词表大小,后面的n_ctx实际上是为位置id准备的,前面提到过,在GPT里面,位置的embedding也是通过学习来实现的,而不是使用transformer里面的sin函数的形式。
  3. 紧接着就是模型的主体部分,也即transformer的decoder部分,这里的block后面会详细说。
  4. 最后是loss部分,在pretrain阶段是LM的交叉熵损失,这里是将前面transformer的输出,再乘上一个变换层(用了tie的思想,将输入的embedding层参数直接用于这里的变换)。这里的M表示的是长度上的mask。

下面来看transformer部分中block的具体实现方式:

def block(x, scope, train=False, scale=False):
    with tf.variable_scope(scope):
        nx = shape_list(x)[-1]
        a = attn(x, 'attn', nx, n_head, train=train, scale=scale)
        n = norm(x+a, 'ln_1')
        m = mlp(n, 'mlp', nx*4, train=train)
        h = norm(n+m, 'ln_2')
        return h

可见,就是去掉了encoder-decoder attention部分的标准transformer的decoder形式。

其中,attention计算的方式为:

def attn(x, scope, n_state, n_head, train=False, scale=False):
    assert n_state%n_head==0
    with tf.variable_scope(scope):
        c = conv1d(x, 'c_attn', n_state*3, 1, train=train)
        q, k, v = tf.split(c, 3, 2)
        q = split_heads(q, n_head)
        k = split_heads(k, n_head, k=True)
        v = split_heads(v, n_head)
        a = _attn(q, k, v, train=train, scale=scale)
        a = merge_heads(a)
        a = conv1d(a, 'c_proj', n_state, 1, train=train)
        a = dropout(a, resid_pdrop, train)
        return a
        
def mask_attn_weights(w):
    n = shape_list(w)[-1]
    b = tf.matrix_band_part(tf.ones([n, n]), -1, 0)
    b = tf.reshape(b, [1, 1, n, n])
    w = w*b + -1e9*(1-b)
    return w

def _attn(q, k, v, train=False, scale=False):
    w = tf.matmul(q, k)

    if scale:
        n_state = shape_list(v)[-1]
        w = w*tf.rsqrt(tf.cast(n_state, tf.float32))

    w = mask_attn_weights(w)
    w = tf.nn.softmax(w)

    w = dropout(w, attn_pdrop, train)

    a = tf.matmul(w, v)
    return a

代码思路很清晰,注意mask_attn_weights部分实现的是计算decoder时的mask部分,在进行LM训练时,这一步尤为重要,因需要防止后面的内容泄漏。

PS: 这里有一个疑问就是,一般在transformer的decoder计算mask的时候,是由两部分取&产生的,一部分是batch中本身句子的实际长度,另一部分是为了防止泄漏需要的mask,但这里显然没有batch中句子长度mask的影子,难道是为了简化计算?所以如此粗糙?或者是笔者忽略了哪一点?有哪路大神看懂的话,烦请答疑解惑~

接下来的是feed forward和norm的计算方式:

def mlp(x, scope, n_state, train=False):
    with tf.variable_scope(scope):
        nx = shape_list(x)[-1]
        act = act_fns[afn]
        h = act(conv1d(x, 'c_fc', n_state, 1, train=train))
        h2 = conv1d(h, 'c_proj', nx, 1, train=train)
        h2 = dropout(h2, resid_pdrop, train)
        return h2

def norm(x, scope, axis=[-1]):
    with tf.variable_scope(scope):
        n_state = shape_list(x)[-1]
        g = tf.get_variable("g", [n_state], initializer=tf.constant_initializer(1))
        b = tf.get_variable("b", [n_state], initializer=tf.constant_initializer(0))
        return _norm(x, g, b, axis=axis)
        
def _norm(x, g=None, b=None, e=1e-5, axis=[1]):
    u = tf.reduce_mean(x, axis=axis, keep_dims=True)
    s = tf.reduce_mean(tf.square(x-u), axis=axis, keep_dims=True)
    x = (x - u) * tf.rsqrt(s + e)
    if g is not None and b is not None:
        x = x*g + b
    return x

这些都比较简单,这里就不再赘述。

2. 有监督finetune

源码里面列出的finetune部分主要是针对Story Cloze Test任务的,这个任务给定的是(story, end1, end2),目的是判断哪个ending是story的end,可以转化为一个二分类的任务。

GPT本身秉承的原则就是,用最小的网络修改来finetune原来pretrain好的网络,因此这里相比于pretrain来说,主要的修改就是加了一个分类层和分类loss的计算,如下代码所示:

# 网络结构
def model(X, M, Y, train=False, reuse=False):
    with tf.variable_scope('model', reuse=reuse):
    
        # --------------- 与pretrain相同的部分 -----------------
        we = tf.get_variable("we", [n_vocab+n_special+n_ctx, n_embd], initializer=tf.random_normal_initializer(stddev=0.02))
        we = dropout(we, embd_pdrop, train)

        X = tf.reshape(X, [-1, n_ctx, 2])
        M = tf.reshape(M, [-1, n_ctx])

        h = embed(X, we)
        for layer in range(n_layer):
            h = block(h, 'h%d'%layer, train=train, scale=True)

        lm_h = tf.reshape(h[:, :-1], [-1, n_embd])
        lm_logits = tf.matmul(lm_h, we, transpose_b=True)
        lm_losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=lm_logits, labels=tf.reshape(X[:, 1:, 0], [-1]))
        lm_losses = tf.reshape(lm_losses, [shape_list(X)[0], shape_list(X)[1]-1])
        lm_losses = tf.reduce_sum(lm_losses*M[:, 1:], 1)/tf.reduce_sum(M[:, 1:], 1)
        # ------------------------------------------------------

        # ---------------- 与task相关的部分 --------------------
        clf_h = tf.reshape(h, [-1, n_embd])
        pool_idx = tf.cast(tf.argmax(tf.cast(tf.equal(X[:, :, 0], clf_token), tf.float32), 1), tf.int32)
        clf_h = tf.gather(clf_h, tf.range(shape_list(X)[0], dtype=tf.int32)*n_ctx+pool_idx)

        clf_h = tf.reshape(clf_h, [-1, 2, n_embd])
        if train and clf_pdrop > 0:
            shape = shape_list(clf_h)
            shape[1] = 1
            clf_h = tf.nn.dropout(clf_h, 1-clf_pdrop, shape)
        clf_h = tf.reshape(clf_h, [-1, n_embd])
        clf_logits = clf(clf_h, 1, train=train)
        clf_logits = tf.reshape(clf_logits, [-1, 2])

        clf_losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=clf_logits, labels=Y)
        return clf_logits, clf_losses, lm_losses
     
# 分类函数   
def clf(x, ny, w_init=tf.random_normal_initializer(stddev=0.02), b_init=tf.constant_initializer(0), train=False):
    with tf.variable_scope('clf'):
        nx = shape_list(x)[-1]
        w = tf.get_variable("w", [nx, ny], initializer=w_init)
        b = tf.get_variable("b", [ny], initializer=b_init)
        return tf.matmul(x, w)+b
        
# 最终的loss
train_loss = tf.reduce_mean(clf_losses) + lm_coef*tf.reduce_mean(lm_losses)

这里是:

  1. 将每个输入的(story, end1, end2)组织成两句话,分别是'_start_' + story + '_delimiter_' + end1 + '_classify_''_start_' + story + '_delimiter_' + end2 + '_classify_'注意,在pretrain的时候,并没有这样加入[CLS]、[SEP]等标识符,只是在finetune的时候才加入!

PS: 其实这里会有一些疑问,对于句子的这种处理方式,在pretrain阶段也是有的吗?如若不然,那么这两者的分布会不会不一致?虽说是finetune,也加入了分隔符,但因为没有给出pretrain训练的代码,所以还是会有一些疑惑。哪路大神看明白了,可以解答一下~

  1. 然后分别经过transformer计算出每个timestep的向量表示,计算各自的LM损失lm_losses(这一步与pretrain一致)。
  2. 之后将_classify_这个timestep的表示拿出来作为整句的表示,因其在transformer计算的时候看到了整个句子的信息。并将这个表示用于后续计算二分类的分数(见clf这个函数),与标签计算交叉熵损失clf_losses
  3. 最终的train_loss是这两个loss的一个加权,这也是常见的Multi-task Learning的形式。

在finetune的训练阶段,其流程如下:

# 1. 加载预训练的参数
shapes = json.load(open('model/params_shapes.json'))
offsets = np.cumsum([np.prod(shape) for shape in shapes])
init_params = [np.load('model/params_{}.npy'.format(n)) for n in range(10)]
init_params = np.split(np.concatenate(init_params, 0), offsets)[:-1]
init_params = [param.reshape(shape) for param, shape in zip(init_params, shapes)]
init_params[0] = init_params[0][:n_ctx]
init_params[0] = np.concatenate([init_params[1], (np.random.randn(n_special, n_embd)*0.02).astype(np.float32), init_params[0]], 0)
del init_params[1]

if n_transfer == -1:
    n_transfer = 0
else:
    n_transfer = 1+n_transfer*12
sess.run([p.assign(ip) for p, ip in zip(params[:n_transfer], init_params[:n_transfer])])

# 2. finetune训练
for i in range(n_iter):
    for xmb, mmb, ymb in iter_data(*shuffle(trX, trM, trYt, random_state=np.random), n_batch=n_batch_train, truncate=True, verbose=True):
        cost, _ = sess.run([clf_loss, train], {X_train:xmb, M_train:mmb, Y_train:ymb})
        n_updates += 1
        if n_updates in [1000, 2000, 4000, 8000, 16000, 32000] and n_epochs == 0:
            log()
    n_epochs += 1
    log()

可见就是标准的finetune流程,先加载之前pretrain好的参数,然后进行针对于当前任务的finetune。

四. 总结

优势

  1. 在预训练完进行下面的任务时,只需要很少很少的改动,而且效果比精心设计的各个任务的网络,效果还要好
  2. 探索了ZSL场景下模型的潜力
  3. 给出了预训练好的参数,虽然只有TensorFlow的,但转成别的应该也不难

不足

  1. 没有放出pretrain的训练代码,并且finetune的部分也只列举了一个任务

五. 一些思考

感觉与ELMo的不同在于:

  1. 用transformer而非biLMs
  2. 是基于bpe的
  3. 目的是把model接进去训练,可以加上原来LM的obj,而不侧重于生成offline的词向量
  4. 用的语料长度不同,并且语料普遍长度更长一些

传送门

论文:https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/language-unsupervised/language_understanding_paper.pdf
源码:https://github.com/openai/finetune-transformer-lm (TensorFlow)
https://github.com/huggingface/pytorch-pretrained-BERT (PyTorch,虽然名字是BERT,里面也有GPT的实现)
官方blog:https://blog.openai.com/language-unsupervised/

你可能感兴趣的:(论文笔记,自然语言处理,前沿)