一、為什麼要提出BERT?
傳統的RNN類模型,包括LSTM,GRU以及其他各種變體,最大的問題在於提取能力不足。在《Why Self-Attention?
A Targeted Evaluation of Neural Machine Translation Architectures》中證明了RNN的長距離特徵提取能力甚至不亞於Transformer,並且比CNN強。其主要問題在於這一類模型的並行能力較差,因為time step的存在,導致每一個時刻的輸入必須跟在上一個時刻之後,從而無法使用矩陣進行並行輸入。另一方面,ELMo和GPT的提出,正式宣告了遷移學習(預訓練+微調)的思想在NLP的引入,並且二者作為動態詞向量,逐步代替Word2Vec等靜態詞向量,解決了“一詞多義”的問題。那麼,BERT又為何要被提出呢?
如下圖所示,BERT,GPT和ELMo的結構圖如下。
從特徵提取器方面來看,ELMo使用的是LSTM,而GPT和BERT用的都是Transformer,只不過前者是用decoder而後者用的是encoder。ELMo使用的LSTM提取語義特徵的能力不如Transformer。因此在特徵提取方面,GPT和BERT都要更好。
從單雙向方面來看,GPT是單向的,剩下二者是雙向的。顯然,GPT只利用了上文的資訊去預測某一個詞,效果自然比不過BERT這種利用上下文資訊來"完形填空"的做法。另外,ELMo本質上也不能算作真正的利用到了雙向的資訊,因為它兩個模組是分開訓練的,即圖上顯示的這種分別由左向LSTM和右向LSTM來提取特徵的方式,並且最終使用拼接(concatenate)的融合方式,效果是不如self-attention的特徵融合方式的。在原文中,作者稱BERT是"deep bi-directional"。
綜上所述,我們可以看出BERT是融合了ELMo和GPT兩位"大前輩"的優點而改良得到的。BERT的提出,也轟動了NLP界。
二、BERT是什麼?
1. 簡介
BERT,全稱Bidirectional Encoder Representation of Transformer,首次提出於《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》一文中。簡單來說,BERT是使用了Transformer的encoder(即解碼器)部分,因此也可以認為BERT就是Transformer的encoder部分。BERT既可以認為是一個生成Word Embedding的方法,也可以認為是像LSTM這樣用於特徵提取的模型結構。
2. 結構
BERT的結構如上圖所示。可以看到當Embeddings被輸入後,會經過多層的Transformer的encoder(即圖中的Trm)進行特徵提取。**注意!!!這裡每一層的所有Trm是共用一套\(W_q\)\(,\)W_k\(,\)W_v\(的**,而由於使用了多頭注意力機制(Multi-head attention),每一層其實是有多套\)W_q\(,\)W_k\(,\)W_v$的。
論文中提出的BERT分為\(BERT_{BASE}\)和\(BERT_{LARGE}\)。
\(BERT_{BASE}: L = 12, H = 768, A = 12, Total\_Parameters = 110M\)
\(BERT_{LARGE}: L = 24, H = 1024, A = 16, Total\_Parameters = 340M\)
其中,\(L\)代表層數,\(H\)代表Hidden size, \(A\)代表多頭注意力的頭數。\(BERT_{BASE}\)是為了與GPT對比而提出的,而\(BERT_{LARGE}\)的表現則更優於前者。
1)輸入與嵌入
與其他用於NLP任務的模型類似,文字經過分詞(tokenization)後,每一個token會在embedding層轉化為word embedding,隨後再進入模型內部進行後續操作。略微有些不同的是,Bert的輸入進入embedding層被分為了三個部分。
Token Embedding
與其他用於NLP問題的模型類似,每個token需要轉化為word embedding(詞嵌入,亦稱word vector詞向量),這種結構化的資料才適合作為模型的輸入。token embedding的初始化有兩種方式。第一種是在預訓練時,會生成一個隨機初始化的token embedding矩陣。第二種則是更為常見的在預訓練模型上微調(fine-tune),在這種情況下就會讀取預訓練模型預先訓練好的embedding矩陣(亦稱look-up table),並且在訓練過程中進行微調。注意!token embedding的大小是21128*768(中文),30522*768(英文),其中21128和30522分別為中英文vocab的大小,768是word embedding的維度大小。由於模型結構中用到了multi-head self attention機制,使得token embeddings在訓練過程中可以學習到上下文資訊並以此更新,從而解決一詞多義的問題,這也就是BERT被稱作動態詞向量的原因。在PyTorch中,一般是在定義模型的時候新增這麼一句,embedding層中的權重就會跟著更新了。
for param in self.bert.parameters():
param.requires_grad = True
舉例:
值得注意的是,BERT中使用的分詞方式是基於WordPiece方法的,並且會新增上\([CLS]\)和\([SEP]\)兩個字元。
-
\([CLS]\)就是classification的意思,一般是放在第一個句子的首位。最後一層的\([CLS]\)字元對應的向量可以作為整句話的語義表示,也就是句向量,從而用於下游的分類任務。使用這個字元是因為與文字中已有的其它詞相比,這個無明顯語義資訊的符號會更“公平”地融合文字中各個詞的語義資訊,從而更好的表示整句話的語義。
具體來說,self-attention是用文字中的其它詞來增強目標詞的語義表示,但是目標詞本身的語義還是會佔主要部分的,因此,經過BERT的12層,每次詞的embedding融合了所有詞的資訊,可以去更好的表示自己的語義。而\([CLS]\)本身沒有語義,經過12層,得到的是attention後所有詞的加權平均,相比其他正常詞,可以更好的表徵句子語義。
在Hugging Face中是用pooler_output來返回\([CLS]\)的embedding的。官方描述如下:this returns the classification token after processing through a linear layer and a tanh activation function. The linear layer weights are trained from the next sentence prediction (classification) objective during pretraining.
原始碼中,就是將\([CLS]\)的embedding輸入一個fc層和一個tanh函式再輸出。
-
\([SEP]\)就是用於輸入為句子對時區分兩個句子的字元。
-
關於分詞。BERT採用的是WordPiece方法,屬於subword level的分詞方式,介於word和character兩個粒度級別之間。這種級別主要是為了解決word級別存在的問題:
- vocabulary過大
- 通常會存在out of vocabulary(OOV)的問題
- vocabulary中會存在很多相似的詞
以及character級別中的問題: - 文字序列可能會非常長
- 無法很好對詞語的語義進行表徵,畢竟單詞都被劃分為字母了
subword是指對相對低頻或者很複雜的詞語進行拆分,而對於常見的詞語例如"dog"是不會拆分的,而相對較為低頻的"dogs"則會拆分。這樣做可以使得低頻詞轉化為高頻詞儲存在vocabulary中,從而解決了OOV的問題。同時,轉化為常見詞以後也可以大大降低vocabulary的大小。例如,只需要存放"boy"、"girl"和"##s"就能夠表示"boy"、"girl"、"boys"和"girls"這四個詞。關於WordPiece演算法的具體實現,可以參考理解tokenizer之WordPiece: Subword-based tokenization algorithm
Segment Embedding
BERT可以用於處理句子對輸入的分類問題,簡單來說就是判斷輸入的句子對是否語義相似。而往往我們會將兩個句子拼接成一個句子對輸入至模型中,segment embedding的作用就是用於標識兩個不同的句子。舉例如下:
事實上,當用BERT處理非句子對輸入的任務,例如文字分類時,只需要將輸入文字包括padding(補長)部分全部設為0即可。segment embedding矩陣的大小是2*768。
Position Embedding
跟Transformer類似,多頭注意力機制的使用會使得文字輸入後丟失位置資訊,也就是詞序。然而詞序對於理解一句話來說是非常重要的,“我愛你”和”你愛我”完全是兩種意思。因此position embeddings就是用於標識token的位置,而與Transformer中的不同,BERT中的position embeddings的初始化方式和更新方式與token embedding類似,並且採用的是絕對位置。position embedding矩陣的大小是512*768,因為BERT允許的預設最大長度是512。
Attention masks
事實上,除了以上embeddings之外,在Hugging Face中還有一個引數是需要我們提供的,就是attention mask。關於這個引數,Hugging Face官方文件的解釋是
This argument indicates to the model which tokens should be attended to, and which should not.
由於輸入是轉化成一個個batch的,因此需要靠補長和截斷來保持文字長度的統一,而補長部分是不需要參與attention操作的。1代表需要參與attention的token,而0表示補長的部分。
程式碼例項
text = ['今天天氣很好','我覺得很不錯這款B48發動機很不錯']
for txt in text:
encoding_result = tokenizer.encode_plus(txt, max_length=10, padding='max_length', truncation = True)
print(encoding_result)
[{'input_ids': [101, 791, 1921, 1921, 3698, 2523, 1962, 102, 0, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]},
{'input_ids': [101, 2769, 6230, 2533, 2523, 679, 7231, 6821, 3621, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}]
上述例子展示的是兩個長短不一致的文字經過tokenizer轉換後得到的結果。input_ids是指每個token在vocab中的序號,用這個序號在token embedding矩陣中去查詢對應的詞嵌入。本質上就是將序號轉化為one-hot vector,然後再與embedding矩陣相乘,從而得到矩陣中的某一行/列,這個行/列向量即為所求,這種操作就是look up,這種embedding矩陣也稱為look-up table。類似的,token_type_ids則是用於查詢segment embedding的,而attention_mask就只是用於標識是否需要attention操作,不會轉化為向量。那麼position_ids呢?它則是由模型自動生成的,會在模型的forward()函式中生成。Hugging Face官方文件是這樣描述的:
position_ids — Indices of positions of each input sequence tokens in the position embeddings. Selected in the range [0, config.max_position_embeddings - 1].
此處的config.max_position_embeddings預設為512,也可以調成1024或者2048。
總結
BERT的輸入包含三種embedding:token embedding、segment embedding和position embedding,都是由對應的id做look up操作而得的。其中position_ids是可以由模型自己生成的。值得注意的是,BERT中生成的position embedding的方式類似於word embedding的生成方式,也被稱為parametric(引數式),對應的則是Transformer中的functional(函式式)。得到三種embedding之後,模型會將三者相加,一併輸入模型中做後續操作。為什麼這三個embedding可以相加呢?不會改變向量原本的方向從而失去一定語義資訊嗎?事實上,這種element-wise summation就等同於先將三種embedding向量拼接在一起,然後再與一個大的look-up table相乘,這種拼接本質上就是做特徵融合。舉個例子,某個token的三種獨熱向量分別是\([0,1,0,0]\)、\([1,0]\)和\([1,0,0]\)。下圖是三個向量分別和矩陣做乘法最後相加得到的結果。
下圖則是三個向量先做拼接後再與一個由上述三個矩陣拼接而成的大矩陣相乘得到的結果。
可以看到,這兩種方式得到的結果是一致的。因此可以認為三個embedding相加就是在做特徵融合。
2) 中間層
從上面結構圖可知,中間部分採用的是Transformer的encoder。encoder的結構如下。
Multi-head Attention
多頭自注意力機制是BERT最關鍵的部分之一。略微不同的是,在微調階段,BERT的幾個矩陣中的權值都是預先訓練好的,僅需在下游任務訓練時進行微調。
Add&Norm
這部分看起來就兩個詞,實際上包含了兩種機制/技術。一是skip connect殘差連線,二是Layer Normalization層標準化。通常認為,殘差連線在《Deep Residual Learning for Image Recognition》被提出後廣受歡迎。它的作用就在於減緩反向傳播時導致的梯度消失以及深層網路的退化現象。下圖展示了殘差連線的結構,BERT中的add指的就是將原輸入與經過多頭自注意力機制之後的結果相加起來。
Layer Normalization,即層標準化,是對應於Batch Normalization的另一種標準化方式,在《Layer Normalization》中被提出。與Batch Normalization不同的是,Layer Normalization是對於同一層中所有節點進行標準化,在NLP問題中就是對某一個詞的向量進行標準化。原文中用以下的公式來對第\(l\)層進行Layer Normalization:
其中\(H\)代表這一層中節點的個數,即詞向量的維度,\(g\)和\(b\)分別叫做gain和bias引數,用於仿射變換,實際上就是乘以\(g\)做放縮,再加上\(b\)做平移。而在PyTorch中是用下面這個公式去計算的
\(\epsilon\)是一個非常小的數,作用是防止分母為0,\(\gamma\)和\(\beta\)就是上述兩個引數。PyTorch中nn.LayerNorm類的定義如下:
torch.nn.LayerNorm(normalized_shape, eps=1e-05, elementwise_affine=True, device=None, dtype=None)
舉個例子來說明這個類怎麼用。
text = torch.FloatTensor([[[1,3,5],
[1,7,8]],
[[2,4,6],
[3,2,1]]])
layer_norm = nn.LayerNorm(3)
print(layer_norm(text))
tensor([[[-1.2247, 0.0000, 1.2247],
[-1.4018, 0.5392, 0.8627]],
[[-1.2247, 0.0000, 1.2247],
[ 1.2247, 0.0000, -1.2247]]], grad_fn=<NativeLayerNormBackward0>)
text是一個2*2*3的張量,可以理解為batch*seq_len*embedding_dim,即batch數為2,文字長度為2,詞向量維度為3。我們可以看到,輸出也是一個2*2*3的張量,那麼其中元素數值是怎麼算的呢?此時normalised_shape引數傳入的是3,即輸入維度最後一維的size,那麼就會沿著最後一維求出均值\(E[X]\)和方差\(Var[x]\)。此處
再根據上述公式計算Layer Normalization之後的值。舉個例子,第1行(從0開始)第0列的\(1-\frac{16}{3}=-\frac{13}{3}\),除以\(\sqrt{\frac{86}{9}+0.00001}\),得到的就是-1.4018。注意,此時elementwise_affine為True,weight和bias引數的shape和normalised_shape是一致的,二者中的元素分別初始化為1和0。而當elementwise_affine為False時,得到的結果如下。此時是少了兩個可學習的引數,並且不參與梯度計算。關於Normalization可以參考
tensor([[[-1.2247, 0.0000, 1.2247],
[-1.4018, 0.5392, 0.8627]],
[[-1.2247, 0.0000, 1.2247],
[ 1.2247, 0.0000, -1.2247]]])
值得注意的是,Add&Norm這部分在程式碼中實際上還包括dropout,原文作者有提到dropout之後的效果更好。
Feed Forward
Transformer模型原文中的公式是
實際上就是兩層全連線層,中間隱層用的啟用函式是ReLU函式。在PyTorch中的程式碼實現如下:
class FeedForward(nn.Module):
'''
原文中隱層維度為3072,輸入和輸出維度即d_model = 768
'''
def __init__(self, input_dim, hidden_dim = 2048):
super(FeedForward,self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, input_dim)
def forward(self, x):
out = self.fc1(x)
out = F.relu(out)
out = self.fc2(out)
return out
而不一樣的是,在BERT模型中,使用的啟用函式是GELU。GELU,高斯誤差線性單元啟用函式Gaussian Error Linear Units,可以被看作是ReLU函式的平滑版,畢竟ReLU並非處處可導。在BERT原始碼中是這樣寫的
def gelu(input_tensor):
cdf = 0.5 * (1.0 + tf.erf(input_tensor / tf.sqrt(2.0))) #用erf函式近似
return input_tesnsor*cdf
下圖展示了GELU與ReLU函式的對比圖,橙色的是GELU函式,藍色的是ReLU函式。可以看到,GELU函式在0點處是可導的。
3) 輸出層
根據Hugging Face的官方文件,BERT本身的輸出的有四個。
- last_hidden_state:這是模型最後一層輸出的隱藏狀態,shape是[batch_size, seq_len, hidden_dim],而hidden_dim = 768;
- pooler_output:這就是\([CLS]\)字元對應的隱藏狀態,它經過了一個線性層和Tanh啟用函式進一步的處理。shape是[batch_size, hidden_dim]
- hidden_states:這是可選項,當output_hidden_states = True時會輸出。它是一個包含了13個torch.FloatTensor的元組,每一個張量的shape均為[batch_size, seq_len, hidden_dim]。根據文件,這13個張量分別代表了嵌入層和12層encoder的輸出。例如hidden_states[0]就代表嵌入層的輸出,hidden_states[12]就是最後一層的輸出,即last_hidden_state。
- attentions:這是可選項,當output_attentions = True時會輸出。它是一個12個torch.FloatTensor元組,包含了每一層注意力權重,即經過自注意力操作中經過Softmax之後得到的矩陣。每一個張量的shape均為[batch_size, num_head, seq_len, seq_len]。
由於BERT是一個預訓練模型,因此最終的輸出層是根據下游任務不同而變化的。下圖是BERT原文中展示的幾個下游任務以及BERT是怎麼做的。句子對分類任務以及單句的分類任務都是透過\([CLS]\)字元輸出class label的,一般來說後面接個全連線層就可以將向量從768維對映為目標維數,再接一個Softmax函式就可以變為機率分佈,從而完成分類。上文提到,\([CLS]\)可以理解為整個句子的句向量,因此可以用作分類任務。(d)中提到的則是實體標註的任務,即對句子中每個token的詞性或者其他屬性進行標註,因此需要對每個token都進行輸出。
(c)中展示的是BERT用於問答任務(其實是閱讀理解)。在此類任務中,BERT要求將問題和答案所在參考文字拼接在一起,中間用\([SEP]\)作為分隔。此處可以當成句子對的任務來看,因此需要顯式指定\(segment\_id\)。
那麼BERT是怎麼從文字中找到對應答案的呢?BERT是將某一個範圍的文字"高亮"出來,以表示選出來的答案。這本質上就是預測哪個token作為開始,哪個token作為結束。下圖描述的是將文字中每一個token對應的最終embedding向量與start token分類器的權重做點乘,再經過Softmax函式得到機率分佈,以此選出得分最高的token作為start token。這個start token分類器只有一套權重,作用於文字中每一個token。同樣地,end token也是這麼被找到的,只不過用的是end token分類器。
三、BERT是怎麼進行預訓練的?
上文提到,BERT屬於預訓練模型,而根據下游任務的不同再進行微調。當然,也可以選擇不微調,Huggingface的Transformer庫裡提供了很多已經可以直接拿來解決不同下游任務的預訓練模型,例如BertForQuestionAnswering,BertForSequenceClassification等等。那麼BERT是怎麼進行預訓練的呢?BERT是針對兩個任務進行預訓練的。
1.Masked Language Model
簡單來說,這個預訓練任務就是一個完型填空的任務,即透過上下文判斷出某一位置應該是什麼詞。這一任務是受到了ELMo和GPT的啟發。在GPT中,訓練語言模型的時候用的是Decoder,這就導致它有一個必須從左到右預測的限制,因為解碼器中存在masked multi-head attention。因此,GPT只訓練出提取上文資訊預測下文的能力,而沒有使用下文。而ELMo看上去用了雙向,但實際上是分別以\(P(w_i|w_1,w_2,\cdots,w_{i-1})\)和\(P(w_i|w_{i+1},\cdots,w_n)\)作為目標函式,這兩個目標函式在訓練過程中都只考慮了單向的上文或下文,只是在得到representation時拼接在一起。但BERT不一樣,它是以\(P(w_i|w_1,\cdots,w_{i-1},w_{i+1},\cdots,w_n)\)作為目標函式的,也就是考慮了上下文。
原文中,作者在輸入的序列裡隨機選中15%的詞用\([MASK]\)字元替換掉,然後讓BERT去預測這個詞。但後來這也導致了一個問題:在微調階段\([MASK]\)字元是不會出現的,所以就產生了不匹配。因此,作者對這15%的詞做了以下改動:
- 其中80%仍用\([MASK]\)字元替換
- 10%用隨機的詞語替換
- 10%保持原來的詞
細節
- 引入\([MASK]\)字元是為了顯示地告訴模型”當前這個詞你得從上下文去推斷,我不會告訴你“。實際上這就是一種Denoising Autoencoder的思路,那些被替換掉的位置就相當於引入了噪音,BERT的這種預訓練方式也被稱為DAE LM(Denosing Autoencoder Language Model)。
- 為什麼這15%的詞不能全部都用\([MASK]\)去替換?倘若這麼做,在微調階段,模型見到的都是正常的詞語而沒有\([MASK]\),它就只能完全基於上下文資訊來推斷當前詞,而無法利用當前詞本身的資訊,畢竟它們從未在預訓練階段出現過。
- 為什麼要引入隨機詞語?如果按照80%用\([MASK]\)字元,剩下20%用於原詞語,那麼模型就會學到“如果當前詞語是\([MASK]\),那麼就從上下文去推斷;如果當前詞語是一個正常詞語,那麼答案就是這個詞“這一模式。這樣一來,在微調階段模型見到的都是正常的詞語,模型就直接”照抄“所有的詞,而不會提取上下文的資訊了。以一定機率引入隨機詞語,就是想讓模型無論什麼情況下,都要把當前token資訊和上下文資訊結合起來,從而在微調階段才能提取這兩方面的資訊,因為它不知道當前的詞語是否是”原來的詞“。並且,隨機詞語的替換僅佔1.5%(10%*15%),因此對於模型的語言理解能力沒有什麼影響。
2. Next Sentence Prediction
此任務是讓模型預測下一個句子是否真的是當前句子的下一句。起因是很多重要的下游任務例如問答(QA)和自然語言推理(NLI)都基於兩個句子之間的關係,因而此任務就可以使得模型學習提取兩個句子之間關係的能力。具體做法如下:
- 選擇句子A和B作為輸入,將兩個句子首尾相接拼接起來,中間用\([SEP]\)連線。
- 其中50%的時間裡,選擇的B是A的真實的下一句。
- 剩下50%的時間裡,隨機選擇B,只要不是A的下一句即可。
下圖即為NSP任務的一個例子
3. 關於MLM和NSP的其他問題
損失函式
BERT的損失函式由兩部分組成:MLM任務的損失函式+NSP任務的損失函式,用公式表示即為:
其中\(\theta\)指的是encoder部分中的引數,\(\theta_1\)指的是MLM任務在encoder部分之後接的輸出層中的引數,\(\theta_2\)指的是NSP任務中encoder後接上的分類器的引數。
而對於MLM任務,實際上也就是一個分類的任務。倘若所有被遮蓋/替換的詞語的集合是M,而vocabulary的長度為\(|V|\),那麼這就是一個\(|V|\)分類的問題。下面這個公式就是負對數似然函式,最小化這個函式就等同於最大似然估計,即求得一組\(\theta\)和\(\theta_1\),使得N個\(m_i\)出現的機率最大。
再來看看NSP任務的損失函式。NSP可以看作是一個二分類的文字分類任務,只需要將\([CLS]\)的輸出接入一個全連線層作為分類器。
加在一起就是
其他細節
- 借鑑Adherer要加油呀~ 的說法,具體的預訓練工程實現細節方面,BERT 還利用了一系列策略,使得模型更易於訓練,比如對於學習率的 warm-up 策略,使用的啟用函式不再是普通的 ReLu,而是 GeLu,也使用了 dropout 等常見的訓練技巧。
- 由上述損失函式可以推斷出來,MLM和NSP這兩個預訓練是聯合訓練的,也就是一起訓練的。
- 在BERT後續的變體模型RoBERTa的論文裡,被提出NSP這個預訓練任務不但沒有使下游任務微調時有明顯的受益,甚至還會有負面作用,所以乾脆直接不用NSP了。
四、如何使用BERT?
下面用一個簡單的例子來展示bert_case_chinese這個預訓練模型是怎麼用的,其他版本的也都是大同小異了。以下內容參考Pytorch-Bert預訓練模型的使用(呼叫transformers)。
首先下載transformers模組,這個模組包含了很多NLP和NLU中會使用的預訓練模型,包括BERT、GPT-2、RoBERTa等等。從transformers模組中引入BertModel、BertTokenizer和BertConfig類。同時還需要引入torch模組。
!pip install transformers
from transformers import BertModel, BertTokenizer, BertConfig
import torch
值得注意的是,由於我使用的是Google Colab平臺,直接from transformers import BertModel會從官方的s3資料庫下載模型配置、引數等資訊,這在大陸並不可用。因此一般來說就需要手動下載模型,下載bert-base-chinese,裡面包含config.josn,vocab.txt,pytorch_model.bin三個檔案,將其放在對應的資料夾內。
下面則是匯入分詞器、配置和模型
#透過詞典匯入分詞器
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
#匯入配置檔案
model_config = BertConfig.from_pretrained('bert-base-chinese')
#修改配置
model_config.output_hidden_states = True
model_config.output_attentions = True
#透過配置和模型id來匯入模型
model = BertModel.from_pretrained('bert-base-chinese', config = model_config)
接著開始分詞。此處設定最大長度為10,過長的會被截斷,而不夠長的會用\([PAD]\)補長
text = ['你真的很好看。','這個牌子的咖啡很好喝。']
encoding_results = list()
for txt in text:
encoding_results.append(tokenizer.encode_plus(txt, max_length = 10, padding = 'max_length', truncation = True))
print(encoding_results)
[{'input_ids': [101, 872, 4696, 4638, 2523, 1962, 4692, 102, 0, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]},
{'input_ids': [101, 6821, 702, 4277, 2094, 4638, 1476, 1565, 2523, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}]
列印的結果就是encode_plus()返回的結果。encode_plus()返回的是兩個字典,每個字典包含以下三個元素:
- input_ids:每個token在詞典中的index。例如此處\([CLS]\)和\([SEP]\)token分別對應的是101和102,而補長的token則是0。
- token_type_ids:上文提到的用於查詢segment embedding的id,即用於區分兩個句子的編碼。
- attention_mask: 指定對於哪些token進行attention操作。例如此處第一個句子最後補長的部分則不進行attention操作。
除此之外,也可以用encode()來進行分詞,只不過只會返回input_ids。接著讓我們看看分詞後句子變成了什麼。
for res in encoding_results:
print(tokenizer.convert_ids_to_tokens(res['input_ids']))
['[CLS]', '你', '真', '的', '很', '好', '看', '[SEP]', '[PAD]', '[PAD]']
['[CLS]', '這', '個', '牌', '子', '的', '咖', '啡', '很', '[SEP]']
可以看到,文字被切分成一個個的字,首尾分別新增上了\([CLS]\)和\([SEP]\)字元,並且補償部分用的是\([PAD]\)字元。接著將字典中的三個元素取出來,放入列表後組成張量作為模型輸入。
input_ids = list()
type_ids = list()
mask_ids = list()
for res in encoding_results:
input_ids.append(res['input_ids'])
type_ids.append(res['token_type_ids'])
mask_ids.append(res['attention_mask'])
#將三個列表轉化為張量
input_ids = torch.tensor(input_ids)
type_ids = torch.tensor(type_ids)
mask_ids = torch.tensor(mask_ids)
輸入模型之後,得到返回值。返回值是一個字典,我們先檢視它的keys。
outputs = model(input_ids, token_type_ids = type_ids, attention_mask = mask_ids)
print(outputs.keys())
#odict_keys(['last_hidden_state', 'pooler_output', 'hidden_states', 'attentions'])
可以看到,keys包含了上述的四個輸出,由於config部分將兩個引數調為了True,因此也會輸出hidden_states和attentions。至此,關於BERT如何使用的部分就結束了。現在看看輸出部分。
print(outputs['last_hidden_state'].shape)
print(outputs['pooler_output'].shape)
torch.Size([2, 10, 768])
torch.Size([2, 768])
剛好對應上batch_size = 2, seq_len = 10和hidden_dim = 768。
print(len(outputs['hidden_states']))
print(len(outputs['attentions']))
print(outputs['hidden_states'][8].shape)
print(outputs['attentions'][1].shape)
13
12
torch.Size([2, 10, 768])
torch.Size([2, 12, 10, 10])
前兩個結果說明,attentions是不算上embedding層的,因此只有12個元素;而hidden_states則是包含了embedding層的輸出,所以一共有13個元素。另外後兩個結果也正好對應了上文的shape。另外,如果下游任務需要進行微調,就需要定義最佳化器和損失函式。損失函式根據不同下游任務有不同的選擇,例如多分類任務可以使用交叉熵函式;而最佳化器一般選擇的是\(AdamW\)最佳化器,具體可以看這裡[[關於最佳化(一)]]。
五、一些細節
1. Feature-based和Fine-tuning
在BERT的論文中,作者提到了ELMo是屬於Feature-based,而GPT和BERT屬於Fine-tuning(當然,BERT也可以用feature-based方法)。
Feature-based就是透過訓練神經網路語言模型,而其中的權重是可以拿來當作詞語的embedding的。簡單來說,feature-based要的不是整個語言模型,而是其中的”中間產物”,即embedding,再用這些embedding去作為下游任務的輸入。最經典的例子就是ELMo和Word2Vec。
對於靜態詞向量例如Word2Vec和Glove,其做法就是查表。也就是輸入某一個詞的one-hot編碼,然後查詢對應的詞向量,並且得到的詞向量用以下游任務;對於動態詞向量例如ELMo和BERT,是將下游任務的資料輸入至模型中,得到每個詞的embedding,再用於下游任務中。由此也可以看出,靜態詞向量是指在訓練後不再發生改變,而動態詞向量會根據上下文的不同而變化。
Feature-based方法分為兩個步驟:
- 首先在大的語料A上無監督地訓練語言模型,訓練完畢得到語言模型。
- 然後構造task-specific model例如序列標註模型,採用有標記的語料B來有監督地訓練task-sepcific model,將語言模型的引數固定,語料B的訓練資料經過語言模型得到LM embedding,作為task-specific model的額外特徵。
Fine-tuning則不同,此類方法是將整個模型拿過來,再根據下游任務的不同進行新增或者修改,使其輸出符合任務需要。一般來說都是在模型的最後一層或者現有模型結構之後新增上一層網路結構以匹配各種下游任務。GPT-1、GPT-2和BERT就用到了Fine-tuning。
Fine-tune分為兩個步驟:
- 構造語言模型,採用大的語料A來訓練語言模型
- 在語言模型基礎上增加少量神經網路層來完成specific task例如序列標註、分類等,然後採用有標記的語料B來有監督地訓練模型,這個過程中語言模型的引數並不固定,依然是trainable variables。
2. BERT是如何解決一詞多義問題的?
所謂一詞多義,就是指相同的詞在不同上下文語境中有可能意思不同。例如"這個蘋果真好吃"和“今年蘋果手機又漲價了”,這其中的“蘋果”一詞代表的就是不同意思。而靜態詞向量如Word2Vec和GloVe,訓練好之後是透過查表(即look up)的方式取得對應的詞向量的,在這種情況下詞向量是固定的,因此不論上下文怎麼變化,使用的都是這個詞向量。
上文提到,BERT是動態詞向量,因此可以解決一詞多義的問題。這是因為對於某一個詞,BERT會讓其學習到上下文資訊並結合自身資訊,因此經過十二層encoder之後得到的詞向量就會根據上下文的不同而改變,這是多頭注意力機制的作用。
3. BERT的雙向體現在哪裡?
BERT的全稱是Bidirectional Encoder Representation of Transformer,其雙向就體現在encoder做self-attention操作時除了當前的詞/token以外,還同時使用了上下文的詞/token作為輸入,同時學習到了上文和下文的資訊,這也是MLM任務的作用。
4. BERT的引數量
此處以\(BERT_{BASE}\)為例
輸入部分的引數量:(30522+2+512)*768
中間層對於每一個encoder(算上bias):
- attention機制的引數=768*768/12*3*12(12個頭)+768/12*12*3
- 將每個頭拼接在一起並經過一個全連線層= 768/12*12*768+768
- LayerNorm層引數=768* +768
- 兩層前饋層=768*3072+3072+3072*\768+768
- LayerNorm層引數=768+768
中間層引數求和後乘以12,最終得到108890112,即約為110M。
5. BERT在預訓練時構造的樣本長度
為了不浪費算力同時也節省訓練時間,在預訓練階段,BERT在前90%的時間裡都將樣本長度設定為128,後10%的時間為了訓練位置編碼才設定為512。
6. BERT的每一層都學到了什麼?
關於這一點可以參考此文ACL 2019 | 理解BERT每一層都學到了什麼,原論文為What does BERT learn about the structure of language?。
7. 其他
關於其他細節,可以參考關於BERT中的那些為什麼。
參考文章
- BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
- 【譯】為什麼BERT有3個嵌入層,它們都是如何實現的
- The Illustrated Transformer
- 超詳細圖解Self-Attention
- This post is all you need(①多頭注意力機制原理)
- transformers庫中BertModel中的hidden_states元組的內容是如何排列的
- Pytorch-Bert預訓練模型的使用(呼叫transformers)
- 手把手教你用Pytorch-Transformers——部分原始碼解讀及相關說明(一)
- 簡單說明一下BERT模型相比ELMo模型有哪些優缺點?
- BERT引數量計算
- 關於BERT中的那些為什麼
- NLPer看過來,一些關於BERT的問題整理記錄
- BERT模型的損失函式怎麼定義的?
- 關於bert的輸出是什麼
- BERT模型返回值
- 理解tokenizer之WordPiece: Subword-based tokenization algorithm
- 為什麼bert的詞向量是動態的,與word2vec的區別是什麼?
- 淺談feature-based 和 fine-tune
- Question Answering with a Fine-Tuned BERT