Bert的原理及實現


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預訓練的具體任務:

一共是兩個任務:

  1. 漏字填空(完型填空),學術點的說法是 Masked Language Model
  2. 判斷第 2 個句子在原始本文中是否跟第 1 個句子相接(Next Sentence Prediction)

設計細節

BERT 語言模型任務一:Masked Language Model

在 BERT 中,Masked LM(Masked Language Model)構建了語言模型,簡單來說,就是隨機遮蓋或替換一句話里面的任意字或詞,然后讓模型通過上下文預測那一個被遮蓋或替換的部分,之后做 Loss 的時候也只計算被遮蓋部分的 Loss,這其實是一個很容易理解的任務,實際操作如下:

  1. 隨機把一句話中 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. 之后讓模型預測和還原被遮蓋掉或替換掉的部分,計算損失的時候,只計算在第 1 步里被隨機遮蓋或替換的部分,其余部分不做損失,其余部分無論輸出什么東西,都無所謂

這樣做的好處是,BERT 並不知道 [MASK] 替換的是哪一個詞,而且任何一個詞都有可能是被替換掉的,比如它看到的 apple 可能是被替換的詞。這樣強迫模型在編碼當前時刻詞的時候不能太依賴當前的詞,而要考慮它的上下文,甚至根據上下文進行 "糾錯"。比如上面的例子中,模型在編碼 apple 時,根據上下文 my dog is,應該把 apple 編碼成 hairy 的語義而不是 apple 的語義

BERT 語言模型任務二:Next Sentence Prediction

我們首先拿到屬於上下文的一對句子,也就是兩個句子,之后我們要在這兩個句子中加一些特殊的 token:[CLS]上一句話[SEP]下一句話[SEP]。也就是在句子開頭加一個 [CLS],在兩句話之間和句末加 [SEP],具體地如下圖所示

  1. 可以看到,上圖中的兩句話明顯是連續的。如果現在有這么一句話 [CLS]我的狗很可愛[SEP]企鵝不擅長飛行[SEP],可見這兩句話就不是連續的。在實際訓練中,我們會讓這兩種情況出現的數量為 1:1

  2. 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相比具有一些概率上的性質,具體的可以看開頭的一些參考:

\[\begin{gathered} \operatorname{GELU}(x)=x P(X \leq x)=x \Phi(x)=x \cdot \frac{1}{2}[1+\operatorname{erf}(x / \sqrt{2})] \\ \text { or } \\ 0.5 x\left(1+\tanh \left[\sqrt{2 / \pi}\left(x+0.044715 x^{3}\right)\right]\right) \end{gathered} \]

'''
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:

\[\text { Attention }(Q, K, V)=\operatorname{softmax}\left(\frac{Q K^{T}}{\sqrt{d_{k}}}\right) V \]

'''
計算上下文向量
這里要做的是,通過 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

\[\begin{aligned} \operatorname{MultiHead}(Q, K, V) &=\text { Concat }\left(\text { head }_{1}, \text { head }_{2}, \ldots, \text { head }_{h}\right) W^{O} \\ \text { where head }_{i} &=\text { Attention }\left(Q W_{i}^{Q}, K W_{i}^{K}, V W_{i}^{V}\right) \end{aligned} \]

'''
多頭注意力機制

完整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:

\[\operatorname{FFN}(x)=\operatorname{GELU}\left(x W_{1}+b_{1}\right) W_{2}+b_{2} \]

'''
前饋連接層
就是做兩次線性變換,與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 為了減少模型訓練上的負擔, 這里對poolerfc和MLM輸出時使用的fc做了權重共享, 也對Word Embedding和word_classifier的權重做了共享.

  • torch.gather能收集特定維度的指定位置的數值. h_masked使用的gather是為了檢索outputmax_len維度上被Mask的位置上的表示, 總大小[batch, max_pred, d_model]. 因為masked_pos大小為[batch, max_pred, d_model]. 可能我表述不太清楚, 請參照Pytorch之張量進階操作中的例子理解.

  • 沒在模型中使用SoftmaxSimgoid的原因是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 怎么辦,那不就矛盾了嗎?其實在某些訓練集里,有的問題就是沒有答案的,因此此時的預測搞不好是對的,就是沒有答案


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM