[深度學習] 自然語言處理---Transformer原理和實現

目錄

Encoder-Decoder框架

一 整體架構

動態流程圖

二 Encoder

2.1 Encoder Layer和殘差網絡

Residual Connection

2.2 Attention

Self Attention

Multi-head Attention

2.3 Add & Norm

LayerNormalization 層歸一化

2.4 前饋網絡 Feed Forward Neural Network

2.5 詞向量

Positional Encoding 位置編碼

三 Decoder

3.1 Masked Mutil-head Attention

3.2 線性層和softmax

3.3 完整模型代碼

四、相關問題

4.1 Transformer爲什麼需要進行Multi-head Attention?

4.2 Transformer相比於RNN/LSTM,有什麼優勢?爲什麼?

4.3 爲什麼說Transformer可以代替seq2seq?

4.4 Transformer如何並行化的?

4.5 訓練-模型的參數在哪裏?

參考文獻



 

所謂 ”工預善其事,必先利其器“, BERT之所以取得這麼驚才絕豔的效果,很大一部分原因源自於Transformer。爲了後面更好、更快地理解BERT模型,這一節從Transformer的開山鼻祖說起,先來跟着”Attention is All You Need[1]“ 這篇文章,走近transformer的世界,在這裏你再也看不到熟悉的CNN、RNN的影子,取而代之的是,你將看到Attention機制是如何被髮揮的淋漓盡致、妙至毫顛,以及它何以從一個爲CNN、RNN跑龍套的配角實現華麗逆襲。對於Bert來說,transformer真可謂天縱神兵,出匣自鳴!

看完本文,你大概能夠:

當然,最重要的,你能瞭解Transformer的原理和代碼實現。

Notes: 本文代碼參考哈弗大學的The Annotated Transformer

Encoder-Decoder框架

Encoder-Decoder是爲seq2seq(序列到序列)量身打造的一個深度學習框架,在機器翻譯、機器問答等領域有着廣泛的應用。這是一個抽象的框架,由兩個組件:Encoder(編碼器)和Decoder(解碼器)組成。

class EncoderDecoder(nn.Module):
    # A standard Encoder-Decoder architecture. Base for this and many other models.
    def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):
        super(EncoderDecoder, self).__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.src_embed = src_embed
        self.tgt_embed = tgt_embed
        self.generator = generator
    def forward(self, src, tgt, src_mask, tgt_mask):
        "Take in and process masked src and target sequences."
        return self.decode(self.encode(src, src_mask), src_mask,tgt, tgt_mask)
    def encode(self, src, src_mask):
        return self.encoder(self.src_embed(src), src_mask)
    def decode(self, memory, src_mask, tgt, tgt_mask):
        return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)

上述代碼呈現了一個標準的Encoder-Decoder框架。在實際應用中,編碼器和解碼器可以有多種組合,比如(RNN, RNN)、(CNN,RNN)等等,這就是傳統的seq2seq框架。後來引入了attention機制,上述框架也被稱爲”分心模型“。爲什麼說他”分心“呢?因爲對於解碼器來說,他在生成每一個單詞的時候,中間向量的每一個元素對當前生成詞的貢獻都是一樣的。Attention的思想則是對於當前生成的單詞,中間向量z的每個元素對其貢獻的重要程度不同,跟其強相關的賦予更大的權重,無關的則給一個很小的權重。

 

一 整體架構

這部分我們來看看Transformer的架構。Transformer遵循了Encoder-Decoder的架構。在Encoder方面,6個編碼器組件協同工作,組成一個大的編碼器,解碼器同樣由6個解碼器組件組成。我們先看Encoder。6個編碼器組件依次排列,每個組件內部都是由一個多頭attention加上一個前饋網絡,attenion和前饋的輸出都經過層歸一化(LayerNormalization),並且都有各自的殘差網絡 。Decoder呢,組件的配置基本相同, 不同的是Decoder有兩個多頭attention機制,一個是其自身的mask自注意力機制,另一個則是從Encoder到Decoder的注意力機制,而且是Decoder內部先做一次attention後再接收Encoder的輸出。

說完了Encoder和Decoder,再說說輸入,模型的輸入部分由詞向量(embedding)經位置編碼(positional Encoding)後輸入到Encoder和Decoder。編碼器的輸出由一個線性層和softmax組成,將浮點數映射成具體的符號輸出。

 

首先,Transformer模型也是使用經典的encoer-decoder架構,由encoder和decoder兩部分組成。

上圖的左半邊用Nx框出來的,就是我們的encoder的一層。encoder一共有6層這樣的結構。

上圖的右半邊用Nx框出來的,就是我們的decoder的一層。decoder一共有6層這樣的結構。

輸入序列經過word embeddingpositional encoding相加後,輸入到encoder。

輸出序列經過word embeddingpositional encoding相加後,輸入到decoder。

最後,decoder輸出的結果,經過一個線性層,然後計算softmax。

 

 

再通俗一點的圖,可能你在其他博客裏看到的圖,如下所示,Transformer由六個編碼器和六個解碼器組成。

  

動態流程圖

舉個例子介紹下如何使用這個Transformer Seq2Seq做翻譯

  • 首先,Transformer對原語言的句子進行編碼,得到memory。
  • 第一次解碼時輸入只有一個<SOS>標誌,表示句子的開始。
  • 解碼器通過這個唯一的輸入得到的唯一的輸出,用於預測句子的第一個詞。

編碼器通過處理輸入序列開啓工作。頂端編碼器的輸出之後會變轉化爲一個包含向量K(鍵向量)和V(值向量)的注意力向量集 ,這是並行化操作。這些向量將被每個解碼器用於自身的“編碼-解碼注意力層”,而這些層可以幫助解碼器關注輸入序列哪些位置合適:

在完成編碼階段後,則開始解碼階段。解碼階段的每個步驟都會輸出一個輸出序列(在這個例子裏,是英語翻譯的句子)的元素。

接下來的步驟重複了這個過程,直到到達一個特殊的終止符號,它表示transformer的解碼器已經完成了它的輸出。每個步驟的輸出在下一個時間步被提供給底端解碼器,並且就像編碼器之前做的那樣,這些解碼器會輸出它們的解碼結果 。

第二次解碼,將第一次的輸出Append到輸入中,輸入就變成了<SOS>和句子的第一個詞(ground truth或上一步的預測),解碼生成的第二個輸出用於預測句子的第二個詞。以此類推(過程與Seq2Seq非常類似)

 

 

 

二 Encoder

Encoder由N=6個相同的layer組成,layer指的就是上圖左側的單元,最左邊有個“Nx”,這裏是x6個。

每個Layer由兩個sub-layer組成:

  • 第一部分是一個multi-head self-attention mechanism
  • 第二部分是一個position-wise feed-forward network,是一個全連接層

其中每個sub-layer都加了residual connection和normalisation,因此可以將sub-layer的輸出表示爲:


 

def clones(module, N):
    "Produce N identical layers."
    return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])

class Encoder(nn.Module):
    "Core encoder is a stack of N layers"
    def __init__(self, layer, N):
        super(Encoder, self).__init__()
        self.layers = clones(layer, N)
        self.norm = LayerNorm(layer.size)
        
    def forward(self, x, mask):
        "Pass the input (and mask) through each layer in turn."
        for layer in self.layers:
            x = layer(x, mask)
        return self.norm(x)

以上便是Encoder的核心實現。它由N個encoderLayer組成。輸入一次通過每個encoderLayer,然後經過一個歸一化層。下面來看下encoderLayer和LayerNorm是什麼樣子。

我們在每兩個子層之間都使用了殘差連接(Residual Connection) 和歸一化

 

2.1 Encoder Layer和殘差網絡

class SublayerConnection(nn.Module):
    """
    A residual connection followed by a layer norm.
    Note for code simplicity the norm is first as opposed to last.
    """
    def __init__(self, size, dropout):
        super(SublayerConnection, self).__init__()
        self.norm = LayerNorm(size)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, sublayer):
        "Apply residual connection to any sublayer with the same size."
        return x + self.dropout(sublayer(self.norm(x)))
class EncoderLayer(nn.Module):
    "Encoder is made up of self-attn and feed forward (defined below)"
    def __init__(self, size, self_attn, feed_forward, dropout):
        super(EncoderLayer, self).__init__()
        self.self_attn = self_attn
        self.feed_forward = feed_forward
        self.sublayer = clones(SublayerConnection(size, dropout), 2)
        self.size = size

    def forward(self, x, mask):
        "Follow Figure 1 (left) for connections."
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
        return self.sublayer[1](x, self.feed_forward)

這裏的代碼初看上去有點繞,不過沒關係,聽我娓娓道來。我們先看什麼是殘差網絡(即代碼中的SublayerConnection)。其實非常簡單,就是在正常的前向傳播基礎上開一個綠色通道,這個通道里x可以無損通過。這樣做的好處不言而喻,避免了梯度消失(求導時多了一個常數項)。最終的輸出結果就等於綠色通道里的x加上sublayer層的前向傳播結果。注意,這裏輸入進來的時候做了個norm歸一化,關於norm我們後面再說。

Residual Connection

殘差連接其實很簡單!給你看一張示意圖你就明白了:

residual_conn

 

假設網絡中某個層對輸入x作用後的輸出是F(x),那麼增加residual connection之後,就變成了:F(x) + x,這個+x操作就是一個shortcut。那麼殘差結構有什麼好處呢?顯而易見:因爲增加了一項x,那麼該層網絡對x求偏導的時候,多了一個常數項1!所以在反向傳播過程中,梯度連乘,也不會造成梯度消失

所以,代碼實現residual connection很非常簡單:

def residual(sublayer_fn,x):
	return sublayer_fn(x)+x

文章開始的transformer架構圖中的Add & Norm中的Add也就是指的這個shortcut

理解了殘差網絡,EncoderLayer的代碼就很好看懂了。sublayer有兩個,一個是多頭self-attention層,另一個是前饋網絡(feed_forward)。輸入x先進入多頭self-attention,用一個殘差網絡加成,接着通過前饋網絡, 再用一個殘差網絡加成。

讓我們從輸入x開始,再從頭理一遍這個過程:

  • 輸入x
  • x做一個層歸一化: x1 = norm(x)
  • 進入多頭self-attention: x2 = self_attn(x1)
  • 殘差加成:x3 = x + x2
  • 再做個層歸一化:x4 = norm(x3)
  • 經過前饋網絡: x5 = feed_forward(x4)
  • 殘差加成: x6 = x3 + x5
  • 輸出x6

以上就是一個Encoder組件所做的全部工作了。裏面有兩點暫未說明,一個是多頭attention, 另一個是層歸一化。

 

2.2  Self Attention

熟悉attention原理的童鞋都知道,attention可由以下形式表示:

             

首先說明一下我們的K、Q、V是什麼:

  • 在encoder的self-attention中,Q、K、V都來自同一個地方(相等),他們是上一層encoder的輸出。對於第一層encoder,它們就是word embedding和positional encoding相加得到的輸入。
  • 在decoder的self-attention中,Q、K、V都來自於同一個地方(相等),它們是上一層decoder的輸出。對於第一層decoder,它們就是word embedding和positional encoding相加得到的輸入。但是對於decoder,我們不希望它能獲得下一個time step(即將來的信息),因此我們需要進行sequence masking
  • 在encoder-decoder attention中,Q來自於decoder的上一層的輸出,K和V來自於encoder的輸出,K和V是一樣的。
  • Q、K、V三者的維度一樣,即 dq = dk = dv

scaled dot-product attentiondot-product attention唯一的區別就是,scaled dot-product attention有一個縮放因子

上面公式中的dk表示的是K的維度,在論文裏面,默認是64

那麼爲什麼需要加上這個縮放因子呢?論文裏給出瞭解釋:對於dk很大的時候,點積得到的結果維度很大,使得結果處於softmax函數梯度很小的區域。我們知道,梯度很小的情況,這對反向傳播不利。爲了克服這個負面影響,除以一個縮放因子,可以一定程度上減緩這種情況。

 

其思想和attention類似,但是self-attention是Transformer用來將其他相關單詞的“理解”轉換成我們正在處理的單詞的一種思路

我們看個例子:

The animal didn't cross the street because it was too tired

這裏的 it 到底代表的是 animal 還是 street 呢,對於我們來說能很簡單的判斷出來,但是對於機器來說,是很難判斷的,self-attention就能夠讓機器把 it 和 animal 聯繫起來,接下來我們看下詳細的處理過程。

 

Self Attention就是句子中的某個詞對於本身的所有詞做一次Attention。算出每個詞對於這個詞的權重,然後將這個詞表示爲所有詞的加權和。每一次的Self Attention操作,就像是爲每個詞做了一次Convolution操作或Aggregation操作。具體操作如下:

  1. 首先,self-attention會計算出三個新的向量,在論文中,向量的維度是512維,我們把這三個向量分別稱爲Query、Key、Value,這三個向量是用embedding向量與一個矩陣相乘得到的結果,這個矩陣是隨機初始化的,維度爲(64,512)注意第二個維度需要和embedding的維度一樣,其值在BP的過程中會一直進行更新,得到的這三個向量的維度是64。
  2. 計算self-attention的分數值,該分數值決定了當我們在某個位置encode一個詞時,對輸入句子的其他部分的關注程度。這個分數值的計算方法是Query與Key做點成,以下圖爲例,首先我們需要針對Thinking這個詞,計算出其他詞對於該詞的一個分數值,首先是針對於自己本身即q1·k1,然後是針對於第二個詞即q1·k2。

  3. 接下來,把點成的結果除以一個常數,這裏我們除以8,這個值一般是採用上文提到的矩陣的第一個維度的開方即64的開方8,當然也可以選擇其他的值,然後把得到的結果做一個softmax的計算。得到的結果即是每個詞對於當前位置的詞的相關性大小,當然,當前位置的詞相關性肯定會會很大。

  4. 下一步就是把Value和softmax得到的值進行相乘,並相加,得到的結果即是self-attetion在當前節點的值。

     

在實際的應用場景,爲了提高計算速度,我們採用的是矩陣的方式,直接計算出Query, Key, Value的矩陣,然後把embedding的值與三個矩陣直接相乘,把得到的新矩陣 Q 與 K 相乘,除以一個常數,做softmax操作,最後乘上 V 矩陣。

歸一化之前需要通過除以向量的維度dk來進行標準化,所以最終Self Attention用矩陣變換的方式可以表示爲

最終每個Self Attention接受n個詞向量的輸入,輸出n個Aggregated的向量。

這種通過 query 和 key 的相似性程度來確定 value 的權重分佈的方法被稱爲scaled dot-product attention。

 

上文提到Encoder中的Self Attention與Decoder中的有所不同,Encoder中的Q、K、V全部來自於上一層單元的輸出,而Decoder只有Q來自於上一個Decoder單元的輸出,K與V都來自於Encoder最後一層的輸出。也就是說,Decoder是要通過當前狀態與Encoder的輸出算出權重後,將Encoder的編碼加權得到下一層的狀態。

 

Multi-head Attention

理解了Scaled dot-product attention,Multi-head attention也很簡單了。Multi-Head Attention就是將上述的Attention做h遍,然後將h個輸出進行concat得到最終的輸出。這樣做可以很好地提高算法的穩定性,在很多Attention相關的工作中都有相關的應用。

論文提到,他們發現將Q、K、V通過一個線性映射之後,分成 h份,對每一份進行scaled dot-product attention效果更好。然後,把各個部分的結果合併起來,再次經過線性映射,得到最終的輸出。這就是所謂的multi-head attention。上面的超參數 h就是heads數量。論文默認是8

Transformer的實現中,爲了提高Multi-Head的效率,將W擴大了h倍,然後通過view(reshape)和transpose操作將相同詞的不同head的k、q、v排列在一起進行同時計算,完成計算後再次通過reshape和transpose完成拼接,相當於對於所有的head進行了一個並行處理。


 

值得注意的是,上面所說的分成 h 份是在 dk,kq,dv 維度上面進行切分的。因此,進入到scaled dot-product attention的 dk 實際上等於未進入之前的 Dk/h 

multi-head attention則是通過h個不同的線性變換對Q,K,V進行投影,最後將不同的attention結果拼接起來:

self-attention則是取Q,K,V相同。

論文裏面,dmodel = 512,h = 8。所以在scaled dot-product attention裏面的

作者同樣提到了另一種複雜度相似但計算方法additive attention,在 dk 很小的時候和dot-product結果相似,dk大的時候,如果不進行縮放則表現更好,但dot-product的計算速度更快,進行縮放後可減少影響(由於softmax使梯度過小,具體可見論文中的引用)

 

這裏使用的是點乘attention,而不是加性(additive)attention。但是再提一點,在encoder和decoder的自注意力中,attention層的輸入分爲self_attn(x, x, x, mask)和self_attn(t, t, t, mask), 這裏的x和t分別爲source和target輸入。後面會看到,從encoder到decoder層的注意力輸入時attn(t, m, m), 這裏的m是Encoder的輸出。

def attention(query, key, value, mask=None, dropout=None):
    #因子化的點乘Attention-矩陣形式
    #Query: 查詢 (batch_size, heads, max_seq_len, d_k)
    #Key: 鍵 (batch_size, heads, max_seq_len_d_k)
    #Value: 值 (batch_size, heads, max_seq_len, d_v)
    #d_v = d_k
    #Q=K=V
    d_k = query.size(-1)
    # (batch_size, heads, max_seq_len, d_k) * (batch_size, heads, d_k, max_seq_len)
    #  = (batch_size, heads, max_seq_len, max_seq_len)
    # 爲了方便說明,只看矩陣的後兩維 (max_seq_len, max_seq_len), 即
    #       How  are  you
    # How [[0.8, 0.2, 0.3]
    # are  [0.2, 0.9, 0.6]
    # you  [0.3, 0.6, 0.8]]
    # 矩陣中每個元素的含義是,他對其他單詞的貢獻(分數)
    # 例如,如果我們想得到所有單詞對單詞“How”的打分,取矩陣第一列[0.8, 0.2, 0.3], 然後做softmax
    scores = torch.matmul(query, key.transpose(-2, -1)) \
             / math.sqrt(d_k)
    # 對於padding部分,賦予一個極大的負數,softmax後該項的分數就接近0了,表示貢獻很小
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    
    p_attn = F.softmax(scores, dim = -1)
    
    if dropout is not None:
        p_attn = dropout(p_attn)
    # 接着與Value做矩陣乘法  和V做點積:
    # (batch_size, heads, max_seq_len, max_seq_len) * (batch_size, heads, max_seq_len, d_k)
    # = (batch_size, heads, max_seq_len, d_k)
    context = torch.matmul(p_attn, value)

    return context, p_attn
class MultiHeadedAttention(nn.Module):
    def __init__(self, h, d_model, dropout=0.1):
        "Take in model size and number of heads."
        super(MultiHeadedAttention, self).__init__()
        assert d_model % h == 0, "heads is not a multiple of the number of the in_features"
        # We assume d_v always equals d_k
        self.d_k = d_model // h
        self.h = h
        self.linears = clones(nn.Linear(d_model, d_model), 4)
        self.attn = None
        self.dropout = nn.Dropout(p=dropout)

    def forward(self, query, key, value, mask=None):
        #這裏的query, key, value與attention函數中的含義有所不同,這裏指的是原始的輸入.
        #對於Encoder的自注意力來說,輸入query=key=value=x
        #對於Decoder的自注意力來說,輸入query=key=value=t
        #對於Encoder和Decoder之間的注意力來說, 輸入query=t, key=value=m
        #其中m爲Encoder的輸出,即給定target,通過key計算出m中每個輸出對當前target的分數,在乘上m
        if mask is not None:
            # Same mask applied to all h heads.
            mask = mask.unsqueeze(1)
        nbatches = query.size(0)
        # 1) Do all the linear projections in batch from d_model => h x d_k
        query, key, value = \
            [l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
             for l, x in zip(self.linears, (query, key, value))]
        # 2) Apply attention on all the projected vectors in batch.
        ##   x: (batch_size, heads, max_seq_len, d_k)
        x, self.attn = attention(query, key, value, mask=mask,
                                 dropout=self.dropout)
        # 3) "Concat" using a view and apply a final linear.
        ##   x: (batch_size, max_seq_len, d_k*h)
        x = x.transpose(1, 2).contiguous() \
            .view(nbatches, -1, self.h * self.d_k)
        ## output: (batch_size, max_seq_len, d_model)
        return self.linears[-1](x)
def clones(module, N):
    "Produce N identical layers."
    return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])

2.3 Add & Norm

在Transformer中,每一個子層(self-attetion,Feed Forward Neural Network)之後都會接一個殘缺模塊,並且有一個Layer normalization。

一個殘差網絡,將一層的輸入與其標準化後的輸出進行相加即可。Transformer中每一個Self Attention層與FFN層後面都會連一個Add & Norm層。

LayerNormalization 層歸一化

Normalization有很多種,但是它們都有一個共同的目的,那就是把輸入轉化成均值爲0方差爲1的數據。我們在把數據送入激活函數之前進行normalization(歸一化),因爲我們不希望輸入數據落在激活函數的飽和區。

BN的主要思想就是:在每一層的每一批數據上進行歸一化。我們可能會對輸入數據進行歸一化,但是經過該網絡層的作用後,我們的數據已經不再是歸一化的了。隨着這種情況的發展,數據的偏差越來越大,我的反向傳播需要考慮到這些大的偏差,這就迫使我們只能使用較小的學習率來防止梯度消失或者梯度爆炸。BN的具體做法就是對每一小批數據,在批這個方向上做歸一化。

Layer normalization 它也是歸一化數據的一種方式,不過LN 是在每一個樣本上計算均值和方差,而不是BN那種在批方向計算均值和方差!公式如下:

class LayerNorm(nn.Module):
    """實現LayerNorm。其實PyTorch已經實現啦,見nn.LayerNorm。"""

    def __init__(self, features, eps=1e-6):
        """
        Args:
            features: 就是模型的維度。論文默認512
            eps: 一個很小的數,防止數值計算的除0錯誤
        """
        super(LayerNorm, self).__init__()
        self.gamma = nn.Parameter(torch.ones(features))
        self.beta = nn.Parameter(torch.zeros(features))
        self.eps = eps

    def forward(self, x):
        """
        Args:
            x: 輸入序列張量,形狀爲[B, L, D]
        """
        # 在X的最後一個維度求均值,最後一個維度就是模型的維度
        mean = x.mean(-1, keepdim=True)
        # 在X的最後一個維度求方差,最後一個維度就是模型的維度
        std = x.std(-1, keepdim=True)
        return self.gamma * (x - mean) / (std + self.eps) + self.beta

2.4 前饋網絡 Feed Forward Neural Network

我們需要一種方式,把 8 個矩陣降爲 1 個,首先,我們把 8 個矩陣連在一起,這樣會得到一個大的矩陣,再隨機初始化一個矩陣和這個組合好的矩陣相乘,最後得到一個最終的矩陣。

Encoder中和Decoder中經過Attention之後輸出的n個向量(這裏n是詞的個數)都分別的輸入到一個全連接層中,完成一個逐個位置的前饋網絡。

每個encoderLayer中,多頭attention後會接一個前饋網絡。這個前饋網絡其實是兩個全連接層,進行了如下操作:

論文提到,這個公式還可以用兩個核大小爲1的一維卷積來解釋,卷積的輸入輸出都是dmodel=512dmodel=512 dmodel​=512,中間層的維度是dff=2048

class PositionwiseFeedForward(nn.Module):
    '''Implements FFN equation.
    d_model=512
    d_ff=2048
    '''
    def __init__(self, d_model, d_ff, dropout=0.1):
        super(PositionwiseFeedForward, self).__init__()
        self.w_1 = nn.Linear(d_model, d_ff)
        # self.w_1 = nn.Conv1d(in_features=d_model, out_features=d_ff, kenerl_size=1)
        self.w_2 = nn.Linear(d_ff, d_model)
        # self.w_2 = nn.Conv1d(in_features=d_ff, out_features=d_model, kenerl_size=1)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        return self.w_2(self.dropout(F.relu(self.w_1(x))))

這兩層的作用等價於兩個 kenerl_size=1的一維卷積操作。

2.5 詞向量

這裏就是普通的不能再普通的詞向量,將詞語變成d_model維的向量。Word embedding應該是老生常談了,它實際上就是一個二維浮點矩陣,裏面的權重是可訓練參數,我們只需要把這個矩陣構建出來就完成了word embedding的工作。

class Embeddings(nn.Module):
    def __init__(self, d_model, vocab):
        super(Embeddings, self).__init__()
        self.lut = nn.Embedding(vocab, d_model)
        self.d_model = d_model

    def forward(self, x):
        return self.lut(x) * math.sqrt(self.d_model)

上面vocab_size就是詞典的大小,embedding_size就是詞嵌入的維度大小,論文裏面就是等於dmodel=512,所以word embedding矩陣就是一個vocab_size*embedding_size的二維張量

Positional Encoding 位置編碼

由於Transformer沒有用到CNN和RNN,因此,句子單詞之間的位置信息就沒有利用到。顯然,這些信息對於翻譯來說是非常有用的,同樣一句話,每個單詞的意思能夠準確的翻譯出來,但如果順序不對,表達出來的意思就截然不同了。舉個栗子感受一下,原句:”A man went through the Big Buddhist Temple“, 翻譯成:”人過大佛寺“和”寺佛大過人“,意思就完全不同了。

那麼如何表達一個序列的位置信息呢?爲了解決這個問題,Transformer提出了Positional Encoding的方案,就是給每個輸入的詞向量疊加一個固定的向量來表示它的位置。文中使用的Positional Encoding如下:

對於某一個單詞來說,他的位置信息主要有兩個方面:一是絕對位置,二是相對位置。絕對位置決定了單詞在一個序列中的第幾個位置,相對位置決定了序列的流向。作者利用了正弦函數和餘弦函數來進行位置編碼:

其中pos是指當前詞在句子中的位置,i是指向量中每個值的index,可以看出,在偶數位置,使用正弦編碼,在奇數位置,使用餘弦編碼

最後把這個Positional Encoding與embedding的值相加,作爲輸入送到下一層。

 

其中pos是詞在句子中的位置,i是詞向量中第i位,即將每個詞的詞向量爲一行進行疊加,然後針對每一列都疊加上一個相位不同或波長逐漸增大的波,以此來唯一區分位置。

其中pos是單詞處於句子的第幾個位置。我們來考察一下第一個公式,看是否每個位置都能得到一個唯一的值作爲編碼。爲簡單起見,不妨令i=0,那麼:

我們反過來想,假如存在位置j和k的編碼值相同,那麼就有:

i,j 爲非負整數且i不等於j, 以上兩式需要同時滿足,可等價爲:

i,j爲非負整數且i不等於j且k爲整數

同時成立,這就意味着:

這顯然是不可能的,因爲左邊是個無理數(無限不循環小數),而右邊是個有理數。通過反證法就證明了在這種表示下,每個位置確實有唯一的編碼。

上面的討論並未考慮i的作用。i決定了頻率的大小,不同的i可以看成是不同的頻率空間中的編碼,是相互正交的,通過改變i的值,就能得到多維度的編碼,類似於詞向量的維度。這裏2i<=512(d_model), 一共512維。想象一下,當2i大於d_model時會出現什麼情況,這時sin函數的週期會變得非常大,函數值會非常接近於0,這顯然不是我們希望看到的,因爲這樣和詞向量就不在一個量級了,位置編碼的作用被削弱了。另外,值得注意的是,位置編碼是不參與訓練的,而詞向量是參與訓練的。作者通過實驗發現,位置編碼參與訓練與否對最終的結果並無影響。

class PositionalEncoding(nn.Module):
    "Implement the PE function."
    def __init__(self, d_model, dropout, max_len=5000):
        """初始化。
        
        Args:
            d_model: 一個標量。模型的維度,論文默認是512
            max_seq_len: 一個標量。文本序列的最大長度
        """
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)

        # Compute the positional encodings once in log space.
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2) *
                             -(math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + Variable(self.pe[:, :x.size(1)],
                         requires_grad=False)
        return self.dropout(x)

之所以對奇偶位置分別編碼,是因爲編碼前一個位置是可以由另一個位置線性表示的(公差爲1的等差數列),在編碼之後也希望能保留這種線性。我們以第1個位置和第k+1個位置爲例,還是令i=0:

至此,我們就把Encoder部分的細節介紹完了,下面來看下Decoder部分

 

三 Decoder

Decoder和Encoder的結構差不多,但是多了一個attention的sub-layer,這裏先明確一下decoder的輸入輸出和解碼過程:

  • 輸出:對應i位置的輸出詞的概率分佈
  • 輸入:encoder的輸出 & 對應i-1位置decoder的輸出。所以中間的attention不是self-attention,它的K,V來自encoder,Q來自上一位置decoder的輸出
  • 解碼:這裏要特別注意一下,編碼可以並行計算,一次性全部encoding出來,但解碼不是一次把所有序列解出來的,而是像rnn一樣一個一個解出來的,因爲要用上一個位置的輸入當作attention的query

明確瞭解碼過程之後最上面的圖就很好懂了,這裏主要的不同就是新加的另外要說一下新加的attention多加了一個mask,因爲訓練時的output都是ground truth,這樣可以確保預測第i個位置時不會接觸到未來的信息。

每一個層包括以下3個部分:

  • 第一個部分是multi-head self-attention mechanism
  • 第二部分是multi-head context-attention mechanism
  • 第三部分是一個position-wise feed-forward network

還是和encoder類似,上面三個部分的每一個部分,都有一個殘差連接,後接一個Layer Normalization

但是,decoder出現了一個新的東西multi-head context-attention mechanism。這個東西其實也不復雜,理解了multi-head self-attention你就可以理解multi-head context-attention

 

通過觀察上面的結構圖我們還可以發現Decoder與Encoder的另外一個不同,就是每個Decoder單元的輸入層,要先經過一個Masked Attention層。那麼Masked的與普通版本的Attention有什麼區別呢?

3.1 Masked Mutil-head Attention

mask 表示掩碼,它對某些值進行掩蓋,使其在參數更新時不產生效果

Encoder因爲要編碼整個句子,所以每個詞都需要考慮上下文的關係。所以每個詞在計算的過程中都是可以看到句子中所有的詞的。但是Decoder與Seq2Seq中的解碼器類似,每個詞都只能看到前面詞的狀態,所以是一個單向的Self-Attention結構。

Masked Attention的實現也非常簡單,只要在普通的Self Attention的Softmax步驟之前,與(&)上一個下三角矩陣M就好了

需要說明的是,我們的Transformer模型裏面涉及兩種mask。分別是padding mask和sequence mask。其中後者我們已經在decoder的self-attention裏面見過啦!其中,padding mask在所有的scaled dot-product attention裏面都需要用到,而sequence mask只有在decoder的self-attention裏面用到。

所以,我們之前ScaledDotProductAttention的forward方法裏面的參數attn_mask在不同的地方會有不同的含義。

  1. padding mask

    什麼是padding mask呢?回想一下,我們的每個批次輸入序列長度是不一樣的!也就是說,我們要對輸入序列進行對齊!具體來說,就是給在較短的序列後面填充0。但是如果輸入的序列太長,則是截取左邊的內容,把多餘的直接捨棄。因爲這些填充的位置,其實是沒什麼意義的,所以我們的attention機制不應該把注意力放在這些位置上,所以我們需要進行一些處理。

    具體的做法是,把這些位置的值加上一個非常大的負數(可以是負無窮),這樣的話,經過softmax,這些位置的概率就會接近0!

    而我們的padding mask實際上是一個張量,每個值都是一個Boolen,值爲False的地方就是我們要進行處理的地方。

    下面是實現:

    # 參考實現代碼
    def padding_mask(seq_k, seq_q):
        # seq_k和seq_q的形狀都是[B,L]
        len_q = seq_q.size(1)
        # `PAD` is 0
        pad_mask = seq_k.eq(0)
        pad_mask = pad_mask.unsqueeze(1).expand(-1, len_q, -1)  # shape [B, L_q, L_k]
        return pad_mask
    
    
  2. Sequence mask

    文章前面也提到,sequence mask是爲了使得decoder不能看見未來的信息。也就是對於一個序列,在time_step爲t的時刻,我們的解碼輸出應該只能依賴於t時刻之前的輸出,而不能依賴t之後的輸出。因此我們需要想一個辦法,把t之後的信息給隱藏起來。

    那麼具體怎麼做呢?也很簡單:產生一個上三角矩陣,上三角的值全爲1,下三角的值權威0,對角線也是0。把這個矩陣作用在每一個序列上,就可以達到我們的目的啦。

    具體的代碼實現如下:

    #參考實現代碼
    def sequence_mask(seq):
        batch_size, seq_len = seq.size()
        mask = torch.triu(torch.ones((seq_len, seq_len), dtype=torch.uint8),
                        diagonal=1)
        mask = mask.unsqueeze(0).expand(batch_size, -1, -1)  # [B, L, L]
        return mask

對於 decoder 的 self-attention,裏面使用到的 scaled dot-product attention,同時需要padding mask 和 sequence mask 作爲 attn_mask,具體實現就是兩個mask相加作爲attn_mask。其他情況,attn_mask 一律等於 padding mask。

 

注意下attention當中的mask。我們之前提到,在三個地方用到了attention。在Encoder的自注意力機制中,mask是用來過濾padding部分的作用,對於source中的每一個詞來講,其他的詞對他都是可見的,都可以做出貢獻的。但是在Decoder中,mask的作用就有所不同了。這可能又要從Encoder-Decoder框架說起。在這個框架下,解碼器實際上可看成一個神經網絡語言模型,預測的時候,target中的每一個單詞是逐個生成的,當前詞的生成依賴兩方面:

  • 一是Encoder的輸出.
  • 二是target的前面的單詞.

例如,在生成第一個單詞是,不僅依賴於Encoder的輸出,還依賴於起始標誌[CLS];生成第二個單詞是,不僅依賴Encoder的輸出,還依賴起始標誌和第一個單詞……依此類推。這其實是說,在翻譯當前詞的時候,是看不到後面的要翻譯的詞。由上可以看出,這裏的mask是動態的。

def subsequent_mask(size):
    "Mask out subsequent positions."
    # size: 序列長度
    attn_shape = (1, size, size)
    # 生成一個上三角矩陣
    subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')
    return torch.from_numpy(subsequent_mask) == 0

下面詳細介紹下subsequent_mask是如何起作用的。函數的參數size指的是target句子的長度。以”[CLS] That is it“這個長度爲4的target輸入爲例,這個函數的輸出是什麼呢?

print(subsequent_mask(size=4))

tensor([[[1, 0, 0, 0],
         [1, 1, 0, 0],
         [1, 1, 1, 0],
         [1, 1, 1, 1]]], dtype=torch.uint8)

可以看到,輸出爲一個下三角矩陣,維度爲(1,4,4)。現在我們再來看下attention函數,mask起作用的地方是在Query和Key點乘後,結果矩陣的維度爲(batch_size, heads, max_seq_len, max_seq_len)。爲方便起見,我們只看一條數據,即batch_size=1。進入多頭attention時,注意到對mask做了一步操作:

mask = mask.unsqueeze(1)
mask:
tensor([[[[1, 0, 0, 0],
          [1, 1, 0, 0],
          [1, 1, 1, 0],
          [1, 1, 1, 1]]]], dtype=torch.uint8)

這時mask的維度變成了(1,1,4,4).

target:
         CLS  That is   it
 CLS [[[[0.8, 0.2, 0.3, 0.9]
 That   [0.2, 0.9, 0.6, 0.4]
 is     [0.3, 0.6, 0.8, 0.7]
 it     [1.2, 0.6, 2.1, 3.2]]]]

mask:
       [[[[1,  0,   0,  0],
          [1,  1,   0,  0],
          [1,  1,   1,  0],
          [1,  1,   1,  1]]]]

寫成了上面的樣子,mask的作用就很顯然了。例如,對於”CLS“來說,預測它下一個詞時,只有”CLS“參與了attention,其他的詞(相對於CLS爲未來的詞)都被mask_fill掉了,不起作用。後面的情況依此類推。

細心的小夥伴可能發現了,這裏的解釋並沒有考慮padding部分。事實上,就算加了padding部分(爲0),也不影響上述過程,有興趣的話可以在上面it後面加上個0,下面的矩陣加一列[0 0 0 0 ], 就可以一目瞭然。

 

class Decoder(nn.Module):
    "Generic N layer decoder with masking."
    def __init__(self, layer, N):
        super(Decoder, self).__init__()
        self.layers = clones(layer, N)
        self.norm = LayerNorm(layer.size)
    def forward(self, x, memory, src_mask, tgt_mask):
        for layer in self.layers:
            x = layer(x, memory, src_mask, tgt_mask)
        return self.norm(x)

每個組件長什麼樣子呢?首先輸入經過詞向量和位置編碼,進入target的自注意力層,這裏和Encoder一樣,也是用了殘差和層歸一化。然後呢,這個輸出再和Encoder的輸出做一次context attention,相當於把上面的那層重複了一次,唯一不同的是,這次的attention有點不一樣的,不再是自注意力,所有的技術細節都可以參照Encoder部分,這裏不再複述。

class DecoderLayer(nn.Module):
    "Decoder is made of self-attn, src-attn, and feed forward (defined below)"
    def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
        super(DecoderLayer, self).__init__()
        self.size = size
        self.self_attn = self_attn
        self.src_attn = src_attn
        self.feed_forward = feed_forward
        self.sublayer = clones(SublayerConnection(size, dropout), 3)

    def forward(self, x, memory, src_mask, tgt_mask):
        m = memory
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
        x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
        return self.sublayer[2](x, self.feed_forward)

3.2 線性層和softmax

這是整個模型的最後一步了。從Decoder拿到的輸出是維度爲(batch_size, max_seq_len, d_model)的浮點型張量,我們希望得到最終每個單詞預測的結果,首先用一個線性層將d_model映射到vocab的維度,得到每個單詞的可能性,然後送入softmax,找到最可能的單詞。

線性層的參數個數爲d_mode ⋆⋆ vocab_size, 一般來說,vocab_size會比較大,拿20000爲例,那麼只這層的參數就有512⋆20000512⋆20000個,約爲10的8次方,非常驚人。而在詞向量那一層,同樣也是這個數值,所以,一種比較好的做法是將這兩個全連接層的參數共享,會節省不少內存,而且效果也不會差。

class Generator(nn.Module):
    "Define standard linear + softmax generation step."
    def __init__(self, d_model, vocab):
        super(Generator, self).__init__()
        self.proj = nn.Linear(d_model, vocab)
    def forward(self, x):
        return F.log_softmax(self.proj(x), dim=-1)

3.3 完整模型代碼

def transformer_model(src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):
    "Helper: Construct a model from hyperparameters."
    c = copy.deepcopy
    attn = MultiHeadedAttention(h, d_model)
    ff = PositionwiseFeedForward(d_model, d_ff, dropout)
    position = PositionalEncoding(d_model, dropout)
    model = EncoderDecoder(
        Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
        Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
        nn.Sequential(Embeddings(d_model, src_vocab), c(position)),
        nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),
        Generator(d_model, tgt_vocab))
    
    # This was important from their code. 
    # Initialize parameters with Glorot / fan_avg.
    for p in model.parameters():
        if p.dim() > 1:
            nn.init.xavier_uniform(p)
    return model

model = transformer_model(10, 10, 2)

四、相關問題

4.1 Transformer爲什麼需要進行Multi-head Attention?

原論文中說到進行Multi-head Attention的原因是將模型分爲多個頭,形成多個子空間,可以讓模型去關注不同方面的信息,最後再將各個方面的信息綜合起來。其實直觀上也可以想到,如果自己設計這樣的一個模型,必然也不會只做一次attention,多次attention綜合的結果至少能夠起到增強模型的作用,也可以類比CNN中同時使用多個卷積核的作用,直觀上講,多頭的注意力有助於網絡捕捉到更豐富的特徵/信息

4.2 Transformer相比於RNN/LSTM,有什麼優勢?爲什麼?

  • RNN系列的模型,並行計算能力很差。RNN並行計算的問題就出在這裏,因爲 T 時刻的計算依賴 T-1 時刻的隱層計算結果,而 T-1 時刻的計算依賴 T-2 時刻的隱層計算結果,如此下去就形成了所謂的序列依賴關係。

  • Transformer的特徵抽取能力比RNN系列的模型要好。

    具體實驗對比可以參考:放棄幻想,全面擁抱Transformer:自然語言處理三大特徵抽取器(CNN/RNN/TF)比較

    但是值得注意的是,並不是說Transformer就能夠完全替代RNN系列的模型了,任何模型都有其適用範圍,同樣的,RNN系列模型在很多任務上還是首選,熟悉各種模型的內部原理,知其然且知其所以然,才能遇到新任務時,快速分析這時候該用什麼樣的模型,該怎麼做好。

4.3 爲什麼說Transformer可以代替seq2seq?

seq2seq缺點:這裏用代替這個詞略顯不妥當,seq2seq雖已老,但始終還是有其用武之地,seq2seq最大的問題在於將Encoder端的所有信息壓縮到一個固定長度的向量中,並將其作爲Decoder端首個隱藏狀態的輸入,來預測Decoder端第一個單詞(token)的隱藏狀態。在輸入序列比較長的時候,這樣做顯然會損失Encoder端的很多信息,而且這樣一股腦的把該固定向量送入Decoder端,Decoder端不能夠關注到其想要關注的信息。

Transformer優點:transformer不但對seq2seq模型這兩點缺點有了實質性的改進(多頭交互式attention模塊),而且還引入了self-attention模塊,讓源序列和目標序列首先“自關聯”起來,這樣的話,源序列和目標序列自身的embedding表示所蘊含的信息更加豐富,而且後續的FFN層也增強了模型的表達能力,並且Transformer並行計算的能力是遠遠超過seq2seq系列的模型,因此我認爲這是transformer優於seq2seq模型的地方。

4.4 Transformer如何並行化的?

Transformer的並行化我認爲主要體現在self-attention模塊,在Encoder端Transformer可以並行處理整個序列,並得到整個輸入序列經過Encoder端的輸出,在self-attention模塊,對於某個序列x1,x2,…,xn,self-attention模塊可以直接計算xi,xj的點乘結果,而RNN系列的模型就必須按照順序從x1​計算到xn。

4.5 訓練-模型的參數在哪裏?

Transformer的工作流程就是上面介紹的每一個子流程的拼接

  • 輸入的詞向量首先疊加上Positional Encoding,然後輸入至Transformer內
  • 每個Encoder Transformer會進行一次Multi-head self attention->Add & Normalize->FFN->Add & Normalize流程,然後將輸出輸入至下一個Encoder中
  • 最後一個Encoder的輸出將會作爲memory保留
  • 每個Decoder Transformer會進行一次Masked Multi-head self attention->Multi-head self attention->Add & Normalize->FFN->Add & Normalize流程,其中Multi-head self attention時的K、V至來自於Encoder的memory。根據任務要求輸出需要的最後一層Embedding。
  • Transformer的輸出向量可以用來做各種下游任務

Encoder端可以並行計算,一次性將輸入序列全部encoding出來,但Decoder端不是一次性把所有單詞(token)預測出來的,而是像seq2seq一樣一個接着一個預測出來的。

 

transformer的核心點積是沒有參數,transformer結構的訓練,會優化的參數主要在:

  1. 嵌入層-Word Embedding
  2. 前饋(Feed Forward)層
  3. 多頭注意力中的“切片”操作(映射成多個/頭小向量)實際是一個全連接層(線性映射矩陣),以及多頭輸出拼接結果(Concat)後會經過一個Linear全連接層。這兩個全連接層也是殘差塊有意義的地方,如果沒有這一層,那這個注意力機制中就沒有參數,殘差就沒有意義了。

 

GitHub鏈接:https://github.com/harvardnlp/annotated-transformer

代碼解讀:Transformer解析與tensorflow代碼解讀

https://github.com/Kyubyong/transformer

 

參考文獻




 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章