保姆級教程:圖解Transformer

本文 GitHub //github.com/Jack-Cherish/PythonPark 已收錄,有技術乾貨文章,整理的學習資料,一線大廠面試經驗分享等,歡迎 Star 和 完善。

一、前言

大家好,我是 Jack。

本文是圖解 AI 演算法系列教程的第二篇,今天的主角是 Transformer

Transformer 可以做很多有趣而又有意義的事情。

比如我寫過的《用自己訓練的AI玩王者榮耀是什麼體驗?》。

再比如 OpenAIDALL·E,可以魔法一般地按照自然語言文字描述直接生成對應圖片!

輸入文本:鱷梨形狀的扶手椅。

AI 生成的影像:
在這裡插入圖片描述

兩者都是多模態的應用,這也是各大巨頭的跟進方向,可謂大勢所趨

Transformer 最初主要應用於一些自然語言處理場景,比如翻譯、文本分類、寫小說、寫歌等。

隨著技術的發展,Transformer 開始征戰視覺領域,分類、檢測等任務均不在話下,逐漸走上了多模態的道路。


Transformer 近兩年非常火爆,內容也很多,要想講清楚,還涉及一些基於該結構的預訓練模型,例如著名的 BERTGPT,以及剛出的 DALL·E 等。

它們都是基於 Transformer 的上層應用,因為 Transformer 很難訓練,巨頭們就肩負起了造福大眾的使命,開源了各種好用的預訓練模型

我們都是站在巨人肩膀上學習,用開源的預訓練模型在一些特定的應用場景進行遷移學習

篇幅有限,本文先講解 Transformer 的基礎原理,希望每個人都可以看懂。

後面我會繼續寫 BERTGPT 等內容,更新可能慢一些,但是跟著學,絕對都能有所收穫。

還是那句話:如果你喜歡這個 AI 演算法系列教程,一定要讓我知道,轉發在看支援,更文更有動力!

二、Transformer

TransformerGoogle2017 年提出的用於機器翻譯的模型。
在這裡插入圖片描述
Transformer 的內部,在本質上是一個 Encoder-Decoder 的結構,即 編碼器-解碼器
在這裡插入圖片描述Transformer 中拋棄了傳統的 CNNRNN,整個網路結構完全由 Attention 機制組成,並且採用了 6Encoder-Decoder 結構。

顯然,Transformer 主要分為兩大部分,分別是編碼器解碼器

整個 Transformer 是由 6 個這樣的結構組成,為了方便理解,我們只看其中一個Encoder-Decoder 結構。

以一個簡單的例子進行說明:


Why do we work?,我們為什麼工作?

左側紅框是編碼器,右側紅框是解碼器

編碼器負責把自然語言序列映射成為隱藏層(上圖第2步),即含有自然語言序列的數學表達。

解碼器把隱藏層再映射為自然語言序列,從而使我們可以解決各種問題,如情感分析、機器翻譯、摘要生成、語義關係抽取等。

簡單說下,上圖每一步都做了什麼:

  • 輸入自然語言序列到編碼器: Why do we work?(為什麼要工作);
  • 編碼器輸出的隱藏層,再輸入到解碼器;
  • 輸入 <𝑠𝑡𝑎𝑟𝑡> (起始)符號到解碼器;
  • 解碼器得到第一個字”為”;
  • 將得到的第一個字”為”落下來再輸入到解碼器;
  • 解碼器得到第二個字”什”;
  • 將得到的第二字再落下來,直到解碼器輸出 <𝑒𝑛𝑑> (終止符),即序列生成完成。

解碼器和編碼器的結構類似,本文以編碼器部分進行講解。即把自然語言序列映射為隱藏層的數學表達的過程,因為理解了編碼器中的結構,理解解碼器就非常簡單了。

為了方便學習,我將編碼器分為 4 個部分,依次講解。
在這裡插入圖片描述

1、位置嵌入(𝑝𝑜𝑠𝑖𝑡𝑖𝑜𝑛𝑎𝑙 𝑒𝑛𝑐𝑜𝑑𝑖𝑛𝑔)

我們輸入數據 X 維度為[batch size, sequence length]的數據,比如我們為什麼工作

batch size 就是 batch 的大小,這裡只有一句話,所以 batch size1sequence length 是句子的長度,一共 7 個字,所以輸入的數據維度是 [1, 7]

我們不能直接將這句話輸入到編碼器中,因為 Tranformer 不認識,我們需要先進行字嵌入,即得到圖中的 X_{\text {embedding }}

簡單點說,就是文字->字向量的轉換,這種轉換是將文字轉換為電腦認識的數學表示,用到的方法就是 Word2VecWord2Vec 的具體細節,對於初學者暫且不用了解,這個是可以直接使用的。

得到的 X_{\text {embedding }} 的維度是 [batch size, sequence length, embedding dimension]embedding dimension 的大小由 Word2Vec 演算法決定,Tranformer 採用 512 長度的字向量。所以 X_{\text {embedding }} 的維度是 [1, 7, 512]

至此,輸入的我們為什麼工作,可以用一個矩陣來簡化表示。
在這裡插入圖片描述
我們知道,文字的先後順序,很重要。

比如吃飯沒沒吃飯沒飯吃飯吃沒飯沒吃,同樣三個字,順序顛倒,所表達的含義就不同了。

文字的位置資訊很重要,Tranformer 沒有類似 RNN 的循環結構,沒有捕捉順序序列的能力。

為了保留這種位置資訊交給 Tranformer 學習,我們需要用到位置嵌入

加入位置資訊的方式非常多,最簡單的可以是直接將絕對坐標 0,1,2 編碼。

Tranformer 採用的是 sin-cos 規則,使用了 sincos 函數的線性變換來提供給模型位置資訊:

\begin{aligned} P E_{(p o s, 2 i)} &=\sin \left(p o s / 10000^{2 i / d_{\text {model }}}\right) \\ P E_{(\text {pos }, 2 i+1)} &=\cos \left(\text { pos } / 10000^{2 i / d_{\text {model }}}\right) \end{aligned}

上式中 pos 指的是句中字的位置,取值範圍是 [0, 𝑚𝑎𝑥 𝑠𝑒𝑞𝑢𝑒𝑛𝑐𝑒 𝑙𝑒𝑛𝑔𝑡ℎ)i 指的是字嵌入的維度, 取值範圍是 [0, 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛)d_{\text {model }} 就是 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛 的大小。

上面有 sincos 一組公式,也就是對應著 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛 維度的一組奇數和偶數的序號的維度,從而產生不同的周期性變化。

可以用程式碼,簡單看下效果。

# 導入依賴庫
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import math

def get_positional_encoding(max_seq_len, embed_dim):
    # 初始化一個positional encoding
    # embed_dim: 字嵌入的維度
    # max_seq_len: 最大的序列長度
    positional_encoding = np.array([
        [pos / np.power(10000, 2 * i / embed_dim) for i in range(embed_dim)]
        if pos != 0 else np.zeros(embed_dim) for pos in range(max_seq_len)])
    positional_encoding[1:, 0::2] = np.sin(positional_encoding[1:, 0::2])  # dim 2i 偶數
    positional_encoding[1:, 1::2] = np.cos(positional_encoding[1:, 1::2])  # dim 2i+1 奇數
    # 歸一化, 用位置嵌入的每一行除以它的模長
    # denominator = np.sqrt(np.sum(position_enc**2, axis=1, keepdims=True))
    # position_enc = position_enc / (denominator + 1e-8)
    return positional_encoding
    
positional_encoding = get_positional_encoding(max_seq_len=100, embed_dim=16)
plt.figure(figsize=(10,10))
sns.heatmap(positional_encoding)
plt.title("Sinusoidal Function")
plt.xlabel("hidden dimension")
plt.ylabel("sequence length")

可以看到,位置嵌入在 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛 (也是hidden dimension )維度上隨著維度序號增大,周期變化會越來越慢,而產生一種包含位置資訊的紋理。
在這裡插入圖片描述
就這樣,產生獨一的紋理位置資訊,模型從而學到位置之間的依賴關係和自然語言的時序特性。

最後,將 X_{\text {embedding }}位置嵌入 相加,送給下一層。

2、自注意力層(𝑠𝑒𝑙𝑓 𝑎𝑡𝑡𝑒𝑛𝑡𝑖𝑜𝑛 𝑚𝑒𝑐ℎ𝑎𝑛𝑖𝑠𝑚)

直接看下圖筆記,講解的非常詳細。
在這裡插入圖片描述
多頭的意義在於,Q K^{T} 得到的矩陣就叫注意力矩陣,它可以表示每個字與其他字的相似程度。因為,向量的點積值越大,說明兩個向量越接近。
在這裡插入圖片描述
我們的目的是,讓每個字都含有當前這個句子中的所有字的資訊,用注意力層,我們做到了。

需要注意的是,在上面 𝑠𝑒𝑙𝑓 𝑎𝑡𝑡𝑒𝑛𝑡𝑖𝑜𝑛 的計算過程中,我們通常使用 𝑚𝑖𝑛𝑖 𝑏𝑎𝑡𝑐ℎ,也就是一次計算多句話,上文舉例只用了一個句子。

每個句子的長度是不一樣的,需要按照最長的句子的長度統一處理。對於短的句子,進行 Padding 操作,一般我們用 0 來進行填充。
在這裡插入圖片描述

3、殘差鏈接和層歸一化

加入了殘差設計和層歸一化操作,目的是為了防止梯度消失,加快收斂。

1) 殘差設計

我們在上一步得到了經過注意力矩陣加權之後的 𝑉, 也就是 𝐴𝑡𝑡𝑒𝑛𝑡𝑖𝑜𝑛(𝑄, 𝐾, 𝑉),我們對它進行一下轉置,使其和 𝑋𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 的維度一致, 也就是 [𝑏𝑎𝑡𝑐ℎ 𝑠𝑖𝑧𝑒, 𝑠𝑒𝑞𝑢𝑒𝑛𝑐𝑒 𝑙𝑒𝑛𝑔𝑡ℎ, 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛] ,然後把他們加起來做殘差連接,直接進行元素相加,因為他們的維度一致:

X_{embedding} + Attention(Q, \ K, \ V)$$

在之後的運算里,每經過一個模組的運算,都要把運算之前的值和運算之後的值相加,從而得到殘差連接,訓練的時候可以使梯度直接走捷徑反傳到最初始層:

$$X + SubLayer(X) $$

#### 2) 層歸一化

作用是把神經網路中隱藏層歸一為標準正態分布,也就是 `𝑖.𝑖.𝑑` 獨立同分布, 以起到加快訓練速度, 加速收斂的作用。

$$\mu_{i}=\frac{1}{m} \sum^{m}_{i=1}x_{ij}$$

上式中以矩陣的行 (𝑟𝑜𝑤) 為單位求均值:

$$\sigma^{2}_{j}=\frac{1}{m} \sum^{m}_{i=1}
(x_{ij}-\mu_{j})^{2}$$

上式中以矩陣的行 (𝑟𝑜𝑤) 為單位求方差:

$$LayerNorm(x)=\alpha \odot \frac{x_{ij}-\mu_{i}}
{\sqrt{\sigma^{2}_{i}+\epsilon}} + \beta $$

然後用**每一行**的**每一個元素**減去**這行的均值**,再除以**這行的標準差**,從而得到歸一化後的數值,$\epsilon$是為了防止除$0$;

之後引入兩個可訓練參數$\alpha, \ \beta$來彌補歸一化的過程中損失掉的資訊,注意$\odot$表示元素相乘而不是點積,我們一般初始化$\alpha$為全$1$,而$\beta$為全$0$。

程式碼層面非常簡單,單頭 `attention` 操作如下:

“`python
class ScaledDotProductAttention(nn.Module):
”’ Scaled Dot-Product Attention ”’

def __init__(self, temperature, attn_dropout=0.1):
super().__init__()
self.temperature = temperature
self.dropout = nn.Dropout(attn_dropout)

def forward(self, q, k, v, mask=None):
# self.temperature是論文中的d_k ** 0.5,防止梯度過大
# QxK/sqrt(dk)
attn = torch.matmul(q / self.temperature, k.transpose(2, 3))

if mask is not None:
# 屏蔽不想要的輸出
attn = attn.masked_fill(mask == 0, -1e9)
# softmax+dropout
attn = self.dropout(F.softmax(attn, dim=-1))
# 概率分布xV
output = torch.matmul(attn, v)

return output, attn
“`

`Multi-Head Attention` 實現在 `ScaledDotProductAttention` 基礎上構建:

“`python
class MultiHeadAttention(nn.Module):
”’ Multi-Head Attention module ”’

# n_head頭的個數,默認是8
# d_model編碼向量長度,例如本文說的512
# d_k, d_v的值一般會設置為 n_head * d_k=d_model,
# 此時concat後正好和原始輸入一樣,當然不相同也可以,因為後面有fc層
# 相當於將可學習矩陣分成獨立的n_head份
def __init__(self, n_head, d_model, d_k, d_v, dropout=0.1):
super().__init__()
# 假設n_head=8,d_k=64
self.n_head = n_head
self.d_k = d_k
self.d_v = d_v
# d_model輸入向量,n_head * d_k輸出向量
# 可學習W^Q,W^K,W^V矩陣參數初始化
self.w_qs = nn.Linear(d_model, n_head * d_k, bias=False)
self.w_ks = nn.Linear(d_model, n_head * d_k, bias=False)
self.w_vs = nn.Linear(d_model, n_head * d_v, bias=False)
# 最後的輸出維度變換操作
self.fc = nn.Linear(n_head * d_v, d_model, bias=False)
# 單頭自注意力
self.attention = ScaledDotProductAttention(temperature=d_k ** 0.5)
self.dropout = nn.Dropout(dropout)
# 層歸一化
self.layer_norm = nn.LayerNorm(d_model, eps=1e-6)

def forward(self, q, k, v, mask=None):
# 假設qkv輸入是(b,100,512),100是訓練每個樣本最大單詞個數
# 一般qkv相等,即自注意力
residual = q
# 將輸入x和可學習矩陣相乘,得到(b,100,512)輸出
# 其中512的含義其實是8×64,8個head,每個head的可學習矩陣為64維度
# q的輸出是(b,100,8,64),kv也是一樣
q = self.w_qs(q).view(sz_b, len_q, n_head, d_k)
k = self.w_ks(k).view(sz_b, len_k, n_head, d_k)
v = self.w_vs(v).view(sz_b, len_v, n_head, d_v)

# 變成(b,8,100,64),方便後面計算,也就是8個頭單獨計算
q, k, v = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)

if mask is not None:
mask = mask.unsqueeze(1) # For head axis broadcasting.
# 輸出q是(b,8,100,64),維持不變,內部計算流程是:
# q*k轉置,除以d_k ** 0.5,輸出維度是b,8,100,100即單詞和單詞直接的相似性
# 對最後一個維度進行softmax操作得到b,8,100,100
# 最後乘上V,得到b,8,100,64輸出
q, attn = self.attention(q, k, v, mask=mask)

# b,100,8,64–>b,100,512
q = q.transpose(1, 2).contiguous().view(sz_b, len_q, -1)
q = self.dropout(self.fc(q))
# 殘差計算
q += residual
# 層歸一化,在512維度計算均值和方差,進行層歸一化
q = self.layer_norm(q)

return q, attn
“`

### 4、前饋網路

這個層就沒啥說的了,非常簡單,直接看程式碼吧:

“`python
class PositionwiseFeedForward(nn.Module):
”’ A two-feed-forward-layer module ”’

def __init__(self, d_in, d_hid, dropout=0.1):
super().__init__()
# 兩個fc層,對最後的512維度進行變換
self.w_1 = nn.Linear(d_in, d_hid) # position-wise
self.w_2 = nn.Linear(d_hid, d_in) # position-wise
self.layer_norm = nn.LayerNorm(d_in, eps=1e-6)
self.dropout = nn.Dropout(dropout)

def forward(self, x):
residual = x

x = self.w_2(F.relu(self.w_1(x)))
x = self.dropout(x)
x += residual

x = self.layer_norm(x)

return x
“`

最後,回顧下 `𝑡𝑟𝑎𝑛𝑠𝑓𝑜𝑟𝑚𝑒𝑟 𝑒𝑛𝑐𝑜𝑑𝑒𝑟` 的整體結構。

經過上文的梳理,我們已經基本了解了 `𝑡𝑟𝑎𝑛𝑠𝑓𝑜𝑟𝑚𝑒𝑟` 編碼器的主要構成部分,我們下面用公式把一個 `𝑡𝑟𝑎𝑛𝑠𝑓𝑜𝑟𝑚𝑒𝑟 𝑏𝑙𝑜𝑐𝑘` 的計算過程整理一下:

##### 1) 字向量與位置編碼

$$X = EmbeddingLookup(X) + PositionalEncoding$$
$$X \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$

##### 2) 自注意力機制

$$Q = Linear(X) = XW_{Q}$$
$$K = Linear(X) = XW_{K}$$
$$V = Linear(X) = XW_{V}$$
$$X_{attention} = SelfAttention(Q, \ K, \ V)$$

##### 3) 殘差連接與層歸一化

$$X_{attention} = X + X_{attention}$$
$$X_{attention} = LayerNorm(X_{attention})$$

##### 4) 前向網路

其實就是兩層線性映射並用激活函數激活,比如說$ReLU$:
$$X_{hidden} = Activate(Linear(Linear(X_{attention})))$$

##### 5) 重複3)

$$X_{hidden} = X_{attention} + X_{hidden}$$
$$X_{hidden} = LayerNorm(X_{hidden})$$
$$X_{hidden} \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$

## 三、絮叨

至此,我們已經講完了 `Transformer` 編碼器的全部內容,知道了如何獲得自然語言的位置資訊,注意力機制的工作原理等。

本文以原理講解為主,後續我會繼續更新**實戰內容**,教大家如何訓練我們自己的有趣又好玩的模型。

**本文硬核,肝了很久,如果喜歡,還望轉發、再看多多支援。**

我是 Jack ,我們下期見。

> 文章持續更新,可以微信公眾號搜索【JackCui-AI】第一時間閱讀,本文 GitHub [//github.com/Jack-Cherish/PythonPark](//github.com/Jack-Cherish/PythonPark) 已經收錄,有大廠面試完整考點,歡迎Star。