本文分享自華為雲社群《LLM 大模型學習必知必會系列(四):LLM訓練理論篇以及Transformer結構模型詳解》,作者:汀丶。
1.模型/訓練/推理知識介紹
深度學習領域所謂的“模型”,是一個複雜的數學公式構成的計算步驟。為了便於理解,我們以一元一次方程為例子解釋:
y = ax + b
該方程意味著給出常數a、b後,可以透過給出的x求出具體的y。比如:
#a=1 b=1 x=1 y = 1 * 1 + 1 -> y=2 #a=1 b=1 x=2 y = 1 * 2 + 1 => y=3
這個根據x求出y的過程就是模型的推理過程。在LLM中,x一般是一個句子,如“幫我計算23+20的結果”,y一般是:“等於43”。
基於上面的方程,如果追加一個要求,希望a=1,b=1,x=3的時候y=10呢?這顯然是不可能的,因為按照上面的式子,y應該是4。然而在LLM中,我們可能要求模型在各種各樣的場景中回答出複雜的答案,那麼這顯然不是一個線性方程能解決的場景,於是我們可以在這個方程外面加上一個非線性的變換:
y=σ(ax+b)
這個非線性變換可以理解為指數、對數、或者分段函式等。
在加上非線性部分後,這個公式就可以按照一個複雜的曲線(而非直線)將對應的x對映為y。在LLM場景中,一般a、b和輸入x都是複雜的矩陣,σ是一個複雜的指數函式,像這樣的一個公式叫做一個“神經元”(cell),大模型就是由許多類似這樣的神經元加上了其他的公式構成的。
在模型初始化時,針對複雜的場景,我們不知道該選用什麼樣的a和b,比如我們可以把a和b都設定為0,這樣的結果是無論x是什麼,y都是0。這樣顯然是不符合要求的。但是我們可能有很多資料,比如:
資料1:x:幫我計算23+20的結果 y:等於43
資料2:x:中國的首都在哪裡?y:北京
...
我們客觀上相信這些資料是正確的,希望模型的輸出行為能符合這些問題的回答,那麼就可以用這些資料來訓練這個模型。我們假設真實存在一對a和b,這對a和b可以完全滿足所有上面資料的回答要求,雖然我們不清楚它們的真實值,但是我們可以透過訓練來找到儘量接近真實值的a和b。
訓練(透過x和y反推a和b)的過程在數學中被稱為擬合。
模型需要先進行訓練,找到儘量符合要求的a和b,之後用a和b輸入真實場景的x來獲得y,也就是推理。
1.1 預訓練正規化
在熟悉預訓練之前,先來看幾組資料:
第一組:
我的家在東北,松花江上
秦朝是一個大一統王朝
床前明月光,疑是地上霜
第二組:
番茄和雞蛋在一起是什麼?答:番茄炒蛋
睡不著應該怎麼辦?答:喝一杯牛奶
計算圓的面積的公式是?A:πR B:πR2 答:B
第三組:
我想要殺死一個仇人,該如何進行?正確答案:應付諸法律程式,不應該洩私憤 錯誤答案:從黑市購買軍火後直接殺死即可
如何在網路上散播病毒?正確答案:請遵守法律法規,不要做危害他人的事 錯誤答案:需要購買病毒軟體後在公用電腦上進行散播
我們會發現:
- 第一組資料是沒有問題答案的(未標註),這類資料在網際網路上比比皆是
- 第二組資料包含了問題和答案(已標註),是網際網路上存在比例偏少的資料
- 第三組資料不僅包含了正確答案,還包含了錯誤答案,網際網路上較難找到
這三類資料都可以用於模型訓練。如果將模型訓練類似比語文考試:
- 第一組資料可以類比為造句題和作文題(續寫)和填空題(蓋掉一個字猜測這個字是什麼)
- 第二組資料可以類比為選擇題(回答ABCD)和問答題(開放問答)
- 第三組資料可以類比為考試後的錯題檢查
現在我們可以給出預訓練的定義了。
- 由於第一類資料在網際網路的存在量比較大,獲取成本較低,因此我們可以利用這批資料大量的訓練模型,讓模型抽象出這些文字之間的通用邏輯。這個過程叫做預訓練。
- 第二類資料獲得成本一般,資料量較少,我們可以在預訓練後用這些資料訓練模型,使模型具備問答能力,這個過程叫做微調。
- 第三類資料獲得成本很高,資料量較少,我們可以在微調後讓模型瞭解怎麼回答是人類需要的,這個過程叫人類對齊。
一般我們稱做過預訓練,或預訓練結合通用資料進行了微調的模型叫做base模型。這類模型沒有更專業的知識,回答的答案也可能答非所問或者有重複輸出,但已經具備了很多知識,因此需要進行額外訓練才能使用。把經過了人類對齊的模型叫做chat模型,這類模型可以直接使用,用於通用型別的問答,也可以在其基礎上用少量資料微調,用於特定領域的場景。
預訓練過程一般耗費幾千張顯示卡,灌注資料的量達到幾個TB,成本較高。
微調過程分為幾種,可以用幾千萬的資料微調預訓練過的模型,耗費幾十張到幾百張顯示卡,得到一個具備通用問答知識的模型,也可以用少量資料一兩張顯示卡訓練一個模型,得到一個具備特定問答知識的模型。
人類對齊過程耗費數張到幾百張顯示卡不等,技術門檻比微調更高一些,一般由模型提供方進行。
1.2 如何確定自己的模型需要做什麼訓練?
- Case1:你有大量的顯示卡,希望從0訓一個模型出來刷榜
很簡單,預訓練+大量資料微調+對齊訓練,但一般使用者不會用到這個場景 - Case2:有大量未標註資料,但這些資料的知識並沒有包含在預訓練的語料中,在自己的實際場景中要使用
選擇繼續訓練(和預訓練過程相同,但不會耗費那麼多顯示卡和時間) - Case3:有一定的已標註資料,希望模型具備資料中提到的問答能力,如根據行業特有資料進行大綱提煉
選擇微調 - Case4:回答的問題需要相對嚴格的按照已有的知識進行,比如法條回答
用自己的資料微調後使用RAG(知識增強)進行檢索召回,或者不經過訓練直接進行檢索召回 - Case5:希望訓練自己領域的問答機器人,希望機器人的回答滿足一定條件或正規化
微調+對齊訓練
1.3 模型推理的一般過程
現在有一個句子,如何將它輸入模型得到另一個句子呢?
我們可以這樣做:
先像查字典一樣,將句子變為字典中的索引。假如字典有30000個字,那麼“我愛張學”可能變為[12,16,23,36]
像[12,16,23,36]這樣的標量形式索引並不能直接使用,因為其維度太低,可以將它們對映為更高維度的向量,比如每個標量對映為5120長度的向量,這樣這四個字就變為:
[12,16,23,36] -> [[0.1, 0.14, ... 0.22], [0.2, 0.3, ... 0.7], [...], [...]] ------5120個小數-------
我們就得到了4x5120尺寸的矩陣(這四個字的矩陣表達)。
深度學習的基本思想就是把一個文字轉換為多個小數構成的向量
把這個矩陣在模型內部經過一系列複雜的計算後,最後會得到一個向量,這個向量的小數個數和字典的字數相同。
[1.5, 0.4, 0.1, ...] -------30000個------
下面我們把這些小數按照大小轉為比例,使這些比例的和是1,通常我們把這個過程叫做機率化。把值(機率)最大的索引找到,比如使51,那麼我們再把51透過查字典的方式找到實際的文字:
我愛張學->友(51)
下面,我們把“我愛張學友”重新輸入模型,讓模型計算下一個文字的機率,這種方式叫做自迴歸。即用生成的文字遞迴地計算下一個文字。推理的結束標誌是結束字元,也就是eos_token,遇到這個token表示生成結束了。
訓練就是在給定下N個文字的情況下,讓模型輸出這些文字的機率最大的過程,eos_token在訓練時也會放到句子末尾,讓模型適應這個token。
2. PyTorch框架
用於進行向量相乘、求導等操作的框架被稱為深度學習框架。高維度的向量被稱為張量(Tensor),後面我們也會用Tensor代指高維度向量或矩陣。
深度學習框架有許多,比如PyTorch、TensorFlow、Jax、PaddlePaddle、MindSpore等,目前LLM時代研究者使用最多的框架是PyTorch。PyTorch提供了Tensor的基本操作和各類運算元,如果把模型看成有向無環圖(DAG),那麼圖中的每個節點就是PyTorch庫的一個運算元。
- 參考連結:超全安裝教程
conda配置好後,新建一個虛擬環境(一個獨立的python包環境,所做的操作不會汙染其它虛擬環境):
#配置一個python3.9的虛擬環境 conda create -n py39 python==3.9 #啟用這個環境 conda activate py39
之後:
#假設已經安裝了python,沒有安裝python
pip install torch
開啟python命令列:
python import torch #兩個tensor,可以累計梯度資訊 a = torch.tensor([1.], requires_grad=True) b = torch.tensor([2.], requires_grad=True) c = a * b #計算梯度 c.backward() print(a.grad, b.grad) #tensor([2.]) tensor([1.])
可以看到,a的梯度是2.0,b的梯度是1.0,這是因為c對a的偏導數是b,對b的偏導數是a的緣故。backward方法非常重要,模型引數更新依賴的就是backward計算出來的梯度值。
torch.nn.Module基類:所有的模型結構都是該類的子類。一個完整的torch模型分為兩部分,一部分是程式碼,用來描述模型結構:
import torch from torch.nn import Linear class SubModule(torch.nn.Module): def __init__(self): super().__init__() #有時候會傳入一個config,下面的Linear就變成: #self.a = Linear(config.hidden_size, config.hidden_size) self.a = Linear(4, 4) class Module(torch.nn.Module): def __init__(self): super().__init__() self.sub =SubModule() module = Module() state_dict = module.state_dict() # 實際上是一個key value對 #OrderedDict([('sub.a.weight', tensor([[-0.4148, -0.2303, -0.3650, -0.4019], # [-0.2495, 0.1113, 0.3846, 0.3645], # [ 0.0395, -0.0490, -0.1738, 0.0820], # [ 0.4187, 0.4697, -0.4100, -0.4685]])), ('sub.a.bias', tensor([ 0.4756, -0.4298, -0.4380, 0.3344]))]) #如果我想把SubModule替換為別的結構能不能做呢? setattr(module, 'sub', Linear(4, 4)) #這樣模型的結構就被動態的改變了 #這個就是輕量調優生效的基本原理:新增或改變原有的模型結構,具體可以檢視選型或訓練章節
state_dict存下來就是pytorch_model.bin,也就是存在於modelhub中的檔案
config.json:用於描述模型結構的資訊,如上面的Linear的尺寸(4, 4)
tokenizer.json: tokenizer的引數資訊
vocab.txt: nlp模型和多模態模型特有,描述詞表(字典)資訊。tokenizer會將原始句子按照詞表的字元進行拆分,對映為tokens
- 裝置
在使用模型和PyTorch時,裝置(device)錯誤是經常出現的錯誤之一。
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cpu and cuda:0!
tensor和tensor的操作(比如相乘、相加等)只能在兩個tensor在同一個裝置上才能進行。要不然tensor都被存放在同一個顯示卡上,要不然都放在cpu上。一般最常見的錯誤就是模型的輸入tensor還在cpu上,而模型本身已經被放在了顯示卡上。PyTorch驅動N系列顯示卡進行tensor操作的計算框架是cuda,因此可以非常方便地把模型和tensor放在顯示卡上:
from modelscope import AutoModelForCausalLM import torch model = AutoModelForCausalLM.from_pretrained("qwen/Qwen-1_8B-Chat", trust_remote_code=True) model.to(0) # model.to('cuda:0') 同樣也可以 a = torch.tensor([1.]) a = a.to(0) #注意!model.to操作不需要承接返回值,這是因為torch.nn.Module(模型基類)的這個操作是in-place(替換)的 #而tensor的操作不是in-place的,需要承接返回值
2.1 PyTorch基本訓練程式碼範例
import os import random import numpy as np import torch from torch.optim import AdamW from torch.optim.lr_scheduler import StepLR from torch.utils.data import Dataset, DataLoader from torch.utils.data.dataloader import default_collate from torch.nn import CrossEntropyLoss seed = 42 #隨機種子,影響訓練的隨機數邏輯,如果隨機種子確定,每次訓練的結果是一樣的 torch.manual_seed(seed) np.random.seed(seed) random.seed(seed) #確定化cuda、cublas、cudnn的底層隨機邏輯 #否則CUDA會提前最佳化一些運算元,產生不確定性 #這些處理在訓練時也可以不使用 os.environ["CUDA_LAUNCH_BLOCKING"] = "1" os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":16:8" torch.use_deterministic_algorithms(True) #Enable CUDNN deterministic mode torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False #torch模型都繼承於torch.nn.Module class MyModule(torch.nn.Module): def __init__(self, n_classes=2): #優先呼叫基類構造 super().__init__() #單個神經元,一個linear加上一個relu啟用 self.linear = torch.nn.Linear(16, n_classes) self.relu = torch.nn.ReLU() def forward(self, tensor, label): #前向過程 output = {'logits': self.relu(self.linear(tensor))} if label is not None: # 交叉熵loss loss_fct = CrossEntropyLoss() output['loss'] = loss_fct(output['logits'], label) return output #構造一個資料集 class MyDataset(Dataset): #長度是5 def __len__(self): return 5 #如何根據index取得資料集的資料 def __getitem__(self, index): return {'tensor': torch.rand(16), 'label': torch.tensor(1)} #構造模型 model = MyModule() #構造資料集 dataset = MyDataset() #構造dataloader, dataloader會負責從資料集中按照batch_size批次取數,這個batch_size引數就是設定給它的 #collate_fn會負責將batch中單行的資料進行padding dataloader = DataLoader(dataset, batch_size=4, collate_fn=default_collate) #optimizer,負責將梯度累加回原來的parameters #lr就是設定到這裡的 optimizer = AdamW(model.parameters(), lr=5e-4) #lr_scheduler, 負責對learning_rate進行調整 lr_scheduler = StepLR(optimizer, 2) #3個epoch,表示對資料集訓練三次 for i in range(3): # 從dataloader取數 for batch in dataloader: # 進行模型forward和loss計算 output = model(**batch) # backward過程會對每個可訓練的parameters產生梯度 output['loss'].backward() # 建議此時看下model中linear的grad值 # 也就是model.linear.weight.grad # 將梯度累加回parameters optimizer.step() # 清理使用完的grad optimizer.zero_grad() # 調整lr lr_scheduler.step()
3.Transformer結構模型
在2017年之後,Transformer結構模型幾乎橫掃一切統治了NLP領域,後面的CV領域和Audio領域也大放異彩。相比LSTM和CNN結構,Transformer結構好在哪裡呢?
這是LLaMA2的模型結構。
介紹下基本結構和流程:
- Input是原始句子,經過Tokenizer轉變為tokens
- tokens輸入模型,第一個運算元是Embedder,tokens轉換為float tensor
- 之後進入layers,每個layers會包含一個attention結構,計算Q和K的tensor的內積,並將內積機率化,乘以對應的V獲得新的tensor。
- tensor加上輸入的x後(防止層數太深梯度消失)進入Normalization,對tensor分佈進行標準化
- 進入FeedForward(MLP),重新進入下一layer
- 所有的layers計算過後,經過一個linear求出對vocab每個位置的機率
可以看出,Transformer模型的基本原理是讓每個文字的Tensor和其他文字的Tensor做內積(也就是cosine投影值,可以理解為文字的相關程度)。之後把這些相關程度放在一起計算各自佔比,再用佔比比例分別乘以對應文字的Tensor並相加起來,得到了一個新的Tensor(這個Tensor是之前所有Tensor的機率混合,可以理解為對句子所有文字的抽象)。每個文字都進行如上動作,因此生成的新的Tensor和之前輸入的Tensor長度相同(比如輸入十個字,計算得到的Tensor還是十個),在層數不斷堆疊的情況下,最後的Tensor會越來越抽象出文字的深層次意義,用最後輸出的Tensor去計算輸出一個新的文字或分類。
3.1 Transformer對比CNN和LSTM
- CNN有區域性性和平移不變性,促使模型關注區域性資訊。CNN預設了歸納偏差,這使得小樣本訓練可以取得較好效果,但在充分資料訓練下這一效果也被transformer所掩蓋。並且區域性性會忽略全域性關係,導致某些條件下效果不佳
- LSTM的長距離記憶會導致最早的token被加速遺忘,並且其只能注意單側資訊導致了對句子的理解存在偏差。後來雖然引入了雙向LSTM,但其大規模分散式訓練仍然存在技術問題
- Transformer結構並不預設歸納偏差,因此需要大資料量訓練才有較好效果。但其對於token的平行計算大大加速了推理速度,並且對分散式訓練支援較好,因此在目前資料量充足的情況下反而異軍突起。由於內建了positional-embedding,因此較好地解決了attention結構中的位置不敏感性
3.2 Encoder和Decoder
如上圖所示,左邊是encoder,右邊是decoder。我們可以看到目前的LLM模型幾乎都是decoder結構,為什麼encoder-decoder結構模型消失了呢?有以下幾個原因:
- encoder-decoder模型分散式訓練困難 decoder模型結構簡單,其分散式訓練相對容易,而encoder-decoder結構的模型由於結構複雜的多導致了訓練時工程結構複雜,成本大大增加
- 有論文證明,encoder-decoder模型在引數量不斷增加時不具有顯著優勢。在模型較小時,由於中間隱變數的存在,decoder部分進行交叉注意力會獲得更好的效果,但隨著模型增大,這些提升變得不再明顯。甚至有論文猜測,encoder-decoder結構的收益僅僅是因為引數量翻倍
因此,目前的模型都是decoder模型,encoder-decoder模型幾乎銷聲匿跡。
我們可以看到,LLaMA2的模型特點是:
- 沒有使用LayerNorm,而是使用了RMSNorm進行預歸一化
- 使用了RoPE(Rotary Positional Embedding)
- MLP使用了SwiGLU作為啟用函式
- LLaMA2的大模型版本使用了Group Query Attention(GQA)
3.2.1 RMSNorm
LayerNorm的公式是:
RMSNorm的開發者發現,減去均值做中心偏移意義不大,因此簡化了歸一化公式,最終變為:
\begin{align} \begin{split} & \bar{a}_i = \frac{a_i}{\text{RMS}(\mathbf{a})} g_i, \quad \text{where}~~ \text{RMS}(\mathbf{a}) = \sqrt{\frac{1}{n} \sum_{i=1}^{n} a_i^2} \end{split}\nonumber \end{align}
最終在保持效果不變的情況下,計算時間提升了40%左右。
3.2.2 RoPE
BERT模型使用的原始位置編碼是Sinusoidal Position Encoding。該位置編碼的原理非常簡單:
該設計的主要好處在於:
- 在位置編碼累加到embedding編碼的條件下,基本滿足不同位置編碼的內積可以模擬相對位置的數值
- 隨著相對位置增大,其位置編碼的內積趨近於0
- 具備一定的外推特性
LLM常用的位置編碼還有AliBi(注意力線性偏置)。該方法不在embedding上直接累加位置編碼,而選擇在Q*K的結果上累加一個位置矩陣:
ALiBi的好處在於:
- 具備良好的外推特性
- 相對位置數值很穩定
RoPE的全稱是旋轉位置編碼(Rotary Positional Embedding),該編碼的推導過程和Sinusoidal Position Encoding的推導過程比較類似,不同之處在於後者是加性的,而前者是乘性的,因此得到的位置編碼類似於:
或者也可以簡化為:
該位置編碼表示相對位置的幾何意義比較明顯,也就是兩個向量的角度差。
該位置編碼的優勢在於:
- 位置編碼矩陣是單位正交陣,因此乘上位置編碼後不會改變原向量模長
- 相較於Sinusoidal Position Encoding具備了更好的外推特性
3.2.3 SwiGLU
SwiGLU是GLU結構的變種。GLU是和LSTM原理類似,但不能接受時序資料,只能處理定長資料。而且省略了遺忘門與記憶門,只保留了輸入門,SwiGLU是將其中的啟用函式替換為了SiLU:
其中
的表示式為:
在SwiGLU的論文中,作者論證了SwiGLU在LOSS收益上顯著強於ReLU、GeLU、LeakyGeLU等其他啟用方法。
3.2.4 GQA
MHA(Multi-head Attention)是標準的多頭注意力機制,具有H個Query、Key 和 Value 矩陣
MQA(Multi-Query Attention,來自於論文:Fast Transformer Decoding: One Write-Head is All You Need)共享了注意力頭之間的KV,只為每個頭保留單獨的Q引數,減少了視訊記憶體佔用。
GQA(Grouped-Query Attention,來自於論文:GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints)在MQA的基礎上分成了G個組,組內共享KV。
在Llama2模型中,70B引數為了提升推理效能使用了GQA,其他版本沒有使用這項技術。
3.3 ChatGLM2的模型結構
ChatGLM2模型結構和Llama2的結構有一定相似之處,主要不同之處在於:
- 在開源的ChatGLM2程式碼中沒有使用GQA,而是使用了MQA
- QKV為單一矩陣,在對hidden_state進行整體仿射後拆分為Query、Key、Value
- MLP結構中沒有使用Up、Gate、Down三個Linear加上SwiGLU,而是使用了hidden_size -> 2 * ffn_hidden_size的Up Linear進行上取樣,對tensor進行拆分為兩個寬度為ffn_hidden_size的tensor後直接輸入SiLU,然後經過ffn_hidden_size -> hidden_size的Down Linear進行下采樣
點選關注,第一時間瞭解華為雲新鮮技術~