ref :https://jishuin.proginn.com/p/763bfbd37c6f
https://zhuanlan.zhihu.com/p/46990010
https://zhuanlan.zhihu.com/p/48508221
Transformer是Google在2017年提出的用於機器翻譯的模型:
Transformer內部本質上是一個Encoder-Decoder(編碼器-解碼器)結構:
Transformer中拋棄了傳統的CNN和RNN,整個網絡結構完全由Attention機制組成,並且採用了6層Encoder-Decoder結構:
很顯然了,Transformer只有分爲兩大部分:編碼器、解碼器。這裏只看其中一個Encoder-Decoder結構,剩餘5個一樣一樣的。
以一個簡單的例子進行說明:
輸入: Why do we work?(我們爲何工作? 後面以中文字符分析)
上圖左右兩個紅框,左側紅框是編碼器Encoder,右側紅框是解碼器Decoder。
編碼器負責將自然語言序列映射爲隱藏層(上圖第2步),即含有自然語言序列的數學表達。
解碼器負責將隱藏層再映射爲自然語言序列,從而使我們可以解決各種問題,如情感分析、機器翻譯、摘要生成、語義關係抽取等。
簡單說下,上圖每一步都做了什麼:
輸入自然語言序列到編碼器:Why do we work?(爲啥要工作);
編碼器輸出得到隱藏層,再將其輸入到解碼器;
輸入<start>(起始)符號到解碼器;
解碼器得到第一個字: “爲”;
將解碼器得到的第一個字“爲”再次從上圖解碼器結構的下端輸入解碼器;
解碼器得到第二個字:“什”;
同樣將解碼器得到的第二個字“什”再次從上圖解碼器結構的下端輸入解碼器;
如此,直到解碼器輸出<end>終止符,即序列生成完成。
解碼器和編碼器的結構類似,本文對編碼器部分進行講解:把自然語言序列映射爲隱藏層的數學表達的過程。
爲便於理解學習,將編碼器分爲4部分依次講解。
1. 位置嵌入
輸入維度爲[batch size, sequence length]的數據X,例如:我們爲什麼工作。
batch size就是一個batch的大小,這裏只有一句話,所以batch size爲1,sequence length是句子長度,共7個字,所以輸入數據維度是[1,7].
我們不能直接將這句話輸入到編碼器中,因爲transformer不認識,需要先進行字嵌入操作,得到上圖中的Xembedding.
簡單點說,就是文字-->字向量的轉換,這種轉換是將文字轉換爲計算機認識的數學表示,用到的方法就是Word2Vec,Word2Vec的具體細節可以暫時不用瞭解,知道要用到,先拿來用就好。
得到的Xembedding的維度是:[batch size, sequence length, embedding dimension]。enbedding dimension的大小由Word2Vec決定,transformer採用512長度的字向量。所以Xembedding的維度是[1, 7, 512]。
至此,輸入的“我們爲什麼工作”,可以用一個矩陣來表示:
我們知道,文字的先後順序,很重要。
比如喫飯沒
、沒喫飯
、沒飯喫
、飯喫沒
、飯沒喫
,同樣三個字,順序顛倒,所表達的含義就不同了。
--->關於Position Embedding:
因爲前面說過模型本身並不包含RNN、CNN,因此無法捕捉到序列的順序/位置信息,例如將K, V(需要先驗知識:關於attention機制中Q K V三矩陣的常識)按行打亂,那麼attention之後的順序是一樣的。
但是序列信息又非常重要,代表全局結構,因此必須將序列的token相對或絕對position信息利用起來。
這裏每個token的position embedding 向量維度也是 然後將原本的input embedding和position embedding加起來組成最終的embedding作爲encoder/decoder的輸入。其中position embedding計算公式如下:
其中 表示位置index, 表示dimension index。
Position Embedding本身是一個絕對位置的信息,但在語言中,相對位置也很重要,Google選擇前述的位置向量公式的一個重要原因是:由於我們有:
這表明位置p+k的向量可以表示成位置p的向量的線性變換,這提供了表達相對位置信息的可能性。
在其他NLP論文中,大家也都看過position embedding,通常是一個訓練的向量,但是position embedding只是extra features,有該信息會更好,但是沒有性能也不會產生極大下降,因爲RNN、CNN本身就能夠捕捉到位置信息,但是在Transformer模型中,Position Embedding是位置信息的唯一來源,因此是該模型的核心成分,並非是輔助性質的特徵。
也可以採用訓練的position embedding,但是試驗結果表明相差不大,因此論文選擇了sin position embedding,因爲
- 這樣可以直接計算embedding而不需要訓練,減少了訓練參數
- 這樣允許模型將position embedding擴展到超過了training set中最長position的position,例如測試集中出現了更大的position,sin position embedding依然可以給出結果,但不存在訓練到的embedding。
--->繼續:
所以我們知道文字的位置信息很重要,Tranformer
沒有類似 RNN
的循環結構,沒有捕捉順序序列的能力。
爲了保留這種位置信息提供給 Tranformer
進行學習,我們需要用到位置嵌入。
加入位置信息的方式非常多,最簡單的可以是直接將絕對座標 0,1,2
編碼。
Tranformer
採用的是 sin-cos
規則,使用了 sin
和 cos
函數的線性變換給模型提供位置信息:
上式中,pos指的是句中字的位置,取值範圍是[0, 𝑚𝑎𝑥 𝑠𝑒𝑞𝑢𝑒𝑛𝑐𝑒 𝑙𝑒𝑛𝑔𝑡ℎ),i指的是字嵌入的維度,取值範圍是[0, 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛)。 dmodel就是 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛
的大小。
上面有 sin
和 cos
的一組公式,也就是對應着 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛
維度的一組奇數和偶數的序號的維度,從而產生不同的週期性變化。
可以通過代碼簡單看下效果:
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")
plt.legend()
plt.show()
可以看到,位置嵌入在 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛
(也是hidden dimension
)維度上隨着維度序號增大,週期變化會越來越慢,而產生一種包含位置信息的紋理。
這樣,產生獨一的紋理位置信息,模型從而學到位置之間的依賴關係和自然語言的時序特性。最後,將 Xembedding 和 位置嵌入
相加,輸入給下一層。
2. 自注意力層(self attention mechanism)
多頭的意義在於,QKT得到的矩陣就叫做注意力矩陣,它可以表示,每個字與其他字的相似程度。因爲向量點積值越大,表明兩個向量越接近。
我們的目的是,讓每個字都含有當前這個句子中所有字的信息,用注意力層,我們做到了。
需要注意的是,在上面 𝑠𝑒𝑙𝑓 𝑎𝑡𝑡𝑒𝑛𝑡𝑖𝑜𝑛
的計算過程中,我們通常使用 𝑚𝑖𝑛𝑖 𝑏𝑎𝑡𝑐ℎ
,也就是一次計算多句話,上文舉例只用了一個句子。
每個句子的長度是不一樣的,需要按照最長的句子的長度統一處理。對於較短的句子,做padding操作,一般用0填充。
3. 殘差鏈接和層歸一化
加入了殘差設計和層歸一化操作,目的是爲了防止梯度消失,加快收斂。
1)殘差設計
我們在上一步得到了經過注意力矩陣加權之後的V,也就是Attention(Q, K, V),將其轉置,使其和Xembeding維度一致,也就是[𝑏𝑎𝑡𝑐ℎ 𝑠𝑖𝑧𝑒, 𝑠𝑒𝑞𝑢𝑒𝑛𝑐𝑒 𝑙𝑒𝑛𝑔𝑡ℎ, 𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔 𝑑𝑖𝑚𝑒𝑛𝑠𝑖𝑜𝑛]
,然後把他們加起來做殘差連接,直接元素相加,因爲他們的維度一致:
Xembeding + Attention(Q, K, V)
在之後的運算裏,每經過一個模塊的運算,都要把運算之前的值和運算之後的值相加,從而得到殘差連接,訓練的時候可以使梯度直接走捷徑反傳到最初始層:
2)層歸一化
作用是把神經網絡中隱藏層歸一爲標準正態分佈,也就是 𝑖.𝑖.𝑑
獨立同分布, 以起到加快訓練速度, 加速收斂的作用。
上式中以矩陣的行 (𝑟𝑜𝑤) 爲單位求均值:
上式中以矩陣的行 (𝑟𝑜𝑤) 爲單位求方差:
然後用每一行的每一個元素減去這行的均值,再除以這行的標準差,從而得到歸一化後的數值,ε是爲了防止除0;
之後引入兩個可訓練參數來α、β來彌補歸一化過程中損失掉的信息,注意表示元素相乘而不是點積,我們一般初始化α爲全1,β爲全0。
代碼層面非常簡單,單頭 attention
操作如下:
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
基礎上構建:
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的含義其實是8x64,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
前饋網絡:
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)字向量與位置編碼
2)自注意力機制
3)殘差連接與層歸一化
4)前向網絡
其實就是兩層線性激活函數,比如ReLU:
5)repeat " 3)"
至此,我們已經講完了 Transformer
編碼器的全部內容,知道了如何獲得自然語言的位置信息,注意力機制的工作原理等。