GPT这篇论文,我还是在GPT-2出来了之后,被它能续写《红楼梦》这一事件而震惊,所以才统一看了一下这两篇论文。这俩都是OpenAI出的,也是用pretrain+fintune的套路进行处理。
GPT的训练分为两个阶段:1)无监督预训练语言模型;2)各个任务的微调。
这一步论文里面用的是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)=i∑logP(ui∣ui−k,...,ui−1;Θ)
对于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(hl−1)∀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的读者应该都知道,这里就不再赘述,不熟悉的可以看笔者之前的博客
以分类任务为例,在用前面的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(y∣x1,...,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,y∑logP(y∣x1,...,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这个参数,因此添加的新参数量很少。
前面的finetune是针对分类任务的,那么同样也可以通过一些变换,应用到其他类型的任务上,见下图:
比如对于Entailment任务,可以将Premise和Hypothesis打包在一起,而后一起经过这个transformer,进行编码,然后当成分类任务来处理。
对于Similarity任务,因为不像是Entailment是有序的,所以应有两种句子拼接方式,分别是Text1+Text2和Text2+Text1,这样分别经过transformer得到最后一个编码结果,然后逐元素相加,再进行最后的Linear层进行分类。
对于Multiple Choice任务,则将Context(包括文章和问题)分别与多个answer进行拼接,然后分别送入transformer,得到各个choice的向量表示,最后再分别经过各自的Linear得到分数,而后经过softmax计算概率。
作者首先将这里用到的数据集与ELMo进行了对比,用了BooksCorpus作为数据集,也有用到ELMo的那个数据集,但指出ELMo在进行LM的训练过程中,将其切分成了句子,并且做了shuffle,所以句子普遍都比较短。但GPT这里,则用原始的连续长句子进行训练,最后的ppl比ELMo在该数据集上的要低很多。
在模型上,与transformer不同的是,使用了GELU作为激活函数,并使用了可学习的position embedding。
作者在很多数据集上进行了评估,如下:
下面是在各个数据集上的表现:
其实笔者感觉,在作者对比各个不同model的时候,还是挺机智的。因为作者的模型可能会对长句子处理得较好,但这里选择对比的model可能侧重点不在长句子上,而这个任务可能是长句子的,所以比较起来,还是有一些优势的!这其实也给了我们写论文一些启发,可以找一些不同寻常的切入点,说不定你就是SoTA了呢。。
见下图:
结论就是每一层都有有用的信息
对于一些完全没有见过的任务的评估,有助于分析为什么pretrain是有用的,一种解释就是:pretrain的这个model在学习LM的时候,也自然学到了要评估的这些任务所需要的信息来辅助建立语言模型,这也是GPT2的切入点和主推的思路,可见切入点也是比较清奇。
结果如下:
结论就是:1)大数据集,在finetune的时候使用LM的obj作为辅助obj的时候,效果比较明显,可能是大数据集如果自己finetune的话,前面LM会产生灾难性遗忘,但对于小数据,就不会这样;2)LSTM的效果在大部分情况下都比Transformer的要差;3)pretrain很重要!
通过阅读它的源码,发现openai没有给出无监督pretrain具体的训练代码,只给了模型结构及预训练好的参数。同时在finetune部分,也仅仅是放出了一个Story Cloze任务的源码。不过,从实用角度来考虑的话,这些内容已经完全足够了!这里,笔者将按照论文的思路,将源码里面的内容拆分为pretrain的模型部分和finetune的模型及训练部分这两块进行剖析。
前面提到过,这里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)
这是模型的整体架构,与前面原理部分介绍的一样。分为如下几个部分:
reshape
为[-1, n_ctx, 2]
,这里的-1
是batch_size * sentence_num
,即先对所有的句子分别进行encode,n_ctx
是句子长度,2
分别代表的是句子中词的id,以及位置的id。n_vocab + n_special + n_ctx
,前面的n_vocab + n_special
是正常embed的词表大小,后面的n_ctx
实际上是为位置id准备的,前面提到过,在GPT里面,位置的embedding也是通过学习来实现的,而不是使用transformer里面的sin函数的形式。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
这些都比较简单,这里就不再赘述。
源码里面列出的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)
这里是:
'_start_' + story + '_delimiter_' + end1 + '_classify_'
和'_start_' + story + '_delimiter_' + end2 + '_classify_'
。注意,在pretrain的时候,并没有这样加入[CLS]、[SEP]等标识符,只是在finetune的时候才加入!PS: 其实这里会有一些疑问,对于句子的这种处理方式,在pretrain阶段也是有的吗?如若不然,那么这两者的分布会不会不一致?虽说是finetune,也加入了分隔符,但因为没有给出pretrain训练的代码,所以还是会有一些疑惑。哪路大神看明白了,可以解答一下~
lm_losses
(这一步与pretrain一致)。_classify_
这个timestep的表示拿出来作为整句的表示,因其在transformer计算的时候看到了整个句子的信息。并将这个表示用于后续计算二分类的分数(见clf
这个函数),与标签计算交叉熵损失clf_losses
。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。
感觉与ELMo的不同在于:
论文: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/