從模型原理到代碼實踐,深入淺出上手 Transformer,叩開大模型世界的大門
作者 | Plus
一、序言
作為非算法同學,最近被Cursor、DeepSeek搞的有點焦慮,同時也非常好奇這里的原理,所以花了大量業余時間自學了Transformer并做了完整的工程實踐。希望自己心得和理解可以幫到大家~
如有錯漏,歡迎指出~
本文都會以用Transformer做中英翻譯的具體實例進行闡述。
二、從宏觀邏輯看Transformer
讓我們先從宏觀角度解釋一下這個架構。
首先 Transformer也是一個神經網絡,神經網絡的本質是模擬人腦神經元的思考過程,數學上是一種擬合,當然,人腦內部的信號處理是否連續或者可擬合我們不得而知,但Transformer在我的機器上實實在在地思考并輸出了正確的答案。
Transformer 主要是設計用來做翻譯的,分兩大塊,如上圖,左邊的編碼器和右邊的解碼器。
編碼器負責提取原文的特征, 解碼器負責提取當前已有譯文序列的特征,并結合原文特征(編碼器解碼器的連線部分),給出下一個詞的預測。
GPT基本可以認為就是Transformer的解碼器部分。
接下來我們分幾個部分逐步講解并附上代碼實踐,很長 得慢慢看....
三、輸入和輸出
我認為大部分文章都沒有把輸入和輸出講得很細,其實理解了輸入輸出,你就基本可以理解Transformer的大半了。
1. 模型的輸入
以中英翻譯為例,Transformer的輸入分兩部分:
- 源文序列(中文) 即圖1左側部分,輸入到編碼器。 舉例: 我愛00700
- 目標譯文(英文)即圖1右側部分,輸入到解碼器。 一開始只有一個,僅用于告訴模型開始翻譯
2. 模型的輸出
很顯然,對于上述輸入,我們期待的輸出是 I love 00700
的作用是表示翻譯結束,因為翻譯的英文不一定和輸入的中文等長,所以需要有一個結束符。
模型并不能一下子輸出完整的句子,他是一個詞一個詞( /token/ )吐出來的,并且每一個詞都需要作為下一詞的輸入,這也是為什么大模型都是打字機交互的原因。 具體例子:
第一個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos>
輸出 I
第二個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I
輸出 love
第三個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I love
輸出 00700
第四個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I love 00700
輸出 <eos>
// 輸出了結束符,翻譯完成
請注意上述是為了簡化理解的一個陳述,實際上模型真正的輸出并不是一個詞,而是整個 /詞表/ 內任意一個詞可能的概率,也就是圖2所示的probabilities。
具體說,就是一個數組[0.2, 0.7, 0.1], 序號為0的詞的概率是0.2,序號為1的詞的概率是0.7,序號為2的詞的概率是0.1。 假設這是第二輪循環,詞表是[I, love, 00700] 取最大概率0.7的詞,然后得到第二輪輸出是love, 和第一輪輸出拼起來就是 I love。
3. 詞表 & token
劃線的兩個詞 /token/ /詞表/ 你可能仍有疑惑。這正是我們真正弄清楚整個輸入輸出的關鍵。
顯然,計算機只能理解二進制數據,我們說輸入"我愛00700"的時候,實際上輸入的是處理好的二進制數據。
如何把句子轉化成模型可以理解的二進制數據呢? 不妨先想想我們怎么學英語的, 沒錯,背單詞??! 我們需要先認識詞,然后理解句子,模型也是一樣的。
我們背的英文單詞表,和這里的模型 /詞表/ 其實是一個意思,當然,形式略有不同。 詞表里的每一個詞,就是我們說的 /token/ ,請注意 token 不一定是一個英語單詞。上述例子的英文詞表可能是: [I, lo, ve, 00, 7] 。 顯然,token不是按照空格分的。 原因是算力難以覆蓋,英文單詞有幾十萬個(不權威),老黃聽了都搖頭??。 而更低維度的分詞,可以壓縮詞表的數量,[I, lo, ve, 00, 7]是我瞎寫的,理解意思就行,讓DS給我們舉一個更好的例子:
假設語料庫包含以下高頻單詞:
- play (10次)
- player (8次)
- playing (6次)
- plays (5次)
- replay (7次)
- replaying (4次)
直接按空格分詞會生成獨立詞表:
空格分詞詞表大?。?
[play, player, playing, plays, replay, replaying]
# bpe分詞算法最終詞表(目標大小=4)
# ?表示這個token只會出現在開始,想一想 還原句子的時候你需要知道兩個token是拼起來還是插入空格
[?play, re, ing, er]
對比空格分詞, /bpe分詞算法/ (想了解的自行ds,限于篇幅不贅述)可以有效壓縮詞表大小,在大量語料的情況下會更明顯,40GB數據的GPT2也僅5w詞表大小
4. 嵌入(embedding)
現在,我們有了詞表,很容易就可以得到二進制的序列了
還是老例子:
我 愛 00700 被編碼為 [1, 2, 3] (為了方便,這里假設分詞就這樣。)
我們可以直接輸入到網絡訓練了嗎? 答案是否定的,不過我們也終于來到了有意思的地方。 在在訓練之前需要做一個 /嵌入/
來一個youtobe的動圖看下 /嵌入/ 大概是啥。
再來一個DS的靈魂解釋:
嵌入(Embedding)的核心思想是 將復雜、高維的數據(如文字、圖像)轉化為低維、連續的數值向量,同時保留其內在的語義或關系 。這種轉化讓計算機能像人類一樣“理解”數據的含義,并用數學方式計算相似性、分類或生成。
額... 有點抽象,似乎講了些什么,又似乎什么都沒講......
沒關系,讓我來舉一個形象的例子 觀察下面的句子:
紅色
當紅明星
生意好紅火啊
注意"紅"這個字,發現了嗎,在不同語境(或者說維度)它有不同的意思,如果我們直接輸入token編碼,這些信息是缺失的,模型就不太可能理解句子的意思。
那么,一個字或者說一個token到底有多少維度的語義呢?不知道啊??,但是沒關系,猜一個, 512。 沒錯,就是這么樸實無華!
于是我們就有了嵌入矩陣 (self.weight)
: 詞表大小(num_embeddings,如32000)
: 嵌入維度(embedding_dim,如512)
即E是一個32000x512的二維矩陣 (代碼里就是數組)
E[1,1] E[1,2] ...... E[1,512] 的值表示詞表中序號為1的詞在維度1、2、512的語義, 由此同樣一個“紅”的token,就可以有多種語義,這樣模型就可以理解詞和句子了。
當我們輸入 我 愛 00700 被編碼為 [1, 2, 3] , 實際上我們應該輸入:
請注意請注意,所謂一個字有不同維度的語義,是我現編的比喻,如果它幫到你理解這個東西那最最好,如果覺得比喻得不太對,大可一笑了之。
繼續
E[1,1] E[1,2] ...... E[1,512] 我們可以把它看作是以原點為起點的 512維的向量 ,一般就叫做詞的嵌入向量,也就是平時可能聽到的向量化,沒什么高大上的對吧。 向量化之后可以做什么呢?看圖
(以三維舉例,512維可以發揮想象力自行腦補)
為了讓模型理解詞和詞的關系,我們用數學語言去描述這種關系,就是向量的內積:
-
是向量a的長度(范數)
是向量b的長度(范數)
是兩個向量之間的夾角
就算忘記了向量內積怎么算也沒關系,我們只需要理解,夾角越小,向量內積越大, 兩個詞的關聯越緊密就行了。 實際上大部分復雜公式的計算都是封裝好了的。 這里的內積,在自注意力的時候我們也會用到。
到了這里就可以解釋一下 的元素值是怎么來的了,我們定義了每一個詞有512個維度,那么每一個維度的值是什么呢?
答案是模型自己學習出來的,E是模型的參數的一部分,一個詞每個維度的值,初始化是一個隨機值,模型訓練的時候會被更新。
怎么學出來的? 試想一下: 語料里面有無數的詞,但是他們是有一定的關聯的,比如 I love 這兩個詞經常同時出現,那么他們的內積就應該較大,反之也一樣。實際上有點像聚類,怎么理解都行,模型學得差不多的時候,token的分布或者說嵌入向量的關系,一定是有規律的。
讓我們來用數學語言總結一下嵌入:
就是我們的查表或者說嵌入操作:輸入張量
- B: 批次大小(batch_size)
- L: 序列長度(sequence_length)
- D: 嵌入維度
嵌入查找操作上述例子 就是 ,L是句子長度也就是3
為什么這里也會叫查表? 實際上對索引為1的token做嵌入,就是在嵌入矩陣里到找第一行然后拿出來,就是 E[1,1] E[1,2] ...... E[1,512] 。這個過程不就是查表么。
5. Batch 處理
細心的你可能發現了問題,這里多了一個維度B,輸入的結果 ,是三維的。這里解釋一下,我們的例子只有一句話,但是在模型的訓練中,其實是一次性輸入多句話并行計算的,batch_size 的值就是你一次性輸入多少句子來訓練,一般來說,這取決于你的顯存大小,自行嘗試調整就可以了。
假設batch 為2,一次輸入兩句:
兩句話的embedding矩陣合并,得到一個 2x3x512的三維矩陣 就是我們給編碼器的輸入了。 此時 B=2 L=3 D=512
6. padding掩碼矩陣
還沒完,讓我們來一點刁鉆的問題。 上述例子L=3,兩個句子長度都一樣=3。 如果句子長度不一樣呢?batch矩陣豈不是拼不出來? 當然不會,實際寫代碼的時候,L一般都是取一個較大值,比如語料中最長句子的值。L=100。 不夠長的句子怎么辦呢? 補padding,注意不是0,因為神經網絡的某些中間計算如softmax輸入0也是有值的。 一般是用一個特殊的token作為padding。
在自注意力計算的時候(下文會講)會根據padding的位置,生成mask 矩陣,計算時候padding替換為一個極小值比如-1e9,就可以不影響計算了。
7. PositionalEncoding
現在這個帶padding的矩陣可以輸入模型開始訓練了嗎? 還是不行..... 我們還缺少一個重要的信息,位置。 舉一個例子:
[ [0.3, 0.5, 0.1, 0.4]
[貓,吃,魚] => Transformer => [0.1, -0.6, -0.2, 0.3],
[0.3, 0.5, 0.3, -0.1] ]
[ [0.3, 0.5, 0.1, 0.4]
[魚,吃,貓] => Transformer => [0.1, -0.6, -0.2, 0.3],
[0.3, 0.5, 0.3, -0.1] ]
很明顯,雖然只是交換了一個字的順序,但其實是兩個完全不同意義的句子, 而 Transformer輸出的分布卻是不變的,說明網絡沒有辦法識別位置信息。
為了解決這個問題,Transformer的論文里的方案是添加位置編碼,也就是PositionalEncoding。
其實沒有那么神秘,我們把 Transformer 看成函數T , 現在的問題是:
我們可以加上一個位置編碼P去規避這個問題:
: 當前的位置序號
來一個簡單粗暴易理解的 P(i) = i
貓 0
吃 1
魚 2
然后呢?就是簡單的直接加上去
沒錯,這樣其實我們的輸入就包含了位置信息了,只要信息在那里,模型總能學明白
當然,這里只是為了方便說明位置編碼本身,所以用了最簡單的方案,你說它能不能跑,那肯定也是能跑的,就是會有不少問題,實際上論文里實現是正弦torch.sin(position * div_term)和余弦函數torch.cos(position * div_term)來生成位置編碼, 解釋起來就篇幅太長,大家可以自己探索。
位置編碼不改變矩陣的維度,只是改變了數值, 我們給編碼器的最終輸入總結如下:
我愛我愛分詞嵌入
但是對于解碼器的輸入,還需要額外處理,繼續往下。
8. 并行計算 & Teacher Forcing
回顧下一開始的例子
第一個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos>
輸出 I
第二個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I
輸出 love
第三個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I love
輸出 00700
第四個循環
編碼器輸入 我 愛 00700
解碼器輸入 <bos> I love 00700
輸出 <eos>
// 輸出了結束符,翻譯完成
我們給編碼器的輸入始終是整個句子序列的embedding。而給解碼器的輸入,則是當前已經預測出來的 n-1個詞的序列的embedding。
(1) 并行計算
當你真的上手去寫代碼的時候,你就會發現, 在訓練階段,代碼里我們給解碼器直接輸入完整的句子I love 00700 ,且模型輸出直接就是整個句子。 只有一個循環。你現在肯定滿腦子問號,直接輸入正確答案了,還學什么?
別急,實際上我們輸入不是全部的ground truth,是 n-1序列的ground truth。怎么理解呢?真正的ground truth 是 I love 00700 eos 。 我們的輸入是I love 00700。 當然這樣僅僅是少了最后一個token的答案而已。
實際上解碼的輸入(姑且叫trg_input)在做計算的時候還需要做一個causal mask,叫做因果掩碼,字面意思就是為了防止計算的時候知道未來的信息。
>>> trg_input # 假設我們的input長這樣 1234代表 <bos> I love 00700
tensor([[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4]])
# 因果掩碼矩陣是這樣的,對角線上移一位的上三角矩陣,上三角的元素是極小值
>>> mask = torch.triu(torch.ones(4, 4), diagonal=1)
>>> mask = mask.float().masked_fill(mask == 1, float(-1e9))
>>> mask
tensor([[0., -inf, -inf, -inf],
[0., 0., -inf, -inf],
[0., 0., 0., -inf],
[0., 0., 0., 0.]])
>>>
#執行 causal mask
>>> trg_input + mask
tensor([[1., -inf, -inf, -inf],
[1., 2., -inf, -inf],
[1., 2., 3., -inf],
[1., 2., 3., 4.]])
看看causal mask結果的第一行,[1., -inf, -inf, -inf] 其實就是相當于第一輪循環的輸入 bos, 因為無限小在計算的時候可以忽略。 后續的2、3、4行剛好也就是 對應的2、3、4輪循環。 get到了嗎, 直接作為一個矩陣輸入,在模型里并行計算,4個循環優化成了一個!能夠并行計算,這正是transformer一個重要特性。
(2) Teacher Forcing
想一想為什么我們可以實現并行計算? 因為訓練的時候,我們提前知道了正確答案,在生產環境推理過程中,我們不可能知道第一個token應該輸出I,第二個token應該輸出love ...., 所以推理的時候,只能順序執行循環。
再多想一點,在訓練的時候,模型還并不成熟,第一個字符輸出的未必是I,假設輸出了 He 呢? 然后第二輪循環我們的輸入就是 bos He, 這樣繼續循環下去只會越來越錯,非常不利于學習的收斂。 所以,我們第二輪循環不輸入He, 而是用正確答案 I 來繼續下一個token預測,實驗表明這樣學習的速度會更快。 這種訓練方法,我們就稱之為Teacher Forcing。
關于并行計算和Teacher Forcing感覺大部分文章都是一筆帶過,而我感覺這里很重要,包括理解架構本身或者是去理解為什么transformer帶來了技術的爆發,并行計算是一個很重要的因素。
9. 輸入輸出的代碼實現
作為一個嚴謹的工程師,最后我貼一下我自己的實現,并做簡單講解:
# 關于語料 我的數據集來自 wmt17 v13 zh-en 大約100m 30+w行長句子
# 從csv讀出來, 第一列是中文 第0列是英文
train_data = read_tsv_file(opt.train_tsv, 1, 0, opt.delimiter)
valid_data = read_tsv_file(opt.valid_tsv, 1, 0, opt.delimiter) if opt.valid_tsv else []
test_data = read_tsv_file(opt.test_tsv, 1, 0, opt.delimiter) if opt.test_tsv else []
# 分詞器初始化 我這里是bpe bytelevel 32000 詞表大小,具體實現太長就不貼了
src_tokenizer_path = os.path.join(opt.data_dir, "tokenizer_zh.json")
src_tokenizer = train_tokenizer([src for src, _ in train_data], opt.vocab_size, src_tokenizer_path)
trg_tokenizer_path = os.path.join(opt.data_dir, "tokenizer_en.json")
trg_tokenizer = train_tokenizer([trg for _, trg in train_data], opt.vocab_size, trg_tokenizer_path)
# 有了分詞器之后,對訓練數據句子分詞 打包成pkl格式,方便模型訓練的時候讀取
processed_train = process_data(train_data, src_tokenizer, trg_tokenizer, opt.max_len)
# train階段
# 從pkl加載數據 略
# 訓練輸入
for i, batch in enumerate(dataloader):
# 準備數據 每次循環處理一個batch的數據
src = batch['src'].to(device) # 取出語料原文。我 愛 00700 (舉例,實際上batch是多句的數組,但是矩陣運算的時候都是一起并行算的)
trg = batch['trg'].to(device) # 取出語料目標譯文。bos I love 00700 eos
trg_input = trg[:, :-1] # 去掉最后一個token, bos I love 00700 n-1的Teacher Forcing序列
trg_output = trg[:, 1:] # 去掉第一個token, I love 00700 eos 真實的groud truth,用于計算損失
# 前向傳播 清空梯度
optimizer.zero_grad()
# 輸入模型計算一次
# teacher forcing: trg_input 實際上就是ground truth(正確的值)
# train 模式下,output是整個句子的預測值
output = model(src, trg_input)
......
# 嵌入
"""初始化嵌入矩陣"""
# 使用正態分布(高斯分布)初始化張量
# 均值為0,標準差為0.02
# 在數學上,512維的隨機向量,可以認為是互相正交的(就是任意兩個token之間一點關系都沒有)
nn.init.normal_(self.weight, mean=0, std=0.02)
# 基本的嵌入查找
# weight 是模型參數,自動參與學習
embeddings = self.weight[x]
# mask
"""創建源序列和目標序列的掩碼"""
src_padding_mask = (src == self.pad_idx) # src padding掩碼
trg_padding_mask = (trg == self.pad_idx) # trg padding掩碼
# 因果掩碼
seq_len = trg.size(1)
trg_mask = torch.triu(
torch.ones((seq_len, seq_len), device=src.device), diagonal=1
).bool()
return src_padding_mask, trg_padding_mask, trg_mask
# 應用mask 我這里的mask是bool矩陣,true表示需要mask
# 如果mask是true,scores對應位置的元素就填充為極小值 -1e9
if mask is not None:
scores = scores.masked_fill(mask == True, -1e9)
輸入輸出終于寫完 看到這里就真的不容易 感謝~!
四、自注意力機制
到這里我們已經完成了輸入的所有前置處理,訓練數據開始進入到網絡訓練。 而理解Transformer網絡的關鍵就在于自注意力機制,如下圖所示的三個模塊,他們是整個網絡的核心。
1. 注意力機制
注意力其實非常好理解, 如下圖: 萬綠叢中一點紅,你首先看到了紅,這就是注意力。
在訓練網絡中,注意力的作用是什么呢?
老例子:“ 我 愛 00700 ” 翻譯為 “ I love () ” () 為當前正在預測的詞。
如上圖, 前面我們介紹過Transformer預測下一個詞的時候,會結合整個上下文,即“我 愛 00700 I love”,假如沒有注意力機制,那么所有詞對預測結果的貢獻是一樣的! 假設語料的言情文偏多,那輸出很可能是 I love you, 很明顯不合理, 此時我們希望網絡把注意力集中在“00700”這個詞上,其他詞應該忽略掉。
那么在數學上注意力是什么呢? 非常簡單,就是權重系數寫出來就是:
- x ∈ R^(seq_len, d_model) seq_len是句子長度,d_model=512是embdedding的緯度,忘了的話往回翻復習下吧
- W ∈ R^(512, 512) 是權重矩陣
??? 解釋了這么久 就這? 沒錯是的,取個高大上的名字而已。
2. 自注意力機制
自注意力畢竟多了一個字,肯定還是有點不一樣的,不一樣在哪呢,他乘的不是權重矩陣,它乘它自己!
- 即權重
- x ∈ R^(seq_len, d_model) seq_len是句子長度,d_model=512是embdedding的緯度,忘了的話往回翻復習下吧
重點看一下這個
如果你對矩陣乘法熟,你馬上就會意識到,這不就是句子的詞和詞之間的內積嗎?
回憶一下嵌入的內容,我們說過詞向量的內積反應的是詞的關系遠近。
本質是,Transformer通過句子本身的詞和詞之間的關系計算注意力權重! 所以我們叫它 - 自注意力 !還算自洽吧~
當然,直接相乘沒有參數可以訓練啊,來個線性變換吧 ,于是
不好記,得起個名字, 第一個式子是原始請求 就叫Query吧,簡寫為Q
感覺不用糾結這個命名,反正論文沒提命名的道理 自行想象吧!
第二個式子是被用來計算關系的,就叫Key吧,簡寫為K
于是:
按照論文所提,實驗發現,QK點積可能會過大,數據方差太大,導致梯度不太穩定,所以需要把分布均勻一下,于是:
我們需要的是權重,所以需要轉化為 0.0~1.0 這樣的值,這正是 /softmax/ 函數的能力 于是:
代入 , y改寫為Attention,更高大上
另外為了增加網路的復雜度,我們不直接乘x,老辦法,線性變換一下, 記為V
最終
最終我們得到了和論文一模一樣的公式~
3. 多頭注意力機制
先看一個圖,這是一個自注意力計算的示例:
問題是: 我 - 我 愛-愛 00700-00700 的分數最高, 自己最關注自己,聽起來是合理的,但論文作者應該是覺得這不利于收斂。 所以提出了多頭機制。
其實這里論文并沒有太多解釋,我理解是一種實驗性的經驗。
那么什么是多頭? 其實也很簡單,就是分塊計算注意力,好比原來是一個頭看整個矩陣, 現在是變身哪吒三頭六臂,一個頭看一小塊,然后信息合并。 畫圖說明:
這種分頭其實本質上計算量是一樣的,只是注意力分塊了,直接看公式就好了。
- Q, K, V 是輸入矩陣,形狀為 (batch_size, seq_len, d_model)
- W_i^Q ∈ R^(d_model × d_k)
- W_i^K ∈ R^(d_model × d_k)
- W_i^V ∈ R^(d_model × d_v)
- head_i 的形狀為 (batch_size, seq_len, d_v)
論文中 i=8, 8個頭, dk =dv = d_model/8 = 64
因果掩碼-多頭自注意力機制:
其實輸入輸出的時候就講了,就是解碼器做多頭計算的時候 使用teacher forcing,需要加上因果掩碼mask。 下面代碼說明。
4. 交叉-多頭自注意力機制
解碼器如何結合編碼器的上下文就在這里了,如果是交叉-多頭自注意力機制,那么 Q = x, K=V=編碼器的輸出,直接看代碼最直觀。
# 1. 線性變換 這里的 query=key=value 就是x
# 如果是解碼器的 交叉-多頭自注意力機制,那么 query = x, key=value=編碼器的輸出
Q = self.q_linear(query) # 這里的linear就是線性變換 wx+b,下同
K = self.k_linear(key)
V = self.v_linear(value)
# 2. 分割成多頭 [batch_size, seq_len, d_model] -> [batch_size, seq_len, num_heads, d_k]
# 實際上 torch的張量(矩陣) 是用一緯數組存的,所謂分割多頭、轉置,最終就是改一下數組元素的順序
Q = Q.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
# 3. 計算注意力
# K.transpose(-2, -1) 就是 k的轉置
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
# 4. 應用mask 因果掩碼 或者 padding 掩碼
if mask is not None:
scores = scores.masked_fill(mask == True, -1e9)
# 5. softmax獲取注意力權重
attn = F.softmax(scores, dim=-1)
# dropout是隨機丟棄一些值(寫0),為了增加隨機性,訓練時才會開啟,避免過擬合
attn = self.dropout(attn)
# 6. 注意力加權求和 就是乘v
out = torch.matmul(attn, V)
# 7. 重新拼起來多頭 把維度轉置回來
out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
# 8. 最后又加了一個線性變換 wx+b, 不要為問我為什么 ...... 實驗性經驗
out = self.out_linear(out)
return out
五、前向傳播
到這里就很簡單了,核心的部分都講完了。 還剩下幾個小塊簡單講一下。
1. Add & Norm
Add 叫殘差,超級簡單,直接上代碼:
# self_attn 就是算注意力,tgt_mask是因果掩碼。
# 本來應該是 x = self.dropout1(self.self_attn(x2, x2, x2, tgt_mask))
# 殘差就是多加了 x, x = x + self.dropout1(self.self_attn(x2, x2, x2, tgt_mask))
# 簡單解釋就是 加這個x,防止梯度下降太快到后面沒了。詳細就不展開了,可自行gpt
x = x + self.dropout1(self.self_attn(x2, x2, x2, tgt_mask))
Norm 是歸一化,簡單解釋一下,歸一化是讓數據分布更均衡,一般是消除一些離譜值、不同數據量綱的影響。 比如分析年齡和資產對相親成功率的影響,年齡只有0-100, 資產的范圍就很大,計算的時候就不好弄,需要把資產也縮放到一個合理的數值范圍 比如 0-100 萬。
歸一化有很多種,這里用的是 /Layernorm/ ,這個公式還挺復雜,有興趣可以自己研究,這里我是偷懶的:
# 直接使用 pytorch的自帶公式
self.norm1 = nn.LayerNorm(d_model)
# 調包就完事
x2 = self.norm1(x)
2. FeedForward
前饋全連接層(feed-forward linear layer) ,好多文章試圖解釋這個意義,其實沒啥意義,就是普通的兩層網絡, 一般來說就是一層神經網絡就是 線性變換+激活函數。
線性變換,無非就是升維和降維。舉例:升維是類似于你看圖片的時候,雙指放大,然后滑來滑去找到你需要的,是特征放大。降維么,就是截圖保存放大部分,然后輸出,是特征提取。
一升一降就是FeedForward啦,直接看代碼:
class FeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
super().__init__()
# 從dmodel升維到d_ff = 2048 論文的值
self.linear1 = nn.Linear(d_model, d_ff)
# 降回來提取特征
self.linear2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 怎么找到關注特征的 那不就是激活函數 F.relu ,很明顯,神經元被激活的地方就是感興趣的特征啊。
return self.linear2(self.dropout(F.relu(self.linear1(x))))
3. 解碼器
到這里就沒啥了,每個塊我們都理解了,后續就是實現邏輯了。 直接上代碼,太長了好像沒必要,文末尾放git地址把。
4. 編碼器
同上。
六、反向傳播
前向傳播計算出一次訓練的結果,反向傳播就是根據結果的好or壞,更新參數,循環往復,最終得到一個滿意的模型。
自己實現 實在是有點累,調包只需要一句:
# 前向傳播
output = model(src, trg_input)
......
# 計算損失
loss = criterion(output_flat, trg_output_flat)
# 反向傳播
loss.backward()
pytorch框架會記錄你整個模型前向傳播的圖,然后根據損失,使用 /鏈式法則 / & /梯度下降算法/ 幫你回溯計算更新每一個參數,太舒服了~