大語言模型底層架構丨帶你認識Transformer

2023-12-06 12:01:39

本文分享自華為雲社群《大語言模型底層架構你瞭解多少?大語言模型底層架構之一Transfomer的介紹和python程式碼實現》,作者: 碼上開花_Lancer 。

語言模型目標是建模自然語言的概率分佈,在自然語言處理研究中具有重要的作用,是自然語言處理基礎任務之一。大量的研究從n 元語言模型(n-gram Language Models)、神經語言模型(Neural Language Models,NLM)以及預訓練語言模型(Pre-trained Language Models,PLM)等不同角度開展了系列工作。這些研究在不同階段都對自然語言處理任務有著重要作用。隨著基於Transformer 各類語言模型的發展以及預訓練微調正規化在自然語言處理各類任務中取得突破性進展,從2020 年OpenAI 釋出GPT-3 開始,大語言模型研究也逐漸深入。雖然大語言模型的引數量巨大,通過有監督微調和強化學習能夠完成非常多的任務,但是其基礎理論也仍然離不開對語言的建模。

上篇文章介紹了大語言模型的發展史,本篇文章將首先介紹Transformer 結構,並在此基礎上後面會介紹生成式預訓練語言模型GPT、大語言模型網路結構和注意力機制優化以及相關實踐。

一、Transformer 模型

Transformer 模型是由谷歌在2017 年提出並首先應用於機器翻譯的神經網路模型結構。機器翻譯的目標是從源語言(Source Language)轉換到目標語言(Target Language)。Transformer 結構完全通過注意力機制完成對源語言序列和目標語言序列全域性依賴的建模。當前幾乎全部大語言模型都是基於Transformer 結構,本節以應用於機器翻譯的基於Transformer 的編碼器和解碼器介紹該模型。

基於Transformer 結構的編碼器和解碼器結構如圖1.1所示,左側和右側分別對應著編碼器(Encoder)和解碼器(Decoder)結構。它們均由若干個基本的Transformer 塊(Block)組成(對應著圖中的灰色框)。這裡N× 表示進行了N 次堆疊。每個Transformer 塊都接收一個向量序列 作為輸入,並輸出一個等長的向量序列作為輸出。這裡的xi 和yi 分別對應著文字序列中的一個單詞的表示。而yi 是當前Transformer 塊對輸入xi 進一步整合其上下文語意後對應的輸出。在從輸入 到輸出 的語意抽象過程中,主要涉及到如下幾個模組:

  • 注意力層:使用多頭注意力(Multi-Head Attention)機制整合上下文語意,它使得序列中任意兩個單詞之間的依賴關係可以直接被建模而不基於傳統的迴圈結構,從而更好地解決文字的長程依賴。
  • 位置感知前饋層(Position-wise FFN):通過全連線層對輸入文字序列中的每個單詞表示進行更復雜的變換。
  • 殘差連線:對應圖中的Add 部分。它是一條分別作用在上述兩個子層當中的直連通路,被用於連線它們的輸入與輸出。從而使得資訊流動更加高效,有利於模型的優化。
  • 層歸一化:對應圖中的Norm 部分。作用於上述兩個子層的輸出表示序列中,對錶示序列進行層歸一化操作,同樣起到穩定優化的作用。

圖1.1 基於Transformer 的編碼器和解碼器結構

接下來將依次介紹各個模組的具體功能和實現方法。

1.1 嵌入表示層

對於輸入文字序列,首先通過輸入嵌入層(Input Embedding)將每個單詞轉換為其相對應的向量表示。通常直接對每個單詞建立一個向量表示。由於Transfomer 模型不再使用基於迴圈的方式建模文字輸入,序列中不再有任何資訊能夠提示模型單詞之間的相對位置關係。在送入編碼器端建模其上下文語意之前,一個非常重要的操作是在詞嵌入中加入位置編碼(Positional Encoding)這一特徵。具體來說,序列中每一個單詞所在的位置都對應一個向量。這一向量會與單詞表示對應相加並送入到後續模組中做進一步處理。在訓練的過程當中,模型會自動地學習到如何利用這部分位置資訊。為了得到不同位置對應的編碼,Transformer 模型使用不同頻率的正餘弦函數如下所示:

其中,pos 表示單詞所在的位置,2i 和2i+1 表示位置編碼向量中的對應維度,d 則對應位置編碼的總維度。通過上面這種方式計算位置編碼有這樣幾個好處:首先,正餘弦函數的範圍是在[-1,+1],匯出的位置編碼與原詞嵌入相加不會使得結果偏離過遠而破壞原有單詞的語意資訊。

其次,依據三角函數的基本性質,可以得知第pos+k 個位置的編碼是第pos 個位置的編碼的線性組合,這就意味著位置編碼中蘊含著單詞之間的距離資訊。使用Pytorch 實現的位置編碼參考程式碼如下:

class PositionalEncoder(nn.Module):
  def __init__(self, d_model, max_seq_len = 80):
      super().__init__()
      self.d_model = d_model
   # 根據pos 和i 建立一個常數PE 矩陣
      pe = torch.zeros(max_seq_len, d_model)
     for pos in range(max_seq_len):
         for i in range(0, d_model, 2):
               pe[pos, i] = math.sin(pos / (10000 ** ((2 * i)/d_model)))
               pe[pos, i + 1] = math.cos(pos / (10000 ** ((2 * (i + 1))/d_model)))
      pe = pe.unsqueeze(0)
      self.register_buffer('pe', pe)
  def forward(self, x):
     # 使得單詞嵌入表示相對大一些
      x = x * math.sqrt(self.d_model)
      # 增加位置常數到單詞嵌入表示中
      seq_len = x.size(1)
      x = x + Variable(self.pe[:,:seq_len], requires_grad=False).cuda()

1.2 注意力層

自注意力(Self-Attention)操作是基於Transformer 的機器翻譯模型的基本操作,在源語言的編碼和目標語言的生成中頻繁地被使用以建模源語言、目標語言任意兩個單詞之間的依賴關係。給定由單詞語意嵌入及其位置編碼疊加得到的輸入表示{xi ∈ Rd}ti=1,為了實現對上下文語意依賴的建模,進一步引入在自注意力機制中涉及到的三個元素:查詢qi(Query),鍵ki(Key),值vi(Value)。在編碼輸入序列中每一個單詞的表示的過程中,這三個元素用於計算上下文單詞所對應的權重得分。直觀地說,這些權重反映了在編碼當前單詞的表示時,對於上下文不同部分所需要的關注程度。具體來說,如圖1.2所示,通過三個線性變換WQ ∈ Rd×dq,WK ∈ Rd×dk,WV ∈ Rd×dv將輸入序列中的每一個單詞表示xi 轉換為其對應的qi ∈ Rdk,ki ∈ Rdk,vi ∈ Rdv 向量。

1.2 自注意力機制中的查詢、鍵、值向量

為了得到編碼單詞xi 時所需要關注的上下文資訊,通過位置i 查詢向量與其他位置的鍵向量做點積得到匹配分數qi · k1, qi · k2, ..., qi · kt。為了防止過大的匹配分數在後續Softmax 計算過程中導致的梯度爆炸以及收斂效率差的問題,這些得分會除放縮因子√d 以穩定優化。放縮後的得分經過Softmax 歸一化為概率之後,與其他位置的值向量相乘來聚合希望關注的上下文資訊,並最小化不相關資訊的干擾。上述計算過程可以被形式化地表述如下:

其中Q ∈ RL×dq ,K ∈ RL×dk ,V ∈ Rd×dv 分別表示輸入序列中的不同單詞的q, k, v 向量拼接組成的矩陣,L 表示序列長度,Z ∈ RL×dv 表示自注意力操作的輸出。為了進一步增強自注意力機制聚合上下文資訊的能力,提出了多頭自注意力(Multi-head Attention)的機制,以關注上下文的不同側面。具體來說,上下文中每一個單詞的表示xi 經過多組線性{WQj WKj WVj}Nj=1 對映到不同的表示子空間中。公式會在不同的子空間中分別計算並得到不同的上下文相關的單詞序列表示{Zj}Nj=1。最終,線性變換WO ∈ R(Ndv)×d 用於綜合不同子空間中的上下文表示並形成自注意力層最終的輸出{xi ∈ Rd}ti=1。
使用Pytorch 實現的自注意力層參考程式碼如下:

class MultiHeadAttention(nn.Module):
    def __init__(self, heads, d_model, dropout = 0.1):
        super().__init__()
        self.d_model = d_model
        self.d_k = d_model // heads
        self.h = heads
        self.q_linear = nn.Linear(d_model, d_model)
        self.v_linear = nn.Linear(d_model, d_model)
        self.k_linear = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        self.out = nn.Linear(d_model, d_model)
   def attention(q, k, v, d_k, mask=None, dropout=None):
         scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)
         # 掩蓋掉那些為了填補長度增加的單元,使其通過softmax 計算後為0
        if mask is not None:
            mask = mask.unsqueeze(1)
            scores = scores.masked_fill(mask == 0, -1e9)
        scores = F.softmax(scores, dim=-1)
        if dropout is not None:
            scores = dropout(scores)
        output = torch.matmul(scores, v)
        return output
    def forward(self, q, k, v, mask=None):
         bs = q.size(0)
         # 進行線性操作劃分為成h 個頭
          k = self.k_linear(k).view(bs, -1, self.h, self.d_k)
          q = self.q_linear(q).view(bs, -1, self.h, self.d_k)
          v = self.v_linear(v).view(bs, -1, self.h, self.d_k)
          # 矩陣轉置
          k = k.transpose(1,2)
          q = q.transpose(1,2)
          v = v.transpose(1,2)
          # 計算attention
          scores = attention(q, k, v, self.d_k, mask, self.dropout)
          # 連線多個頭並輸入到最後的線性層
          concat = scores.transpose(1,2).contiguous().view(bs, -1, self.d_model)
          output = self.out(concat)
          return output

1.3 前饋層

前饋層接受自注意力子層的輸出作為輸入,並通過一個帶有Relu 啟用函數的兩層全連線網路對輸入進行更加複雜的非線性變換。實驗證明,這一非線性變換會對模型最終的效能產生十分重要的影響。

其中W1, b1,W2, b2 表示前饋子層的引數。實驗結果表明,增大前饋子層隱狀態的維度有利於提升最終翻譯結果的質量,因此,前饋子層隱狀態的維度一般比自注意力子層要大。

使用Pytorch 實現的前饋層參考程式碼如下:

class FeedForward(nn.Module):
     def __init__(self, d_model, d_ff=2048, dropout = 0.1):
         super().__init__()
         # d_ff 預設設定為2048
          self.linear_1 = nn.Linear(d_model, d_ff)
          self.dropout = nn.Dropout(dropout)
          self.linear_2 = nn.Linear(d_ff, d_model)
    def forward(self, x):
          x = self.dropout(F.relu(self.linear_1(x)))
          x = self.linear_2(x)

1.4 殘差連線與層歸一化

由Transformer 結構組成的網路結構通常都是非常龐大。編碼器和解碼器均由很多層基本的Transformer 塊組成,每一層當中都包含複雜的非線性對映,這就導致模型的訓練比較困難。

因此,研究者們在Transformer 塊中進一步引入了殘差連線與層歸一化技術以進一步提升訓練的穩定性。具體來說,殘差連線主要是指使用一條直連通道直接將對應子層的輸入連線到輸出上去,從而避免由於網路過深在優化過程中潛在的梯度消失問題:

其中表示第l 層的輸入,f(·) 表示一個對映函數。此外,為了進一步使得每一層的輸入輸出範圍穩定在一個合理的範圍內,層歸一化技術被進一步引入每個Transformer 塊的當中:

其中μ 和σ 分別表示均值和方差,用於將資料平移縮放到均值為0,方差為1 的標準分佈,α 和b是可學習的引數。層歸一化技術可以有效地緩解優化過程中潛在的不穩定、收斂速度慢等問題。

使用Pytorch 實現的層歸一化參考程式碼如下:

class NormLayer(nn.Module):
    def __init__(self, d_model, eps = 1e-6):
        super().__init__()
        self.size = d_model
        # 層歸一化包含兩個可以學習的引數
         self.alpha = nn.Parameter(torch.ones(self.size))
         self.bias = nn.Parameter(torch.zeros(self.size))
         self.eps = eps
    def forward(self, x):
        norm = self.alpha * (x - x.mean(dim=-1, keepdim=True)) \
        / (x.std(dim=-1, keepdim=True) + self.eps) + self.bias
       return norm

1.5 編碼器和解碼器結構

基於上述模組,根據圖1.1所給出的網路架構,編碼器端可以較為容易實現。相比於編碼器端,解碼器端要更復雜一些。具體來說,解碼器的每個Transformer 塊的第一個自注意力子層額外增加了注意力掩碼,對應圖中的掩碼多頭注意力(Masked Multi-Head Attention)部分。這主要是因為在翻譯的過程中,編碼器端主要用於編碼源語言序列的資訊,而這個序列是完全已知的,因而編碼器僅需要考慮如何融合上下文語意資訊即可。

而解碼端則負責生成目標語言序列,這一生成過程是自迴歸的,即對於每一個單詞的生成過程,僅有當前單詞之前的目標語言序列是可以被觀測的,因此這一額外增加的掩碼是用來掩蓋後續的文字資訊,以防模型在訓練階段直接看到後續的文字序列進而無法得到有效地訓練。此外,解碼器端還額外增加了一個多頭注意力(Multi-Head Attention)模組,使用交叉注意力(Cross-attention)方法,同時接收來自編碼器端的輸出以及當前Transformer 塊的前一個掩碼注意力層的輸出。查詢是通過解碼器前一層的輸出進行投影的,而鍵和值是使用編碼器的輸出進行投影的。

它的作用是在翻譯的過程當中,為了生成合理的目標語言序列需要觀測待翻譯的源語言序列是什麼。

基於上述的編碼器和解碼器結構,待翻譯的源語言文字,首先經過編碼器端的每個Transformer 塊對其上下文語意的層層抽象,最終輸出每一個源語言單詞上下文相關的表示。解碼器端以自迴歸的方式生成目標語言文字,即在每個時間步t,根據編碼器端輸出的源語言文字表示,以及前t − 1 個時刻生成的目標語言文字,生成當前時刻的目標語言單詞。使用Pytorch 實現的編碼器參考程式碼如下:

class EncoderLayer(nn.Module):
    def __init__(self, d_model, heads, dropout=0.1):
        super().__init__()
        self.norm_1 = Norm(d_model)
        self.norm_2 = Norm(d_model)
        self.attn = MultiHeadAttention(heads, d_model, dropout=dropout)
        self.ff = FeedForward(d_model, dropout=dropout)
        self.dropout_1 = nn.Dropout(dropout)
        self.dropout_2 = nn.Dropout(dropout)
    def forward(self, x, mask):
        x2 = self.norm_1(x)
        x = x + self.dropout_1(self.attn(x2,x2,x2,mask))
        x2 = self.norm_2(x)
        x = x + self.dropout_2(self.ff(x2))
        return x
class Encoder(nn.Module):
    def __init__(self, vocab_size, d_model, N, heads, dropout):
        super().__init__()
        self.N = N
        self.embed = Embedder(vocab_size, d_model)
        self.pe = PositionalEncoder(d_model, dropout=dropout)
        self.layers = get_clones(EncoderLayer(d_model, heads, dropout), N)
        self.norm = Norm(d_model)

    def forward(self, src, mask):
        x = self.embed(src)
        x = self.pe(x)
        for i in range(self.N):
        x = self.layers[i](x, mask)
        return self.norm(x)

使用Pytorch 實現的解碼器參考程式碼如下:

class DecoderLayer(nn.Module):
    def __init__(self, d_model, heads, dropout=0.1):
        super().__init__()
        self.norm_1 = Norm(d_model)
        self.norm_2 = Norm(d_model)
        self.norm_3 = Norm(d_model)
        self.dropout_1 = nn.Dropout(dropout)
        self.dropout_2 = nn.Dropout(dropout)
        self.dropout_3 = nn.Dropout(dropout)
        self.attn_1 = MultiHeadAttention(heads, d_model, dropout=dropout)
        self.attn_2 = MultiHeadAttention(heads, d_model, dropout=dropout)
        self.ff = FeedForward(d_model, dropout=dropout)
    def forward(self, x, e_outputs, src_mask, trg_mask):
        x2 = self.norm_1(x)
        x = x + self.dropout_1(self.attn_1(x2, x2, x2, trg_mask))
        x2 = self.norm_2(x)
        x = x + self.dropout_2(self.attn_2(x2, e_outputs, e_outputs, \
        src_mask))
        x2 = self.norm_3(x)
        x = x + self.dropout_3(self.ff(x2))
        return x
class Decoder(nn.Module):
    def __init__(self, vocab_size, d_model, N, heads, dropout):
        super().__init__()
        self.N = N
        self.embed = Embedder(vocab_size, d_model)
        self.pe = PositionalEncoder(d_model, dropout=dropout)
        self.layers = get_clones(DecoderLayer(d_model, heads, dropout), N)
        self.norm = Norm(d_model)
    def forward(self, trg, e_outputs, src_mask, trg_mask):
        x = self.embed(trg)
        x = self.pe(x)
        for i in range(self.N):
        x = self.layers[i](x, e_outputs, src_mask, trg_mask)
    return self.norm(x)

最終基於Transformer 的編碼器和解碼器結構整體實現參考程式碼如下:

class Transformer(nn.Module):
    def __init__(self, src_vocab, trg_vocab, d_model, N, heads, dropout):
        super().__init__()
        self.encoder = Encoder(src_vocab, d_model, N, heads, dropout)
        self.decoder = Decoder(trg_vocab, d_model, N, heads, dropout)
        self.out = nn.Linear(d_model, trg_vocab)
    def forward(self, src, trg, src_mask, trg_mask):
        e_outputs = self.encoder(src, src_mask)
        d_output = self.decoder(trg, e_outputs, src_mask, trg_mask)
        output = self.out(d_output)
        return output

基於上述模型結構,可以使用如下程式碼進行模型訓練和測試:

# 模型引數定義
d_model = 512
heads = 8
N = 6
src_vocab = len(EN_TEXT.vocab)
trg_vocab = len(FR_TEXT.vocab)
model = Transformer(src_vocab, trg_vocab, d_model, N, heads)
for p in model.parameters():
    if p.dim() > 1:
        nn.init.xavier_uniform_(p)
        optim = torch.optim.Adam(model.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9)
# 模型訓練
def train_model(epochs, print_every=100):
    model.train()
    start = time.time()
    temp = start
    total_loss = 0
    for epoch in range(epochs):
        for i, batch in enumerate(train_iter):
            src = batch.English.transpose(0,1)
            # the French sentence we input has all words except
            # the last, as it is using each word to predict the next
            trg_input = trg[:, :-1]
            # the words we are trying to predict
            targets = trg[:, 1:].contiguous().view(-1)
            # create function to make masks using mask code above
            src_mask, trg_mask = create_masks(src, trg_input)
            preds = model(src, trg_input, src_mask, trg_mask)
            optim.zero_grad()
            loss = F.cross_entropy(preds.view(-1, preds.size(-1)),
            results, ignore_index=target_pad)
            loss.backward()
            optim.step()
            total_loss += loss.data[0]
            if (i + 1) % print_every == 0:
                loss_avg = total_loss / print_every
                print("time = %dm, epoch %d, iter = %d, loss = %.3f,
                %ds per %d iters" % ((time.time() - start) // 60,
                epoch + 1, i + 1, loss_avg, time.time() - temp,
                print_every))
                total_loss = 0
                temp = time.time()
# 模型測試
def translate(model, src, max_len = 80, custom_string=False):
    model.eval()
    if custom_sentence == True:
        src = tokenize_en(src)
        sentence=Variable(torch.LongTensor([[EN_TEXT.vocab.stoi[tok] for tok in sentence]])).cuda()
    src_mask = (src != input_pad).unsqueeze(-2)
        e_outputs = model.encoder(src, src_mask)
        outputs = torch.zeros(max_len).type_as(src.data)
        outputs[0] = torch.LongTensor([FR_TEXT.vocab.stoi['<sos>']])
    for i in range(1, max_len):
        trg_mask = np.triu(np.ones((1, i, i),k=1).astype('uint8')
        trg_mask= Variable(torch.from_numpy(trg_mask) == 0).cuda()
        out = model.out(model.decoder(outputs[:i].unsqueeze(0),e_outputs, src_mask, trg_mask))
        out = F.softmax(out, dim=-1)
        val, ix = out[:, -1].data.topk(1)
        outputs[i] = ix[0][0]
        if ix[0][0] == FR_TEXT.vocab.stoi['<eos>']:
            break
    return ' '.join(
        [FR_TEXT.vocab.itos[ix] for ix in outputs[:i]]
    )

接下來將重點介紹GPT 無監督預訓練、有監督下游任務微調以及基於HuggingFace 的預訓練語言模型實踐。請持續關注,不要忘記點贊支援一下!!

文章參考連結:

點選關注,第一時間瞭解華為雲新鮮技術~