Bert實際上就是通過疊加多層transformer的encoder(transformer的介紹可以看我的這篇文章)通過兩個任務進行訓練的得到的。本文參考自BERT 的 PyTorch 實現,BERT 詳解.主要結合自己對代碼的一些理解融合成一篇以供學習。同時DaNing大佬的博客寫的比我好的多,大家可以直接點此查看。代碼可以看這里。
關於Bert的一些知識
在開始之前,首先介紹一下Bert的代碼實現涉及的知識以及關於Bert的相關問題解讀,便於讀者理解
准備
頭文件
'''
simple bert in pytorch
code by xyzhrrr
2012/12/27
'''
import re #正則表達式操作
import math
import torch
import numpy as np
from random import*
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as Data
數據集准備
與transformer相同,數據集是手動輸入了兩個人的對話,主要是為了降低代碼閱讀難度,我希望讀者能更關注模型實現的部分
'''
准備數據,為降低閱讀難度,手動輸入兩人對話作為數據
'''
text = (
'Hello, how are you? I am Romeo.\n' # R
'Hello, Romeo My name is Juliet. Nice to meet you.\n' # J
'Nice meet you too. How are you today?\n' # R
'Great. My baseball team won the competition.\n' # J
'Oh Congratulations, Juliet\n' # R
'Thank you Romeo\n' # J
'Where are you going today?\n' # R
'I am going shopping. What about you?\n' # J
'I am going to visit my grandmother. she is not very well' # R
)
sentences = re.sub("[.,!?\\-]", '', text.lower()).split('\n') # 將 '.' ',' '?' '!'以及‘-’全部替換為‘’,即將這些過濾掉
word_list=list(set(" ".join(sentences).split()))
## ['hello', 'how', 'are', 'you',...]獲取單詞list,set集合將自動將重復單詞去掉
word2idx={'[PAD]':0,'[CLS]':1,'[SEP]':2,'[MASK]':3}
#自左到右分別代表:填充、判斷符、分隔符、掩碼mask
for i,w in enumerate(word_list):
word2idx[w]=i+4#生成最終的詞字典,+4是從4開始作為單詞的idx
idx2word={i:w for i,w in enumerate(word2idx)}#idx轉回word的list
vocab_size=len(word2idx)
token_list=list()#存儲將原始數據轉后的值,里面每一行代表一句話
for sentence in sentences:
arr=[word2idx[s] for s in sentence.split()]
token_list.append(arr)
模型參數
- maxlen 表示同一個 batch 中的所有句子都由 30 個 token 組成,不夠的補 PAD(這里我實現的方式比較粗暴,直接固定所有 * batch 中的所有句子都為 30)
- max_pred 表示最多需要預測多少個單詞,即 BERT 中的完形填空任務最多MASK或替換多少單詞
- n_layers 表示 Encoder Layer 的數量
- d_model 表示 Token Embeddings、Segment Embeddings、Position Embeddings 的維度
- d_ff 表示 Encoder Layer 中全連接層的維度
- n_segments 表示 Decoder input 由幾句話組成
maxlen=30
batch_size=6
max_pred=5
n_layers=6
n_heads=12
d_model=768
d_ff=768*4
d_k = d_v = 64 # dimension of K(=Q), V
n_segments=2
數據預處理
需要根據概率隨機 make 或者替換(以下統稱 mask)一句話中 15% 的 token,還需要拼接任意兩句話這些 token 有 80% 的幾率被替換成 [MASK],有 10% 的幾率被替換成任意一個其它的 token,有 10% 的幾率原封不動.
BERT預訓練
我們首先介紹一下BERT預訓練的具體任務:
一共是兩個任務:
- 漏字填空(完型填空),學術點的說法是 Masked Language Model
- 判斷第 2 個句子在原始本文中是否跟第 1 個句子相接(Next Sentence Prediction)
設計細節
BERT 語言模型任務一:Masked Language Model
在 BERT 中,Masked LM(Masked Language Model)構建了語言模型,簡單來說,就是隨機遮蓋或替換一句話里面的任意字或詞,然后讓模型通過上下文預測那一個被遮蓋或替換的部分,之后做 Loss 的時候也只計算被遮蓋部分的 Loss,這其實是一個很容易理解的任務,實際操作如下:
- 隨機把一句話中 15% 的 token(字或詞)替換成以下內容:
- 這些 token 有 80% 的幾率被替換成 [MASK],例如 my dog is hairy→my dog is [MASK]
- 有 10% 的幾率被替換成任意一個其它的 token,例如 my dog is hairy→my dog is apple
- 有 10% 的幾率原封不動,例如 my dog is hairy→my dog is hairy
- 之后讓模型預測和還原被遮蓋掉或替換掉的部分,計算損失的時候,只計算在第 1 步里被隨機遮蓋或替換的部分,其余部分不做損失,其余部分無論輸出什么東西,都無所謂
這樣做的好處是,BERT 並不知道 [MASK] 替換的是哪一個詞,而且任何一個詞都有可能是被替換掉的,比如它看到的 apple 可能是被替換的詞。這樣強迫模型在編碼當前時刻詞的時候不能太依賴當前的詞,而要考慮它的上下文,甚至根據上下文進行 "糾錯"。比如上面的例子中,模型在編碼 apple 時,根據上下文 my dog is,應該把 apple 編碼成 hairy 的語義而不是 apple 的語義
BERT 語言模型任務二:Next Sentence Prediction
我們首先拿到屬於上下文的一對句子,也就是兩個句子,之后我們要在這兩個句子中加一些特殊的 token:[CLS]上一句話[SEP]下一句話[SEP]。也就是在句子開頭加一個 [CLS],在兩句話之間和句末加 [SEP],具體地如下圖所示
-
可以看到,上圖中的兩句話明顯是連續的。如果現在有這么一句話 [CLS]我的狗很可愛[SEP]企鵝不擅長飛行[SEP],可見這兩句話就不是連續的。在實際訓練中,我們會讓這兩種情況出現的數量為 1:1
-
Token Embedding
就是正常的詞向量,即 PyTorch 中的 nn.Embedding()
Segment Embedding
的作用是用 embedding 的信息讓模型分開上下句,我們給上句的 token 全 0,下句的 token 全 1,讓模型得以判斷上下句的起止位置,例如
[CLS]我的狗很可愛[SEP]企鵝不擅長飛行[SEP]
0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1
- position Embedding 和 Transformer 中的不一樣,不是三角函數,而是學習出來的.
代碼
- 代碼中,positive 變量代表兩句話是連續的個數,negative 代表兩句話不是連續的個數,我們需要做到在一個 batch 中,這兩個樣本的比例為 1:1。隨機選取的兩句話是否連續,只要通過判斷 tokens_a_index + 1 == tokens_b_index 即可.
-
然后是隨機 mask 一些 token,n_pred 變量代表的是即將 mask 的 token 數量,cand_maked_pos 代表的是有哪些位置是候選的、可以 mask 的(因為像 [SEP],[CLS] 這些不能做 mask,沒有意義),最后 shuffle() 一下,然后根據 random() 的值選擇是替換為 [MASK] 還是替換為其它的 token
-
接下來會做兩個 Zero Padding,第一個是為了補齊句子的長度,使得一個 batch 中的句子都是相同長度。第二個是為了補齊 mask 的數量,因為不同句子長度,會導致不同數量的單詞進行 mask,我們需要保證同一個 batch 中,mask 的數量(必須)是相同的,所以也需要在后面補一些沒有意義的東西,比方說 [0]
這就是整個數據預處理的部分
def make_data():#可以看到所有數據都是隨機采樣的,因此很可能有的數據沒有用到
batch=[]#存儲一個batch內的輸入
positive=negative=0
while positive !=batch_size/2 or negative!=batch_size/2:
#判斷條件是兩個句子連續與不連續的比例應為1:1
tokens_a_index,tokens_b_index=randrange(len(sentences)),randrange(len(sentences))
# sample random index in sentences,randrange隨機抽取一個數
tokens_a,tokens_b=token_list[tokens_a_index],token_list[tokens_b_index]
input_ids=[word2idx['[CLS]']]+tokens_a+[word2idx['[SEP]']]+tokens_b+[word2idx['[SEP]']]
#上面幾步隨機抽取幾個句子然后按照規范合成一個bert輸入,最后形成一個數字list數組
segment_ids=[0]*(1+len(tokens_a)+1)+[1]*(len(tokens_b)+1)
#這是生成段嵌入,將上下句分開,上一句以及[CLS]和第一個[SEP]都為0
#第二個句子以及最后一個[SEP]用1表示,最后生成一個[0,0,0,0...1,1,1]
n_pred=min(max_pred,max(1,int(len(input_ids)*0.15)))#隨機選15%的用作預測
#15 % of tokens in one sentence,n_pred 變量代表的是即將 mask 的 token 數量
cand_masked_pos=[i for i,token in enumerate(input_ids)
if token!=word2idx['[CLS]'] and token!= word2idx['[SEP]']] #candidate masked position
#選出候選的被替換或mask的位置,標記位不參與
shuffle(cand_masked_pos)#將候選位置打亂
masked_tokens,masked_pos=[],[]
for pos in cand_masked_pos[:n_pred]:
masked_pos.append(pos)#選定的要處理的token的位置。
masked_tokens.append(input_ids[pos])#存儲選定的token的數字表示
if random() <0.8: #80%被替換成 [MASK]
input_ids[pos]=word2idx['[MASK]']
elif random()>0.9: #有 10% 的幾率被替換成任意一個其它的 token,
index=randint(0,vocab_size-1)#隨機生成一個在詞表范圍內的id
while index<4: #不涉及'CLS', 'SEP', 'PAD'
index=randint(0,vocab_size-1)#重新生成
input_ids[pos]=index #替換
#剩下的10%不處理。
n_pad=maxlen-len(input_ids)
input_ids.extend([0]*n_pad)
segment_ids.extend([0]*n_pad)
#第一個是為了補齊句子的長度,使得一個 batch 中的句子都是相同長度。
if max_pred>n_pred: # Zero Padding (100% - 15%) tokens
'''
第二個是為了補齊 mask 的數量,因為不同句子長度,
會導致不同數量的單詞進行 mask,
我們需要保證同一個 batch 中,mask 的數量(必須)是相同的,
所以也需要在后面補一些沒有意義的東西,比方說 [0]
'''
n_pad=max_pred-n_pred
masked_tokens.extend([0]*n_pad)
masked_pos.extend([0]*n_pad)
#所以上面兩個的大小為[batch, max_pred]
'''
positive 變量代表兩句話是連續的個數,negative 代表兩句話不是連續的個數,
在一個 batch 中,這兩個樣本的比例為 1:1。
兩句話是否連續,只要通過判斷 tokens_a_index + 1 == tokens_b_index 即可
'''
if tokens_a_index+1 == tokens_b_index and positive<batch_size/2:
batch.append([input_ids,segment_ids,masked_tokens,masked_pos,True])
positive+=1 # IsNext
elif tokens_a_index+1 != tokens_b_index and negative<batch_size/2:
batch.append([input_ids,segment_ids,masked_tokens,masked_pos,False])
negative+=1 # NotNext
return batch
#獲取一個batch內的所有輸入,並轉換為tensor
#zip將batch按列解壓。所以例如input_ids會存儲那一列說有的batch中的inputs_ids,
# 即每一個是一個矩陣,每一行代表一個輸入的對應元素(也是一個list),一共batch_size行
'''
數據加載器
'''
class MyDataSet(Data.Dataset):
def __init__(self,input_ids,segment_ids,masked_tokens,masked_pos,isNext):
self.input_ids=input_ids
self.segment_ids = segment_ids
self.masked_tokens = masked_tokens
self.masked_pos = masked_pos
self.isNext = isNext
def __len__(self):
return len(self.input_ids)
def __getitem__(self, idx):#最好就用idx而不要用item否則可能會有一些問題
return self.input_ids[idx], self.segment_ids[idx], self.masked_tokens[idx], self.masked_pos[idx], self.isNext[idx]
模型構建
同時參考了DaNing
模型結構主要采用了 Transformer 的 Encoder,所以這里我不再多贅述,可以直接看這篇文章 Transformer 的 PyTorch 實現,以及B 站視頻講解.
Mask
'''
針對句子不夠長,加了 pad,因此需要對 pad 進行 mask
具體參考transformer實現的部分。
seq_q: [batch_size, seq_len]
seq_k: [batch_size, seq_len]
'''
def get_attn_pad_mask(seq_q,seq_k):
batch_size,seq_len=seq_q.size() # eq(zero) is PAD token
'''
是返回一個大小和 seq_k 一樣的 tensor,只不過里面的值只有 True 和 False。
如果 seq_q 某個位置的值等於 0,那么對應位置就是 True,否則即為 False。
'''
pad_attn_mask=seq_q.data.eq(0).unsqueeze(1)# [batch_size, 1, seq_len]
# unsqueeze(1)在1那個位置增加一個維度
return pad_attn_mask.expand(batch_size,seq_len,seq_len)
# [batch_size, seq_len, seq_len],
# #維度是這樣的,因為掩碼用在softmax之前,那他的維度就是Q*k.T的維度,而實際上len_q=len_k
Gelu
在BERT中采用GELU作為激活函數, 它與ReLU相比具有一些概率上的性質,具體的可以看開頭的一些參考:
'''
gelu 激活函數,具體看筆記
'''
def gelu(x):
'''
erf(x)就是對e^(-t^2)作0到x的積分。
'''
return x*0.5*(1.0+ torch.erf(x/math.sqrt(2.0)))
Embedding
BERT中含有三種編碼, Word Embedding, Position Embedding, Segment Embedding:
'''
構建embedding
可以看到這里的位置嵌入是通過學習得到的,具體輸入的是什么還是要看一下后續的代碼
對於具體的內容可以看一下bert的間隔及博客:
https://wmathor.com/index.php/archives/1456/
'''
class Embedding(nn.Module):
def __init__(self):
super(Embedding,self).__init__()
self.tok_embed=nn.Embedding(vocab_size,d_model)
# token embedding,定義一個具有vocab_size個單詞的維度為d_model的查詢矩陣
self.pos_embed=nn.Embedding(maxlen,d_model)# position embedding
self.seg_embed=nn.Embedding(n_segments,d_model) # segment(token type) embedding
self.norm=nn.LayerNorm(d_model)#定義一個歸一化層
def forward(self,x,seg):
seq_len=x.size(1)
pos=torch.arange(seq_len,dtype=torch.long)
pos=pos.unsqueeze(0).expand_as(x) # [seq_len] -> [batch_size, seq_len]
embedding=self.tok_embed(x)+self.pos_embed(pos)+self.seg_embed(seg)
return self.norm(embedding)
Attention
這里的點積縮放注意力和多頭注意力完全和Transformer一致, 不再細說, 直接照搬過來就行.
Scaled DotProduct Attention:
'''
計算上下文向量
這里要做的是,通過 Q 和 K 計算出 scores,
然后將 scores 和 V 相乘,得到每個單詞的 context vector
'''
class ScaleDotProductAttention(nn.Module):
def __init__(self):
super(ScaleDotProductAttention, self).__init__()
def forward(self,Q,K,V,attn_mask):
'''
Q: [batch_size, n_heads, len_q, d_k]
K: [batch_size, n_heads, len_k, d_k]
V: [batch_size, n_heads, len_v(=len_k), d_v]
attn_mask: [batch_size, n_heads, seq_len, seq_len]
'''
scores=torch.matmul(Q,K.transpose(-1,-2))/np.sqrt(d_k)
# scores : [batch_size, n_heads, seq_len, seq_len]
scores.masked_fill_(attn_mask,-1e9)
# masked_fill_()函數可以將attn_mask中為1(True,也就是填充0的部分)的位置填充為-1e9
# 相當於對填0的地方加上一個極小值以消除在計算attention時softmax時的影響。
attn=nn.Softmax(dim=-1)(scores)
# 對行進行softmax,每一行其實就是求一個字對應的注意力權重,可以看博客
context=torch.matmul(attn,V)
# [batch_size, n_heads, len_q, d_v]
return context
Multi - Head Attention
'''
多頭注意力機制
完整transformer代碼中一定會有三處地方調用 MultiHeadAttention(),Encoder Layer 調用一次,
傳入的 input_Q、input_K、input_V 全部都是 encoder_inputs;
Decoder Layer 中兩次調用,第一次傳入的全是 decoder_inputs,
第二次傳入的分別是 decoder_outputs,encoder_outputs,encoder_outputs
'''
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
self.W_Q=nn.Linear(d_model,d_k*n_heads)
#輸入維度為embedding維度,輸出維度為Q(=K的維度)的維度*頭數,
# bias為False就是不要學習偏差,只更新權重即可(計算的就是權重)
self.W_K=nn.Linear(d_model,d_k*n_heads)
self.W_V=nn.Linear(d_model,d_v*n_heads)
self.fc=nn.Linear(n_heads*d_v,d_model)
#通過一個全連接層將維度轉為embedding維度好判斷預測結果
def forward(self,input_Q,input_K,input_V,attn_mask):
'''
input_Q: [batch_size, len_q, d_model]
input_K: [batch_size, len_k, d_model]
input_V: [batch_size, len_v(=len_k), d_model]
attn_mask: [batch_size, seq_len, seq_len]
'''
residual,batch_size=input_Q,input_Q.size(0)
#residual,剩余的,用於后續殘差計算,這里的input的一樣,這里沒有position嵌入
# (B, S, D) -proj-> (B, S, D_new) -split-> (B, S, H, W) -trans-> (B, H, S, W)
Q=self.W_Q(input_Q).view(batch_size,-1,n_heads,d_k).transpose(1,2)
# Q: [batch_size, n_heads, len_q, d_k],-1就是在求長度
#其實self.W_Q就是一個線性層,輸入的時input_Q,然后對輸出進行變形,
# 這也是linear的特點,即只需要最后一個滿足維度就可以即[batch_size,size]中的size
K=self.W_K(input_K).view(batch_size,-1,n_heads,d_k).transpose(1,2)
# K: [batch_size, n_heads, len_k, d_k]
V=self.W_V(input_V).view(batch_size,-1,n_heads,d_v).transpose(1,2)
# V: [batch_size, n_heads, len_v(=len_k), d_v]
'''
我們知道為了能夠計算上下文context我們需要len_v==len_k,這就要求d_v=d_k
所以實際上Q、K、V的維度都是相同的
我猜測這里僅將Q、K一起表示是為了便於管理參與加權計算的和不參與的。
'''
attn_mask=attn_mask.unsqueeze(1).repeat(1,n_heads,1,1)
# attn_mask : [batch_size, n_heads, seq_len, seq_len]
#根據生成attn_mask的函數生成的大小應該為# [batch_size, len_q, len_k]
#所以顯示增加了一個1個列的維度變為[batch_size, 1,len_q, len_k]在通過repeat變為上面結果
context=ScaleDotProductAttention()(Q,K,V,attn_mask)
#這種輸入形式是為了將參數傳送到forward,若在括號里則傳給init了。
context=context.transpose(1,2).reshape(batch_size,-1,n_heads*d_v)
# [batch_size, n_heads, len_q, d_v]->[batch_size, len_q, n_heads * d_v],為了最后一個維度符合全連接層的輸入
output=self.fc(context)# [batch_size, len_q, d_model]
return nn.LayerNorm(d_model)(output+residual)#可以看linear的實現就明白了
#最后進行殘差運算以及通過LayerNorm把神經網絡中隱藏層歸一為標准正態分布,也就是獨立同分布以起到加快訓練速度,加速收斂的作用
#殘差連接實際上是為了防止防止梯度消失,幫助深層網絡訓練
Feed Forward Neural Network
BERT中的FFN實現將激活函數換為了GELU:
'''
前饋連接層
就是做兩次線性變換,與transformer不同,本處使用了bert提出的gelu()激活函數
需要注意,每個 Encoder Block 中的 FeedForward 層權重都是共享的
'''
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
super(PoswiseFeedForwardNet, self).__init__()
self.fc1=nn.Linear(d_model,d_ff) #d_ff全連接層維度
self.fc2= nn.Linear(d_ff,d_model)
#先映射到高維在回到低維以學習更多的信息
def forward(self,x):
'''
x: [batch_size, seq_len, d_model]
'''
residual=x
output=self.fc2(gelu(self.fc1(x)))
# [batch_size, seq_len, d_model]
return nn.LayerNorm(d_model)(output+residual)
#這里與參考博客給的不一樣,我自己加上了殘差和layernorm,要了解二者的作用
#可以參考transformer中layernorm的作用,https://blog.csdn.net/weixin_42399993/article/details/121585747
#但是如果實際用也可以加上試試
Encoder
'''
encoder layer
就是將上述組件拼起來
'''
class EncoderLayer(nn.Module):
def __init__(self):
super(EncoderLayer, self).__init__()
self.enc_self_attn=MultiHeadAttention()#多頭注意力層
self.pos_ffn=PoswiseFeedForwardNet()#前饋層,注意殘差以及歸一化已經在各自層內實現
def forward(self,enc_inouts,enc_self_attn_mask):
'''
enc_inputs: [batch_size, src_len, d_model]
nc_self_attn_mask: [batch_size, src_len, src_len]
'''
enc_outputs=self.enc_self_attn(enc_inouts,enc_inouts,enc_inouts,enc_self_attn_mask)
#三個inputs對應了input_Q\K\V.attn其實就是softmax后沒有乘以V之前的值。
enc_outputs=self.pos_ffn(enc_outputs)# enc_outputs: [batch_size, src_len, d_model]
return enc_outputs
Bert
-
首先介紹一下池化層pooler:Pooler是
Hugging Face
實現BERT時加上的額外組件. NSP任務需要提取[CLS]
處的特征, Hugging Face的做法是將[CLS]處的輸出接上一個FC, 並用tanh激活, 最后再接上二分類輸出層. 他們將這一過程稱為”Pool“.因為額外添加了一個FC層, 所以能增強表達能力, 同樣提升了訓練難度. -
現在大框架中的Embedding, EncoderLayer, Pooler已經定義好了, 只需要額外定義輸出時需要的其他組件. 在NSP任務輸出時需要額外定義一個二分類輸出層
next_cls
, 還有MLM任務輸出所需的word_classifier
, 以及前向傳遞forward
.
class Bert(nn.Module):
def __init__(self):
super(Bert, self).__init__()
self.embedding=Embedding()#返回的是三個嵌入的合,看函數實現即可
self.layers=nn.ModuleList([EncoderLayer() for _ in range(n_layers)])
self.fc=nn.Sequential(
nn.Linear(d_model,d_model),
nn.Dropout(0.5),
nn.Tanh(),
)
'''
用作池化pooler,詳見 https://adaning.github.io/posts/52648.html
這里的作用我猜測:全連接層用來獲取更多特征,dropout防止過擬合,tanh激活函數引入非線性因素
'''
self.classifier=nn.Linear(d_model,2)#這是用作判斷是否是相鄰句子的一個二分類
self.linear=nn.Linear(d_model,d_model)
self.gelu=gelu #這里沒有括號是將activ2初始化為這個函數,加上括號就是引用了,返回的是函數的結果
# 下面三行實現對Word Embedding和word_classifier的權重共享
embed_weight=self.embedding.tok_embed.weight
self.fc2=nn.Linear(d_model,vocab_size,bias=False)#判斷填空的詞為什么的分類器
self.fc2.weight=embed_weight
def forward(self,input_ids,segment_ids,masked_pos):
output=self.embedding(input_ids,segment_ids)#返回 embedding
# [bach_size, seq_len, d_model]
enc_self_attn_mask=get_attn_pad_mask(input_ids,input_ids)#獲得mask
# [batch_size, maxlen, maxlen]
for layer in self.layers:
# output: [batch_size, max_len, d_model]
output=layer(output,enc_self_attn_mask)
h_pooled=self.fc(output[:,0])# [batch_size, d_model]
logits_clsf=self.classifier(h_pooled)
# [batch_size, 2] predict isNext
# 上兩行即是池化過程(兩句是否相鄰任務),將[CLS]作為輸入,通過接上一個FC, 並用tanh激活,
# 最后再接上二分類輸出層,因為額外添加了一個FC層, 所以能增強表達能力, 同樣提升了訓練難度.
'''
# Masked Language Model Task
# masked_pos: [batch, max_pred] -> [batch, max_pred, d_model]
'''
masked_pos=masked_pos.unsqueeze(-1).expand(-1, -1, d_model)
# [batch_size, max_pred, d_model]
h_masked=torch.gather(output,1,masked_pos) # masking position [batch_size, max_pred, d_model]
'''
torch.gather能收集特定維度的指定位置的數值,它的作用是將查找被處理的token的位置,
並按照token的處理順尋的位置重新排序embedding,總大小[batch, max_pred, d_model].
因為masked_pos大小為[batch, max_pred, d_model],所以embedding應該被裁剪了。
簡單來說是為了檢索output中 seq_len維度上被Mask的位置上的表示,
我們只選擇被處理的位置的內容是因為bert計算這里的損失的時候,只計算在第 1 步里被隨機遮蓋或替換的部分
這是可以的,因為是由上下文句子的,只是判斷單詞而已,所以其余的沒有什么作用,或者說作用比較小。
'''
h_masked=self.gelu(self.linear(h_masked))#與plooer的原理相同
# [batch_size, max_pred, d_model]
logits_lm=self.fc2(h_masked) # [batch_size, max_pred, vocab_size],預測的單詞
return logits_lm,logits_clsf
8 為了減少模型訓練上的負擔, 這里對pooler
的fc
和MLM輸出時使用的fc
做了權重共享, 也對Word Embedding和word_classifier的權重做了共享.
-
torch.gather
能收集特定維度的指定位置的數值.h_masked
使用的gather
是為了檢索output
中max_len
維度上被Mask的位置上的表示, 總大小[batch, max_pred, d_model]
. 因為masked_pos
大小為[batch, max_pred, d_model]
. 可能我表述不太清楚, 請參照Pytorch之張量進階操作中的例子理解. -
沒在模型中使用
Softmax
和Simgoid
的原因是nn.CrossEntropyLoss
自帶將Logits轉為概率的效果
訓練
下面是訓練代碼, 沒有什么值得注意的地方.
model=Bert()
criterion=nn.CrossEntropyLoss()
optimizer=optim.Adadelta(model.parameters(),lr=0.001)
batch=make_data()
input_ids, segment_ids, masked_tokens, masked_pos, isNext = zip(*batch)#將batch內的各元素解壓
input_ids2, segment_ids2, masked_tokens2, masked_pos2, isNext2 = \
torch.LongTensor(input_ids), torch.LongTensor(segment_ids), torch.LongTensor(masked_tokens),\
torch.LongTensor(masked_pos), torch.LongTensor(isNext)
'''
batch_tensor = [torch.LongTensor(ele) for ele in zip(*batch)]
'''
loader=Data.DataLoader(MyDataSet(input_ids2, segment_ids2, masked_tokens2, masked_pos2, isNext2), batch_size,True)
'''
訓練代碼
'''
for epoch in range(180):
for input_ids, segment_ids, masked_tokens, masked_pos, isNext in loader:
# =[ele for ele in one_batch]
logits_lm,logits_clsf=model(input_ids,segment_ids,masked_pos)
# for masked LM
loss_lm=criterion(logits_lm.view(-1,vocab_size),masked_tokens.view(-1))#計算損失函數
loss_lm=(loss_lm.float()).mean()
# for sentence classification
loss_clsf=criterion(logits_clsf,isNext)
loss=loss_clsf+loss_lm
if (epoch+1)%10==0:
print('Epoch:', '%04d' % (epoch + 1), 'loss =', '{:.6f}'.format(loss))
optimizer.zero_grad()
loss.backward()
optimizer.step()
測試
這里只采用了單個樣本模擬Evaluation的過程.
'''
測試代碼
'''
# Predict mask tokens ans isNext
input_ids,segment_ids,masked_tokens,masked_pos,isNext=batch[0]
print(text)
print([idx2word[w] for w in input_ids if idx2word[w] != '[PAD]'])
logits_lm,logits_clsf=model(torch.LongTensor([input_ids]),
torch.LongTensor([segment_ids]),
torch.LongTensor([masked_pos]))
logits_lm=logits_lm.data.max(2)[1][0].data.numpy()
print('masked tokens list : ',[pos for pos in masked_tokens if pos != 0])
print('predict masked tokens list : ',[pos for pos in logits_lm if pos != 0])
logits_clsf = logits_clsf.data.max(1)[1].data.numpy()[0]
print('isNext : ', True if isNext else False)
print('predict isNext : ',True if logits_clsf else False)
Fine-Tuning
參考自wmathor
-
BERT 的 Fine-Tuning 共分為 4 中類型,以下內容、圖片均來自台大李宏毅老師 Machine Learning 課程(以下內容 圖在上,解釋在下)
-
如果現在的任務是 classification,首先在輸入句子的開頭加一個代表分類的符號 [CLS],然后將該位置的 output,丟給 Linear Classifier,讓其 predict 一個 class 即可。整個過程中 Linear Classifier 的參數是需要從頭開始學習的,而 BERT 中的參數微調就可以了
這里李宏毅老師有一點沒講到,就是為什么要用第一個位置,即 [CLS] 位置的 output。這里我看了網上的一些博客,結合自己的理解解釋一下。因為 BERT 內部是 Transformer,而 Transformer 內部又是 Self-Attention,所以 [CLS] 的 output 里面肯定含有整句話的完整信息,這是毋庸置疑的。但是 Self-Attention 向量中,自己和自己的值其實是占大頭的,現在假設使用 的 output 做分類,那么這個 output 中實際上會更加看重 ,而 又是一個有實際意義的字或詞,這樣難免會影響到最終的結果。但是 [CLS] 是沒有任何實際意義的,只是一個占位符而已,所以就算 [CLS] 的 output 中自己的值占大頭也無所謂。當然你也可以將所有詞的 output 進行 concat,作為最終的 output.
- 如果現在的任務是 Slot Filling,將句子中各個字對應位置的 output 分別送入不同的 Linear,預測出該字的標簽。其實這本質上還是個分類問題,只不過是對每個字都要預測一個類別
- 如果現在的任務是 NLI(自然語言推理)。即給定一個前提,然后給出一個假設,模型要判斷出這個假設是 正確、錯誤還是不知道。這本質上是一個三分類的問題,和 Case 1 差不多,對 [CLS] 的 output 進行預測即可
- 如果現在的任務是 QA(問答),舉例來說,如上圖,將一篇文章,和一個問題(這里的例子比較簡單,答案一定會出現在文章中)送入模型中,模型會輸出兩個數 s,e,這兩個數表示,這個問題的答案,落在文章的第 s 個詞到第 e 個詞。具體流程我們可以看下面這幅圖
- 首先將問題和文章通過 [SEP] 分隔,送入 BERT 之后,得到上圖中黃色的輸出。此時我們還要訓練兩個 vector,即上圖中橙色和黃色的向量。首先將橙色和所有的黃色向量進行 dot product,然后通過 softmax,看哪一個輸出的值最大,例如上圖中 對應的輸出概率最大,那我們就認為 s=2
- 同樣地,我們用藍色的向量和所有黃色向量進行 dot product,最終預測得 的概率最大,因此 e=3。最終,答案就是 s=2,e=3
你可能會覺得這里面有個問題,假設最終的輸出 s>e 怎么辦,那不就矛盾了嗎?其實在某些訓練集里,有的問題就是沒有答案的,因此此時的預測搞不好是對的,就是沒有答案