GPT這篇論文,我還是在GPT-2出來了之後,被它能續寫《紅樓夢》這一事件而震驚,所以才統一看了一下這兩篇論文。這倆都是OpenAI出的,也是用pretrain+fintune的套路進行處理。
文章目錄
一. GPT原理
GPT的訓練分爲兩個階段:1)無監督預訓練語言模型;2)各個任務的微調。
1. 無監督pretrain
這一步論文裏面用的是Transformer的decoder作爲LM。它的目的是優化如下的損失函數:
對於transformer的decoder,可以簡寫爲如下的樣子:
熟悉Transformer的讀者應該都知道,這裏就不再贅述,不熟悉的可以看筆者之前的博客
2. 有監督finetune
以分類任務爲例,在用前面的LM得到最後一個timestep的輸出之後,可以用如下的方式去進行finetune:
可以優化如下的損失函數:
那麼最終的損失函數就可以是優化:
可以發現,這樣在進行finetune的時候,唯一需要添加的就是這個參數,因此添加的新參數量很少。
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. 一些分析
- finetune層數對結果的影響
見下圖:
結論就是每一層都有有用的信息
- ZSL的表現
對於一些完全沒有見過的任務的評估,有助於分析爲什麼pretrain是有用的,一種解釋就是:pretrain的這個model在學習LM的時候,也自然學到了要評估的這些任務所需要的信息來輔助建立語言模型,這也是GPT2的切入點和主推的思路,可見切入點也是比較清奇。
結果如下:
- 一些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)
這是模型的整體架構,與前面原理部分介紹的一樣。分爲如下幾個部分:
- 首先是輸入,將其
reshape
爲[-1, n_ctx, 2]
,這裏的-1
是batch_size * sentence_num
,即先對所有的句子分別進行encode,n_ctx
是句子長度,2
分別代表的是句子中詞的id,以及位置的id。 - 接着的embedding部分,這裏的詞表大小爲
n_vocab + n_special + n_ctx
,前面的n_vocab + n_special
是正常embed的詞表大小,後面的n_ctx
實際上是爲位置id準備的,前面提到過,在GPT裏面,位置的embedding也是通過學習來實現的,而不是使用transformer裏面的sin函數的形式。 - 緊接着就是模型的主體部分,也即transformer的decoder部分,這裏的block後面會詳細說。
- 最後是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)
這裏是:
- 將每個輸入的(story, end1, end2)組織成兩句話,分別是
'_start_' + story + '_delimiter_' + end1 + '_classify_'
和'_start_' + story + '_delimiter_' + end2 + '_classify_'
。
PS: 其實這裏會有一些疑問,對於句子的這種處理方式,在pretrain階段也是有的嗎?如若不然,那麼這兩者的分佈會不會不一致?雖說是finetune,也加入了分隔符,但因爲沒有給出pretrain訓練的代碼,所以還是會有一些疑惑。哪路大神看明白了,可以解答一下~
- 然後分別經過transformer計算出每個timestep的向量表示,計算各自的LM損失
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。
四. 總結
優勢
- 在預訓練完進行下面的任務時,只需要很少很少的改動,而且效果比精心設計的各個任務的網絡,效果還要好
- 探索了ZSL場景下模型的潛力
- 給出了預訓練好的參數,雖然只有TensorFlow的,但轉成別的應該也不難
不足
- 沒有放出pretrain的訓練代碼,並且finetune的部分也只列舉了一個任務
五. 一些思考
感覺與ELMo的不同在於:
- 用transformer而非biLMs
- 是基於bpe的
- 目的是把model接進去訓練,可以加上原來LM的obj,而不側重於生成offline的詞向量
- 用的語料長度不同,並且語料普遍長度更長一些
傳送門
論文: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/