Step-by-step to Transformer:深入解析工作原理(以Pytorch机器翻译为例)
大家好,我是青青山螺應如是,大家可以叫我青青,工作之余是一名獨立攝影師。喜歡美食、旅行、看展,偶爾整理下NLP學習筆記,不管技術文還是生活隨感,都會分享本人攝影作品,希望文藝的技術青年能夠喜歡~~如果有想拍寫真的妹子也可以在個人公號【青影三弄】留言~
Photograhy?Sharing
拍攝參數? ?|? ?f2.8/ 190mm/ ISO1500
設備? ?|? ?Canon6D2 / 70-200mm f2.8L III USM? ?
先分享一張在佛羅倫薩的人文攝影~
今年去意呆的時候特別熱,每天都是白晃晃的大太陽,所以我總喜歡躲到附近的教堂,那里是“免費的避暑勝地”。
“諸圣教堂”離我住處很近,當時遇到神父禱告,他還緩緩唱了首歌,第一次覺得美聲如此動人,怪不得意呆人那么熱愛歌劇,連我這種音樂小白都被感染到了~
喜歡“諸圣教堂”的另一個原因是這里埋葬著基爾蘭達約、波提切利和他愛慕的女神西蒙內塔。生前“小桶”因她創作了《維納斯的誕生》,離開人世他又得償所愿和心愛之人共眠。情深如此,我能想到的中國式浪漫大概也只有“庭有枇杷樹,吾妻死之年所手植也,今已亭亭如蓋矣”相比了..
AI sharing
之前介紹過Seq2Seq+SoftAttention這種序列模型實現機器翻譯,那么拋棄RNN,全面擁抱attention的transformer又是如何實現的呢。
本篇介紹Transformer的原理及Pytorch實現,包括一些細枝末節的trick和個人感悟,這些都是在調試代碼過程中深切領會的。網上查了很多文章,大部分基于哈佛那片論文注釋,數據集來源于tochtext自帶的英-德翻譯,但是本篇為了和上面攝影分享對應以及靈活的自定義數據集,采用意大利-英語翻譯。
CONTENT
1、Transformer簡介
2、模型概覽
3、數據加載及預處理
?? ? ?3.1原始數據構造DataFrame
? ? ? 3.2自定義Dataset
? ? ? 3.3構建字典
? ? ??3.4Iterator實現動態批量化
? ? ??3.5生成mask
4、Embedding層
? ? ??4.1普通Embedding
? ? ??4.2位置PositionalEncoding
? ? ??4.3層歸一化
5、SubLayer子層組成
? ? ??5.1MulHeadAttention(self+context attention)
? ? ? ? ? ?self attention
? ? ? ? ? ?attention score:scaled dot product
? ? ? ? ? ?multi head
? ? ??5.2Position-wise Feed-forward前饋傳播
? ? ??5.3Residual Connection殘差連接
6、Encoder組合
7、Decoder組合
8、損失函數和優化器
? ? ?8.1損失函數實現標簽平滑
? ? ?8.2優化器實現動態學習率
9、模型訓練Train
10、測試生成
11、注意力分布可視化
12、數學原理解釋transformer和rnn本質區別
【資料索取】
公眾號回復:Transformer
可獲取完整代碼
1.?Transfomer簡介
《Attention Is All Your Need》是一篇Google提出全面使用self-Attention的論文。這篇論文中提出一個全新的模型,叫 Transformer,拋棄了以往深度學習任務里面使用到的 CNN 和 RNN。目前大熱的Bert就是基于Transformer構建的,這個模型廣泛應用于NLP領域,例如機器翻譯,問答系統,文本摘要和語音識別等等方向。
眾所周知RNN雖然模型設計小巧精妙,但是其線性序列模型決定了無法實現并行,從兩個任意輸入和輸出位置獲取依賴關系都需要大量的運算,運算量嚴重受到距離的制約;而且距離不但影響性能也影響效果,隨著記憶時序的拉長,記憶削弱,導致學習能力削弱。
為了拋棄RNN step by step線性時序,Transformer使用了可以biself-attention,不依靠順序和距離就能獲得兩個位置(實質是key和value)的依賴關系(hidden)。這種計算成本減少到一個固定的運算量,雖然注意力加權會減少有效的resolution表征力,但是使用多頭multi-head attention可以彌補平均注意力加權帶來的損失。
自注意力是一種關注自身序列不同位置的注意力機制,能計算序列的表征representation。
和之前分享的Seq2Seq+SoftAttention相比,Transformer不僅關注encoder和decoder之間的attention,也關注encoder和decoder各自內部自己的attention。也就是說前者的hidden是靠lstm來實現,而transformer的encoder或者decoder的hidden是靠self-attention來實現。
2.?模型概覽
Transformer結構和Seq2Seq模型是一樣的,也采用了Encoder-Decoder結構,但Transformer更復雜。
2.1宏觀組成
Encoder由6個EncoderLayer構成,Decoder由6個DecoderLayer構成:
對應的代碼邏輯如下,make_model包含EncoderDecoder模塊,可以看到N=6表示Encoder和Decoder的子層數量,d_ff是前饋神經網絡的中間隱層維度,h=代表的是注意力層的頭數。
后面還規定了初始化的策略,如果每層參數維度大于1,那么初始化服從均勻分布init.xavier_uniform
EncoderDecoder里面除了Encoder和Decoder兩個模塊,還包含embed和generator。Embed層是對對輸入進行初始化,詞嵌入包含普通的Embeddings和位置標記PositionEncoding;Generator作用是對輸出進行full linear+softmax
其中可以看到Decoder輸入的memory就是來自前面Encoder的輸出,memory會分別喂入Decoder的6個子層。
class EncoderDecoder(nn.Module): def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):super(EncoderDecoder, self).__init__()self.encoder = encoderself.decoder = decoderself.src_embed = src_embedself.tgt_embed = tgt_embedself.generator = generatordef forward(self, src, tgt, src_mask, tgt_mask):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) class Generator(nn.Module):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)2.2內部結構
上面是Transformer宏觀上的結構,那Encoder和Decoder內部都有哪些不同于Seq2Seq的技術細節呢:
(1)Encoder的輸入序列經過word embedding和positional encoding后,輸入到encoder。
(2)在EncoderLayer里面先經過8個頭的self-attention模塊處理source序列自身。這個模塊目的是求得序列的hidden,利用的就是自注意力機制,而非之前RNN需要step by step算出每個hidden。然后經過一些norm和drop基本處理,再使用殘差連接模塊,目的是為了避免梯度消失問題。(后面代碼實現和上圖在實現順序上有一點出入)
(3)在EncoderLayer里面再進入Feed-Forward前饋神經網絡,實際上就是做了兩次dense,linear2(activation(linear1))。然后同上經過一些norm和drop基本處理,再使用殘差連接模塊。
(4)Decoder的輸入序列處理方式同上
(5)在DecoderLayer里面也要經過8個頭的self-attentention模塊處理target序列自身。不同于Encoder層,這里只需要關注輸入時刻t之前的部分,目的是為了符合decoder看不到未來信息的邏輯,所以這里的mask是融合了pad-mask和sequence-mask兩種。同Encoder,這個模塊目的也是為了求得target序列自身的hidden,然后經過一些norm和drop基本處理,再使用殘差連接模塊。
(6)在DecoderLayer里面再進入src-attention模塊,這個模塊也是相比Encoder增加的注意力層。其實注意力結構都是相似的,只是(query,key,value)不同,對于self-attention這三個值都是一致的,對于src-attention,query來自decoder的hidden,key和value來自encoder的hidden
(7)在DecoderLayer里面最后進入Feed-Forward前饋神經網絡,同上。
介紹完Transformer整體結構,下面從數據集處理到各層代碼實現細節進行詳細說明~
3.?數據加載及預處理
GPU環境使用Google Colab 單核16g,數據集eng-ita.txt,普通的英意翻譯對的文本數據。數據集預處理使用的是torchtext+spacy工具,他使用的整體思路是構造Dataset,字典、Iterator實現批量化、對矩陣進行mask pad。
數據預處理非常重要,這里涉及很多提高訓練性能的trick。下面具體看一下如何使用自定義數據集來完成這些預處理步驟。
3.1原始數據構造DataFrame
先加載文本,并將source和target兩列轉換為兩個獨立的list:
因為torchtext的dataset的輸入需要DataFrame格式,所以這里先利用上面的source和target list構造DataFrame。訓練集、驗證集、測試集按照實際要求進行劃分:
3.2構造Dataset
這里主要包含分詞、指定起止符和補全字符以及限制序列最大長度。
因為torchtext的Dataset是由example組成,example的含義就是一條翻譯對記錄。
# 分詞 spacy_it = spacy.load('it') spacy_en = spacy.load('en') def tokenize_it(text):return [tok.text for tok in spacy_it.tokenizer(text)] def tokenize_en(text):return [tok.text for tok in spacy_en.tokenizer(text)] # 定義FIELD配置信息 # 主要包含以下數據預處理的配置信息,比如指定分詞方法,是否轉成小寫,起始字符,結束字符,補全字符以及詞典等等 BOS_WORD = '<s>' EOS_WORD = '</s>' BLANK_WORD = "<blank>" SRC = data.Field(tokenize=tokenize_it, pad_token=BLANK_WORD) TGT = data.Field(tokenize=tokenize_en, init_token=BOS_WORD, eos_token=EOS_WORD, pad_token=BLANK_WORD) # get_dataset構造并返回Dataset所需的examples和fields def get_dataset(csv_data, text_field, label_field, test=False):fields = [('id', None), ('src', text_field), ('trg', label_field)]examples = []if test:for text in tqdm(csv_data['src']): # tqdm的作用是添加進度條examples.append(data.Example.fromlist([None, text, None], fields))else:for text, label in tqdm(zip(csv_data['src'], csv_data['trg'])):examples.append(data.Example.fromlist([None, text, label], fields))return examples, fields # 得到構建Dataset所需的examples和fields train_examples, train_fields = get_dataset(train_df, SRC, TGT) valid_examples, valid_fields = get_dataset(valid_df, SRC, TGT) test_examples, test_fields = get_dataset(test_df, SRC, None, True) 構建Dataset數據集 # 構建Dataset數據集 # 這里的ita最大長度也就56 MAX_LEN = 100 train = data.Dataset(train_examples,train_fields,filter_pred=lambda x: len(vars(x)['src'])<= MAX_LEN and len(vars(x)['trg']) <= MAX_LEN) valid = data.Dataset(valid_examples, valid_fields,filter_pred=lambda x: len(vars(x)['src']) <= MAX_LEN and len(vars(x)['trg']) <= MAX_LEN) test = data.Dataset(test_examples, test_fields,filter_pred=lambda x: len(vars(x)['src']) <= MAX_LEN)3.3構造字典
MIN_FREQ = 2 #統計字典時要考慮詞頻 SRC.build_vocab(train.src, min_freq=MIN_FREQ) TGT.build_vocab(train.trg, min_freq=MIN_FREQ)3.4構造Iterator
torchtext的Iterator主要負責把Dataset進行批量劃分、字符轉數字、矩陣pad。
這里有個很重要的點就是批量化,本例使用的是動態批量化,即每個batch的批大小是不同的,是以每個batch的token數量作為統一劃分標準,也就是說每個batch的token數基本一致,這個機制是通過batch_size_fn來實現的,比如batch1[16,20],batch2是[8,40],兩者的批大小是不同的分別是16和8,但是token總數是一樣的都是320。
采取這種方式的特點就是他會把長度相同序列的聚集到一起,然后進行pad,從而減少了pad的比例,為什么要減少pad呢:
(1)padding是對計算資源的浪費,pad越多訓練耗費的時間越長。
(2)padding的計算會引入噪聲,nsformer 中,LayerNorm 會使 padding 位置的值變為非0,這會使每個 padding 都會有梯度,引起不必要的權重更新。
下圖是隨意組織pad的batch(paddings)和長度相近原則組織pad的batch(baseline)
實現代碼部分,可以看到這里MyIterator實現了data.Iterator的create_batch()函數,其實真正起作用的是里面的torchtext.data.batch()函數部分,他的功能是把原始的字符數據按照batch_size_fn算法來進行batch并且shuffle,沒有做數字化也沒有做pad。
class MyIterator(data.Iterator):def create_batches(self):if self.train:def pool(d, random_shuffler):for p in data.batch(d, self.batch_size * 100):p_batch = data.batch(sorted(p, key=self.sort_key),self.batch_size, self.batch_size_fn)for b in random_shuffler(list(p_batch)):yield bself.batches = pool(self.data(), self.random_shuffler)else:self.batches = []for b in data.batch(self.data(), self.batch_size,self.batch_size_fn):self.batches.append(sorted(b, key=self.sort_key)) global max_src_in_batch, max_tgt_in_batch def batch_size_fn(new, count, sofar):global max_src_in_batch, max_tgt_in_batchif count == 1:max_src_in_batch = 0max_tgt_in_batch = 0max_src_in_batch = max(max_src_in_batch, len(new.src))max_tgt_in_batch = max(max_tgt_in_batch, len(new.trg) + 2)src_elements = count * max_src_in_batchtgt_elements = count * max_tgt_in_batchreturn max(src_elements, tgt_elements)那么什么時候對batch進行數字化和pad呢,看了下源碼,實際這些操作封裝在data.Iterator.__iter__的torchtext.data.Batch這個類中。
這里還有一個問題,BATCH_SIZE這個參數設置多少合適呢,這個參數在這里的含義代表每個batch的token總量,我測試了下單核16G的colabGPU訓練環境需要6000,如果超過這個數值,內存容易爆。
BATCH_SIZE = 6000 train_iter = MyIterator(train, batch_size=BATCH_SIZE, device=0, repeat=False,sort_key=lambda x: (len(x.src), len(x.trg)),batch_size_fn=batch_size_fn, train=True) valid_iter = MyIterator(valid, batch_size=BATCH_SIZE, device=0, repeat=False,sort_key=lambda x: (len(x.src), len(x.trg)),batch_size_fn=batch_size_fn, train=False)3.5生成mask
我們知道整個模型的輸入就是src,src_mask,tgt,tgt_mask,現在src和tgt已經比較明確了,那mask部分呢,總的來說mask就是對上面的pad部分做一個統計行程對應的mask矩陣。
但是前面在講整體結構的時候提到,Decoder的mask比Encoder的mask多一層含義,就是sequence_mask,下面說下這兩類mask。
(1)padding mask
Seq2Seq+SoftAttention里面計算的mask就是padding mask。每個批次輸入序列長度是不一樣的,要對輸入序列進行對齊。具體來說,就是給在較短的序列后面填充0。因為這些填充的位置,其實是沒什么意義的,所以我們的attention機制不應該把注意力放在這些位置上,所以我們需要進行一些處理。具體的做法是,把這些位置的值機上一個非常大的復數,經過softmax這些位置就會接近0。而我們的padding mask實際上是一個張量,每個值都是一個Bool,值為False的地方就是我們要進行處理的地方。
(2)sequence mask
自然語言生成(例如機器翻譯,文本摘要)是auto-regressive的,在推理的時候只能依據之前的token生成當前時刻的token,正因為生成當前時刻的token的時候并不知道后續的token長什么樣,所以為了保持訓練和推理的一致性,訓練的時候也不能利用后續的token來生成當前時刻的token。這種方式也符合人類在自然語言生成中的思維方式。
那么具體怎么做呢,也很簡單,產生一個上三角矩陣,上三角的值全為1,下三角的值全為0,對角線也是0。把這個矩陣作用在每一個序列上,就達到我們的目的。如圖:
總結一下transformer里面的mask使用情況:
* encoder里的self-attention使用的是padding mask
* decoder里面的self-attention使用的是padding mask+sequence mask;context-attention使用的是padding mask
下面看代碼來實現上面兩種mask,其中make_std_mask里面實現了pad mask+sequence mask;
除了mask,代碼還對target部分做了處理,self.trg表示輸入,self.trg表示最終loss里面標簽角色,比self.trg往后挪一列。
class BatchMask:def __init__(self, src, trg=None, pad=0):self.src = srcself.src_mask = (src != pad).unsqueeze(-2)if trg is not None:self.trg = trg[:, :-1]self.trg_y = trg[:, 1:]self.trg_mask = \self.make_std_mask(self.trg, pad)self.ntokens = (self.trg_y != pad).data.sum()@staticmethoddef make_std_mask(tgt, pad):tgt_mask = (tgt != pad).unsqueeze(-2)tgt_mask = tgt_mask & Variable(subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data))return tgt_mask def subsequent_mask(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 def batch_mask(pad_idx, batch):src, trg = batch.src.transpose(0, 1), batch.trg.transpose(0, 1)return BatchMask(src, trg, pad_idx) pad_idx = TGT.vocab.stoi["<blank>"]4. Embedding層
這一部分針對輸入模型的數據的詞嵌入處理,主要包含三個過程:普通詞嵌入word Embeddings、位置編碼PositionalEncoding、層歸一化LayerNorm。
(word Embedding是對詞匯本身編碼;Positional encoding是對詞匯的位置編碼)
4.1普通Embedding
初始化embedding matrix,通過embedding lookup將Inputs映射成token embedding,大小是[batch size, max seq length, embedding size],然后乘以embedding size的開方。那么這里為什么要乘以√dmodel ? ?論文并沒有講為什么這么做,我看了代碼,猜測是因為embedding matrix的初始化方式是xavier init,這種方式的方差是1/embedding size,因此乘以embedding size的開方使得embedding matrix的方差是1,在這個scale下可能更有利于embedding matrix的收斂。
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_modeldef forward(self, x):return self.lut(x) * math.sqrt(self.d_model)4.2位置編碼PositionalEncoding
我們知道RNN使用了step by step這種時序算法保持了序列本有的順序特征,但缺點是無法串行降低了性能,transformer的主要思想self-attention在本質上拋棄了rnn這種時序特征,也就拋棄了所謂的序列順序特征,如果缺失了序列順序這個重要信息,那么結果就是所有詞語都對了,但是就是無法組成有意義的語句。那么他是怎么彌補的呢?
為了處理這個問題,transformer給encoder層和decoder層的輸入添加了一個額外的向量Positional Encoding,就是位置編碼,維度和embedding的維度一樣,這個向量采用了一種很獨特的方法來讓模型學習到這個值,這個向量能決定當前詞的位置,或者說在一個句子中不同的詞之間的距離。
這個位置向量的具體計算方法有很多種,論文中的計算方法如下sinusoidal version,這里的2i就是指的d_model這個維度上的,和pos不是一回事,pos就是可以自己定義1-5000的序列,每個數字代表一個序列位置:
上式右端表達式中pos下面的除數如果使用exp來表示的話,手寫推導如下:
代碼表示:
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)
其中pos是指當前詞在句子中的位置,i是指向量d_model中每個值的index,可以看出,在d_model偶數位置index,使用正弦編碼,在奇數位置,使用余弦編碼。上面公式的dmodel就是模型的維度,論文默認是512。
這個編碼的公式的意思就是:給定詞語的位置pos,我們可以把它編碼成dmodel維的向量,也就是說位置編碼的每個維度對應正弦曲線,波長構成了從2π到10000*2π的等比序列。上面的位置編碼是絕對位置編碼,但是詞語的相對位置也非常重要,這就是論文為什么使用三角函數的原因。
正弦函數能夠表達相對位置信息。主要數學依據是以下兩個公式,對于詞匯之間的位置偏移k,PE(pos+k)可以表示成PE(pos)和PE(k)的組合形式,這就是表達相對位置的能力:
可視化位置編碼效果如下,橫軸是位置序列seq_len這個維度,豎軸是d_model這個維度:
代碼中加入了dropout,具體實現如下。self.register_buffer可以將tensor注冊成buffer,網絡存儲時也會將buffer存下,當網絡load模型是,會將存儲模型的buffer也進行賦值,buffer在forward中更新而不再梯度下降中更新,optim.step只能更新nn.Parameter類型參數。
class PositionalEncoding(nn.Module):def __init__(self, d_model, dropout, max_len=5000):super(PositionalEncoding, self).__init__()self.dropout = nn.Dropout(p=dropout)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)4.3層歸一化
層歸一化layernorm是一種基礎數據里手段,作用于輸入和輸出,那么本例都什么時候用到呢,一個是source和target數據經過詞嵌入后進入子層前要進行層歸一化;另一個就是encoder和decoder模塊輸出的時候要進行層歸一化。
layernorm不同于batchnorm,他是在d_model這個維度上計算平均值和方差,公式如下:
可以看到這里引入了參數α和β,所以可以使用torch里面的nn. Parameter,他的作用就是初始化一個可進行訓練優化的參數,并將這個參數綁定到module里面。
代碼如下:
class LayerNorm(nn.Module):def __init__(self, features, eps=1e-6):super(LayerNorm, self).__init__()self.a_2 = nn.Parameter(torch.ones(features))self.b_2 = nn.Parameter(torch.zeros(features))self.eps = epsdef forward(self, x):mean = x.mean(-1, keepdim=True)std = x.std(-1, keepdim=True)return self.a_2 * (x - mean) / (std + self.eps) + self.b_25. SubLayer子層組成
這里的sublayer存在于每個encoderlayer和decoderlayer中,是公用的部分,encoderlayer里面有兩個子層(mulhead-self attention/feed-forward)靠殘差層連接;decoderlayer里面有三個子層(mulhead-self attention/mulhad-context attention/feed-forward)。
這里的mulhead-self attention和mulhad-context attention是整個transformer最核心的部分。前者是自注意力機制,出現在encoder或decoder內部序列自身學習hidden;后者上下文注意力機制,相當于之前分享文章里的Seq2Seq+softattention,是encoder和decoder之間的注意力為了學習context。這兩種注意力機制結構實際上是相同的,不同的在于輸入部分(query,key,value):self-attention的三個數值都是一致的;而contex-attention的query來自decoder,key和value來自encoder。
5.1多頭MuHead Attention(self+context attention)
由于之前分享的文章詳細講解過context-attention(相當于soft-attention),所以這里詳細說明self-attention和multi-head兩種機制。
(1)self-attention
我們看個例子:
The animal didn't cross the street because it was too tired
這里的 it 到底代表的是 animal 還是 street 呢,對于人來說能很簡單的判斷出來,但是對于機器來說,是很難判斷的,self-attention就能夠讓機器把 it 和 animal 聯系起來,接下來我們看下詳細的處理過程。
首先,self-attention會計算出三個新的向量,在論文中,向量的維度是512維,我們把這三個向量分別稱為Query、Key、Value,這三個向量是用embedding向量與一個參數矩陣W相乘得到的結果,這個矩陣是隨機初始化的。
計算self-attention的分數值,該分數值決定了當我們在某個位置encode一個詞時,對輸入句子的其他部分的關注程度。這個分數值的計算方法是Query與Key做點成,以下圖為例,首先我們需要針對Thinking這個詞,計算出其他詞對于該詞的一個分數值,首先是針對于自己本身即q1·k1,然后是針對于第二個詞即q1·k2。
接下來,把點成的結果除以一個常數,這里我們除以8,這個值一般是采用上文提到的矩陣的第一個維度的開方即64的開方8,當然也可以選擇其他的值,然后把得到的結果做一個softmax的計算。得到的結果即是每個詞對于當前位置的詞的相關性大小,當然,當前位置的詞相關性肯定會會很大。
下一步就是把Value和softmax得到的值進行相乘,并相加,得到的結果即是self-attetion在當前節點的值。
在實際的應用場景,為了提高計算速度,我們采用的是矩陣的方式,直接計算出Query, Key, Value的矩陣,然后把embedding的值與三個矩陣直接相乘,把得到的新矩陣 Q 與 K 相乘,乘以一個常數,做softmax操作,最后乘上 V 矩陣。
這種通過 query 和 key 的相似性程度來確定 value 的權重分布的方法被稱為scaled dot-product attention。
結構圖如下:
(2)attention score:scaled dot-product
那么這里Transformer為什么要使用scaled dot-product來計算attention score呢?論文的描述含義就是通過確定Q和K之間的相似度來選擇V,公式:
scaled dot-product attention和dot-product attention唯一的區別就是,scaled dot-product attention有一個縮放因子:
上面公式中的dk表示的k的維度,在論文里面默認是64。為什么需要加上這個縮放因子呢,論文解釋:對于dk很大的時候,點積得到結果量級很大,方差很大,使得處于softmax函數梯度很小的區域。我們知道,梯度很小的情況,對反向傳播不利。下面簡單的測試反映出不同量級對,最大值的概率變化。
f?=?lambda?x:?exp(6*x)?/?(exp(2*x)+exp(2*x+1)+exp(3*x)+exp(4*x)+exp(5*x+4)+exp(6*x))
x?=?np.linspace(0,?30,?100)
y_3?=?[f(x_i)?for?x_i?in?x]
plt.plot(x,?y_3)
plt.show()
可以看到f是softmax的最大值6x的曲線,當x處于1~50之間不同的量級的時候,所表示的概率,當x量級在>7的時候,差不多其分配的概率就接近1了。也就是說輸入量級很大的時候,就會造成梯度消失。
為了克服這個負面影響,除以一個縮放因子可以一定程度上減緩這種情況。點積除以√dmodel ? ,將控制方差為1,也就有效的控制了梯度消失的問題。
注意部分的代碼表示:
def attention(query, key, value, mask=None, dropout=None):d_k = query.size(-1)scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)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)context = torch.matmul(p_attn, value)return context, p_attn(3)multi-head
這篇論文另一個牛的地方是給attention加入另外一個機制——multi head,該機制理解起來很簡單,就是說不僅僅只初始化一組Q、K、V的矩陣,而是初始化多組,tranformer是使用了8組,所以最后得到的結果是8個矩陣。
論文提到,他們發現將Q、K、V通過一個線性映射之后,分成h份,對每一份進行scaled dot-product attention效果更好。然后,把各個部分的結果合并起來,再次經過線性映射,得到最終的輸出。這就是所謂的multi-head attention。上面的超參數h就是heads數量。論文默認是8。
下面是multi-head attention的結構圖。可以看到QKV在輸入前后都有線性變換,總共有四次,上面dk=64=512/8
代碼表示:
原論文中說到進行Multi-head Attention的原因是將模型分為多個頭,形成多個子空間,可以讓模型去關注不同方面的信息,最后再將各個方面的信息綜合起來。其實直觀上也可以想到,如果自己設計這樣的一個模型,必然也不會只做一次attention,多次attention綜合的結果至少能夠起到增強模型的作用,也可以類比CNN中同時使用多個卷積核的作用,直觀上講,多頭的注意力有助于網絡捕捉到更豐富的特征/信息。
5.2Position-wise Feed-forward前饋傳播
這一層很簡單,就是一個全連接網絡,包含兩個線性變換和一個非線性函數Relu;
代碼:
class PositionwiseFeedForward(nn.Module):# 這里input和output都是d_model,中間層維度是d_ff,本例設置為2048def __init__(self, d_model, d_ff, dropout=0.1):super(PositionwiseFeedForward, self).__init__()self.w_1 = nn.Linear(d_model, d_ff)self.w_2 = nn.Linear(d_ff, d_model)self.dropout = nn.Dropout(dropout)def forward(self, x):# 兩次線性變換,第一次activation是relu,第二次沒有return self.w_2(self.dropout(F.relu(self.w_1(x))))這個線性變換在不同的位置(encoder or decoder)都表現地一樣,并且在不同的層之間使用不同的參數。論文提到,這個公式還可以用兩個核大小為1的一維卷積來解釋,卷積的輸入輸出都是dmodel=512,中間層的維度是dff=2048
那么為什么要在multi-attention后面加一個fnn呢,類比cnn網絡中,cnn block和fc交替連接,效果更好。相比于單獨的multi-head attention,在后面加一個ffn,可以提高整個block的非線性變換的能力。
5.3殘差連接ResidualConnec
殘差連接其實很簡單,在encoderlayer和decoderlayer里面都一樣,本文結構如下:
那么殘差結構有什么好處呢?顯而易見:因為增加了一項x,那么該層網絡對x求偏導的時候,多了一個常數項1!所以在反向傳播過程中,梯度連乘,也不會造成梯度消失!
文章開始的transformer架構圖中的Add & Norm中的Add也就是指的這個shortcut。
代碼如下:
class SublayerConnection(nn.Module):def __init__(self, size, dropout):super(SublayerConnection, self).__init__()self.norm = LayerNorm(size)self.dropout = nn.Dropout(dropout)def forward(self, x, sublayer): # 這里的x是指的輸入層src 需要對其進行歸一化norm_x = self.norm(x)sub_x = sublayer(norm_x)sub_x = self.dropout(sub_x)return x + sub_x6.?Encoder組合
EncoderLayer由上面兩個sublayer(multihead-selfattention和residualconnection)組成;Encoder由6個EncoderLayer組成。
代碼如下:
class EncoderLayer(nn.Module):def __init__(self, size, self_attn, feed_forward, dropout):super(EncoderLayer, self).__init__()self.self_attn = self_attnself.feed_forward = feed_forwardself.residual_conn = clones(SublayerConnection(size, dropout), 2)self.size = sizedef forward(self, x, mask):x = self.residual_conn[0](x, lambda x: self.self_attn(x, x, x, mask))return self.residual_conn[1](x, self.feed_forward) class Encoder(nn.Module):def __init__(self, layer, N):super(Encoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, mask):for layer in self.layers:x = layer(x, mask)return self.norm(x)7. Decoder組合
DecoderLayer由上面三個sublayer(multihead-selfattention、multihead-contextattention和residualconnection)組成;Encoder由6個EncoderLayer組成。
代碼如下:
class DecoderLayer(nn.Module):def __init__(self, size, self_attn, src_attn, feed_forward, dropout):super(DecoderLayer, self).__init__()self.size = sizeself.self_attn = self_attnself.src_attn = src_attnself.feed_forward = feed_forwardself.residual_conn = clones(SublayerConnection(size, dropout), 3)def forward(self, x, memory, src_mask, tgt_mask):m = memoryx = self.residual_conn[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))x = self.residual_conn[1](x, lambda x: self.src_attn(x, m, m, src_mask))return self.residual_conn[2](x, self.feed_forward) class Decoder(nn.Module):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)8. 損失函數和優化器
Transfomer里的損失函數引入標簽平滑的概念;梯度下降的優化器引入了動態學習率。下面詳細說明。
8.1損失函數實現標簽平滑
Transformer使用的標簽平滑技術屬于discount類型的平滑技術。這種算法簡單來說就是把最高點砍掉一點,多出來的概率平均分給所有人。
為什么要實現標簽平滑呢,其實就是增加困惑度perplexity,每個時間步都會在一個分布集合里面隨機挑詞,那么平均情況下挑多少個詞才能挑到正確的那個呢。多挑幾次那么就意味著困惑度越高使得模型不確定性增加,但是這樣子的好處是提高了模型精度和BLEU score。
在實際實現時,這里使用KL div loss實現標簽平滑。沒有使用one-hot目標分布,而是創建了一個分布,對于整個詞匯分布表,這個分布含有正確單詞度和剩余部分平滑塊的置信度。
代碼如下,簡單解釋下,一般來說損失函數的輸入crit(x,target),標簽平滑主要是在處理實際標簽target
(1)先使用clone來把target構造成和x一樣維度的矩陣
(2)然后使用fill_在上面的新矩陣里面填充平滑因子smoothing
(3)然后使用scatter_把confidence(1-smoothing)填充到上面的矩陣中,按照target index數值,填充到維度對應位置上。比如scatter_(1,(1,2,3),0.6) target新矩陣是(3,10) 那么就在10這個維度上找到index 1、2、3
(4)按照一定規則對target矩陣進行pad mask
(5)最后使用損失函數KLDivLoss相對熵,他是求兩個概率分布之間的差異,size_averge=False損失值是sum類型,也就是說求得所有token的loss總量。
class LabelSmoothing(nn.Module):def __init__(self, size, padding_idx, smoothing=0.0):super(LabelSmoothing, self).__init__()self.criterion = nn.KLDivLoss(size_average=False)self.padding_idx = padding_idxself.confidence = 1.0 - smoothingself.smoothing = smoothingself.size = sizeself.true_dist = Nonedef forward(self, x, target):assert x.size(1) == self.sizetrue_dist = x.data.clone()true_dist.fill_(self.smoothing / (self.size - 2))true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)true_dist[:, self.padding_idx] = 0mask = torch.nonzero(target.data == self.padding_idx)if mask.dim() > 0:true_dist.index_fill_(0, mask.squeeze(), 0.0)self.true_dist = true_distreturn self.criterion(x, Variable(true_dist, requires_grad=False))舉個簡單的例子,可視化感受下標簽平滑。深藍色的T形表示target被pad的部分,黃色部分是可信confidence部分,普藍色(顏色介于黃和深藍)代表模糊區間。
crit?=?LabelSmoothing(5,?1,?0.5)
predict?=torch.FloatTensor([[0.25,?0,?0.25,?0.25,?0.25],
???????????????[0.4,?0,?0.2,?0.2,?0.2],?
???????????????[0.625,?0,?0.125,?0.125,?0.125],
???????????????[0.25,?0,?0.25,?0.25,?0.25],
???????????????[0.4,?0,?0.2,?0.2,?0.2],?
???????????????[0.625,?0,?0.125,?0.125,?0.125]])
v?=?crit(Variable(predict.log()),Variable(torch.LongTensor([1,2,0,3,2,0])))
那么平滑率到底對loss下降曲線有什么影響呢,舉個簡單的例子看一下。可以看到當smooth越大,也就是說confidence越小,也就是標簽越模糊,loss下降效果反而更好。
crits?=?[LabelSmoothing(5,?0,?0.1),
?????LabelSmoothing(5,?0,?0.05),
?????LabelSmoothing(5,?0,?0),
?????nn.NLLLoss()
?????]
def?loss(x,crit):
????d?=?x?+?3?*?1
? ? predict?=?torch.FloatTensor([[0,?x/d,?1/d,?1/d,?1/d],])
? ? return?crit(Variable(predict.log()),Variable(torch.LongTensor([1]))).item()
plt.plot(np.arange(1,?100),?[[loss(x,crit)?for?crit?in?crits]?for?x?in?range(1,?100)])
plt.legend(["0.1","0.05","0","NLLoss"])
8.2優化器實現動態學習率
我們知道學習率是梯度下降的重要因素,隨著梯度的下降,使用動態變化的學習率,往往取的較好的效果。
這里的算法實現的是先warmup增大學習率,達到摸個合適的step再減小學習率。公式如下。:
可以看出來在開始的warmup steps(本例是8000)的時候學習率隨著step線性增加,然后學習率隨著步數的導數平方根step_num^(-0.5)成比例的減小,8000就是那個轉折點。
代碼如下。除去step,learningrate還和d_model,factor,warmup有關。這個優化器封裝了對lr的修改算法
class NoamOpt:def __init__(self, model_size, factor, warmup, optimizer):self.optimizer = optimizerself._step = 0self.warmup = warmupself.factor = factorself.model_size = model_sizeself._rate = 0def step(self):self._step += 1rate = self.rate()for p in self.optimizer.param_groups:p['lr'] = rateself._rate = rateself.optimizer.step()def rate(self, step=None):if step is None:step = self._stepreturn self.factor * \(self.model_size ** (-0.5) *min(step ** (-0.5), step * self.warmup ** (-1.5)))下面把影響學習率變化的三個超參數model_size/factor/warmup進行可視化:
opts?=?[NoamOpt(512,?1,?4000,?None),?
? ? ?NoamOpt(512,?2,?4000,?None),?
?????NoamOpt(512,?2,?8000,?None),
?????NoamOpt(512,?1,?8000,?None),
?????NoamOpt(256,?1,?4000,?None)]
plt.plot(np.arange(1,?20000),?[[opt.rate(i)?for?opt?in?opts]?for?i?in?range(1,?20000)])
plt.legend(["512:1:4000","512:2:4000","512:2:8000","512:1:8000",?"256:1:4000"])
可以看到,隨著step的增加,可以看到學習率隨著三個超參數的變化曲線:
(1)d_model越小,學習率峰值越大;
(2)factor越大,學習率峰值越大
(3)warmupsteps越大,學習率的峰值越往后推遲,且學習率峰值相對降低一些
【本文采取的超參數是512,2,8000】
8.3整合
SimpleLossCompute這個類包含了softmax+loss+optimizer三個功能。
(1)這里包含了三個功能先用generator計算linear+softmax
(2)利用criterion來計算loss?這里的loss?function是KLDivLoss?求得loss是個sum的形式?所以要根據ntokens數量來求平均loss
(3)優化器是opt?也就是梯度下降算法?這個優化器里面有對lr的算法
class SimpleLossCompute:def __init__(self, generator, criterion, opt=None):self.generator = generatorself.criterion = criterionself.opt = optdef __call__(self, x, y, norm):x = self.generator(x)loss = self.criterion(x.contiguous().view(-1, x.size(-1)), y.contiguous().view(-1)) / normloss.backward()if self.opt is not None:self.opt.step()self.opt.optimizer.zero_grad()return loss.item() * norm9. 模型訓練Train
因為我只是在colab上訓練,所以就是單核16GPU,20個epoch,約10000次迭代,花費了3個多小時。
訓練模型中包含了時間計數、loss記錄、數據和model的cuda()、step計數、學習率記錄。
代碼如下:
USE_CUDA = torch.cuda.is_available() print_every = 50 plot_every = 100 plot_losses = [] def time_since(t):now = time.time()s = now - tm = math.floor(s / 60)s -= m * 60return '%dm %ds' % (m, s) def run_epoch(data_iter, model, loss_compute):"Standard Training and Logging Function"start_epoch = time.time()total_tokens = 0total_loss = 0tokens = 0plot_loss_total = 0plot_tokens_total = 0for i, batch in enumerate(data_iter):src = batch.src.cuda() if USE_CUDA else batch.srctrg = batch.trg.cuda() if USE_CUDA else batch.trgsrc_mask = batch.src_mask.cuda() if USE_CUDA else batch.src_masktrg_mask = batch.trg_mask.cuda() if USE_CUDA else batch.trg_maskmodel = model.cuda() if USE_CUDA else modelout = model.forward(src, trg, src_mask, trg_mask)trg_y = batch.trg_y.cuda() if USE_CUDA else batch.trg_yntokens = batch.ntokens.cuda() if USE_CUDA else batch.ntokensloss = loss_compute(out, trg_y, ntokens)total_loss += lossplot_loss_total += losstotal_tokens += ntokensplot_tokens_total += ntokenstokens += ntokensif i % print_every == 1:elapsed = time.time() - start_epochprint("Epoch Step: %3d Loss: %10f time:%8s Tokens per Sec: %6.0f Step: %6d Lr: %0.8f" %(i, loss / ntokens, time_since(start), tokens / elapsed,loss_compute.opt._step if loss_compute.opt is not None else 0,loss_compute.opt._rate if loss_compute.opt is not None else 0))tokens = 0start_epoch = time.time()if i % plot_every == 1:plot_loss_avg = plot_loss_total / plot_tokens_totalplot_losses.append(plot_loss_avg)plot_loss_total = 0plot_tokens_total = 0return total_loss / total_tokens model = make_model(len(SRC.vocab), len(TGT.vocab), N=6) criterion = LabelSmoothing(size=len(TGT.vocab), padding_idx=pad_idx, smoothing=0.1) model_opt = NoamOpt(model.src_embed[0].d_model, 2, 8000,torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))訓練結果:
start = time.time() for epoch in range(20):print('EPOCH',epoch,'--------------------------------------------------------------')model.train()run_epoch((batch_mask(pad_idx, b) for b in train_iter),model,SimpleLossCompute(model.generator, criterion, opt=model_opt))model.eval()loss=run_epoch((batch_mask(pad_idx, b) for b in valid_iter),model,SimpleLossCompute(model.generator, criterion, opt=None))print(loss)? .....step達到8000后的訓練情況
??.....最后epoch
保存模型:
loss曲線:
10. 模型測試生成
測試生成部分利用的model.decode()函數,采樣方式使用的貪婪算法,部分代碼:
def greedy_decode(model, src, src_mask, max_len, start_symbol):memory = model.encode(src, src_mask)ys = torch.ones(1, 1).fill_(start_symbol).type_as(src.data)for i in range(max_len - 1):out = model.decode(memory, src_mask, Variable(ys), Variable(subsequent_mask(ys.size(1)).type_as(src.data)))prob = model.generator(out[:, -1])_, next_word = torch.max(prob, dim=1)next_word = next_word.data[0]ys = torch.cat([ys, torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=1)return ys輸出結果:
12.注意力分布可視化?
先隨機對valid驗證集中某個句子進行翻譯
Source ? ? ?: non ho mai detto a nessuno che mio padre è in prigione . Target ? ? ?: I 've never told anyone that my father is in prison . Translation : I never told anyone that my father is in prison . ? ? ?
可視化:
tgt_sent = trans.split() # 翻譯數據 sent = source.split() # 源數據src def draw(data, x, y, ax):seaborn.heatmap(data, xticklabels=x, square=True, yticklabels=y, vmin=0.0, vmax=1.0, cbar=False, ax=ax) for layer in range(1, 6, 2):fig, axs = plt.subplots(1, 8, figsize=(25, 15))print("Encoder Layer", layer + 1)for h in range(8):draw(model.encoder.layers[layer].self_attn.attn[0, h].data[:12, :12],sent, sent if h == 0 else [], ax=axs[h])plt.show() for layer in range(1, 6, 2):fig, axs = plt.subplots(1, 8, figsize=(25, 15))print("Decoder Self Layer", layer + 1)for h in range(8):draw(model.decoder.layers[layer].self_attn.attn[0, h].data[:len(tgt_sent), :len(tgt_sent)],tgt_sent, tgt_sent if h == 0 else [], ax=axs[h])plt.show()print("Decoder Src Layer", layer + 1)fig, axs = plt.subplots(1, 8, figsize=(25, 15))for h in range(8):draw(model.decoder.layers[layer].src_attn.attn[0, h].data[:len(tgt_sent), :len(sent)],sent, tgt_sent if h == 0 else [], ax=axs[h])plt.show()可以看到8個頭在不同注意力層里分布情況,實際上在不同的子空間學習到了不同的信息。
13.數學原理解釋Transformer和RNN本質區別
至此,大家應該可以感受到Transformer之所以橫掃碾壓RNN,其實是多個機制大力出奇跡的成果,并不單單是attention的應用。
但我們深一步思考下,Transformer可以把這么多機制組合在一起而性能沒有下降是為什么呢,我個人覺得還是attention的應用大大提升了模型并行運算,但是只是用attention精度可能并不如人意,所以attention省下的空間和時間可以把其他能提高精度的模塊(比如position encoding、residual、mask、multi-head等等)一起添加進來。所以從這個角度來講,attention還是transformer最核心的部分,這個大家應該沒有異議的。
再深入一下,不管是attention還是傳統的rnn,其實都是為了在計算序列的hidden,RNN使用gate(sigmoid)的概念,計算hidden的權重;而attention使用softmax來計算hidden的權重,無論RNN還是attention他們計算完權重都是為了共同的目標——求得上下文context。
先看下RNN的數學公式。
再看下attention 相關數學公式:
大家有沒有發現呢?RNN求得各種門是不是很像softmax求得的權重分布?這里RNN里c<t-1>和c^(t)可以類比attention公式中的V,他們都是hidden的含義。
那么我們再仔細看下RNN的門和attention的權重,是不是也能很像,都是對(query,key)使用了非線性激活函數,前者使用了sigmoid,后者使用了softmax,不管使用哪個激活函數activation,其實目的都是再尋找(query,key)之間的相似度,RNN使用了加性運算(W(a,x)+b),而Transformer使用的是乘性運算(QK^T)。
至此是不是恍然大悟呢,這兩個經典模型追蹤溯源竟然只是sigmoid和softmax的區別。那么我們再回顧下這兩個函數:
sigmod計算是標量,而transformer計算的是向量,my god,這不正好符合rnn和transformer的特性么?
rnn使用的是step by step順序算法,每次都是計算當前input(query)和上一個cell傳來的hidden(key)的關系,由于一次只能喂入一個,所以自然使用sigmoid的標量屬性;但是softmax不同,他針對的是一個向量,transformer里的key可不就是一個序列所有的值么,他們的和為1,計算這個序列向量的權重分布,也就是所謂的并行計算。
網上很多人探討兩者的區別,但總讓我有種隔靴搔癢的感覺,花了點時間從數學原理的角度感知了兩者底層的本質,讓我對(QUERY,KEY,VALUE)模式有了更深的理解,希望對大家也有所幫助~
END
總結
以上是生活随笔為你收集整理的Step-by-step to Transformer:深入解析工作原理(以Pytorch机器翻译为例)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 打脸!一个线性变换就能媲美“最强句子em
- 下一篇: 一训练就显存爆炸?Facebook 推出