GPT解讀(論文 + TensorFlow實現)

GPT這篇論文,我還是在GPT-2出來了之後,被它能續寫《紅樓夢》這一事件而震驚,所以才統一看了一下這兩篇論文。這倆都是OpenAI出的,也是用pretrain+fintune的套路進行處理。

一. GPT原理

GPT的訓練分爲兩個階段:1)無監督預訓練語言模型;2)各個任務的微調。

1. 無監督pretrain

這一步論文裏面用的是Transformer的decoder作爲LM。它的目的是優化如下的損失函數:

L1(U)=ilogP(uiuik,...,ui1;Θ)L_1(U) = \sum_i logP(u_i | u_{i-k}, ..., u_{i-1}; \Theta)

對於transformer的decoder,可以簡寫爲如下的樣子:

h0=UWe+Wph_0 = UW_e + W_p

hl=transformer_block(hl1)i[1,n]h_l = transformer\_block(h_{l-1}) \forall i \in [1, n]

P(u)=softmax(hnWeT)P(u) = softmax(h_n W_e^T)

熟悉Transformer的讀者應該都知道,這裏就不再贅述,不熟悉的可以看筆者之前的博客

2. 有監督finetune

以分類任務爲例,在用前面的LM得到最後一個timestep的輸出之後,可以用如下的方式去進行finetune:

P(yx1,...,xm)=softmax(hlmWy)P(y|x^1, ..., x^m) = softmax(h_l^m W_y)

可以優化如下的損失函數:

L2(C)=x,ylogP(yx1,...,xm)L_2(C) = \sum_{x, y} log P(y|x^1, ..., x^m)

那麼最終的損失函數就可以是優化:

L3(C)=L2(C)+λL1(C)L_3(C) = L_2(C) + \lambda * L_1(C)

可以發現,這樣在進行finetune的時候,唯一需要添加的就是WyW_y這個參數,因此添加的新參數量很少。

3. 變換到其他任務

前面的finetune是針對分類任務的,那麼同樣也可以通過一些變換,應用到其他類型的任務上,見下圖:

比如對於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

作者在很多數據集上進行了評估,如下:

下面是在各個數據集上的表現:

其實筆者感覺,在作者對比各個不同model的時候,還是挺機智的。因爲作者的模型可能會對長句子處理得較好,但這裏選擇對比的model可能側重點不在長句子上,而這個任務可能是長句子的,所以比較起來,還是有一些優勢的!這其實也給了我們寫論文一些啓發,可以找一些不同尋常的切入點,說不定你就是SoTA了呢。。

3. 一些分析

  1. finetune層數對結果的影響

見下圖:

結論就是每一層都有有用的信息

  1. ZSL的表現

對於一些完全沒有見過的任務的評估,有助於分析爲什麼pretrain是有用的,一種解釋就是:pretrain的這個model在學習LM的時候,也自然學到了要評估的這些任務所需要的信息來輔助建立語言模型,這也是GPT2的切入點和主推的思路,可見切入點也是比較清奇。

結果如下:

  1. 一些ablation study

結論就是: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_'

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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章