【CSDN 編者按】 近日,一名工程師 Jay Mody 在一篇文章匯總將用 60 行 NumPy 代碼從頭實現(xiàn)一個 GPT。并把 GPT-2 模型權重加載到實現(xiàn)中,從而生成文本。
原文鏈接:https://jaykmody.com/blog/gpt-from-scratch/
【資料圖】
未經(jīng)授權,禁止轉載!
作者 | Jay Mody 譯者 | 禾木木 出品 | CSDN(ID:CSDNnews)在本篇文章中,作者將用 60 行 NumPy 代碼從頭實現(xiàn)一個 GPT。 并把 GPT-2 模型權重加載到實現(xiàn)中,從而生成文本。
這篇文章假設你已熟悉 Python、NumPy 和一些訓練神經(jīng)網(wǎng)絡的基本經(jīng)驗。
這個實現(xiàn)缺少大量的功能,目的是在保持完整的同時盡可能的簡單。 我們的目標是為 GPT 作為一種教育工具提供一個簡單而完整的技術介紹。
了解 GPT 架構只是 LLM 難題中至關重要的一小部分。
什么是 GPT?
GPT 是 Generative Pre-trained Transformer 的縮寫。這是一種基于 Transformer 的神經(jīng)網(wǎng)絡架構。
Generative:GPT 生成文本。
Pre-trained:GPT 根據(jù)大量的書籍、互聯(lián)網(wǎng)等文本上訓練出來。
Transformer:GPT 是一個僅有解碼器的變換器神經(jīng)網(wǎng)絡。
像 GPT-3、LaMDA 和 Command XLarge 這類的大型語言模型(LLMs)都只是底層的 GPT。它們的特殊之處在于:1)非常大(數(shù)十億的參數(shù));2)在大量的數(shù)據(jù)上進行訓練(數(shù)百GB的文本)。
從根本上說,GPT 生成的是有提示的文本,即使有了這個非常簡單的 API(input=text,output=text),一個訓練有素的 GPT 可以做一些非常棒的事情,例如寫一封電子郵件、總結一本書、給 Instagram 起一些標題、向一個 5 歲的孩子解釋黑洞,用 SQL 編碼,甚至寫遺囑。
這就是對 GPT 及其能力的高級概述。接下來讓我們深入了解更多細節(jié)。
Input / Output
GPT 的函數(shù)簽名大致如下所示:
def gpt(inputs: list[int]) ->list[list[float]]:
# inputs has shape [n_seq]
# output has shape [n_seq, n_vocab]
output = # beep boop neural network magic
return output
Input
Input 是一個整數(shù)序列,表示某些文本的標記:
# integers represent tokens in our text, for example:
# text = \"not all heroes wear capes\":
# tokens = \"not\" \"all\" \"heroes\" \"wear\" \"capes\"
inputs = [1, 0, 2, 4, 6]
我們基于標記器的詞匯量來確定一個指令的整數(shù)值:
# the index of a token in the vocab represents the integer id for that token
# i.e. the integer id for \"heroes\" would be 2, since vocab[2] = \"heroes\"
vocab = [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
# a pretend tokenizer that tokenizes on whitespace
tokenizer = WhitespaceTokenizer(vocab)
# the encode() method converts a str ->list[int]
ids = tokenizer.encode(\"not all heroes wear\") # ids = [1, 0, 2, 4]
# we can see what the actual tokens are via our vocab mapping
tokens = [tokenizer.vocab[i] for i in ids] # tokens = [\"not\", \"all\", \"heroes\", \"wear\"]
# the decode() method converts back a list[int] ->str
text = tokenizer.decode(ids) # text = \"not all heroes wear\"
簡而言之:
我們有一個字符串
我們使用一個標記器將其分解成更小的部分,稱為指令(tokens)
我們使用詞匯表將這 些標記映射成整數(shù)。
在實踐中,我們使用更先進的標記化方法,而不是簡單地通過空白分割,例如 Byte-Pair Encoding 或 WordPiece,但原理是一樣的:
有一個詞匯表,將字符串標記映射為整數(shù)索引
有一個編碼方法可以將str->list[int]轉換。
有一個解碼方法可以將list[int] ->str轉換。
Output
Output 是一個二維數(shù)組,其中 output[i][j] 是模型的預測概率,即 vocab[j] 的令牌是下一個指令 inputs[i+1]。 例如:
vocab = [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
inputs = [1, 0, 2, 4] # \"not\" \"all\" \"heroes\" \"wear\"
output = gpt(inputs)
# [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
# output[0] = [0.75 0.1 0.0 0.15 0.0 0.0 0.0 ]
# given just \"not\", the model predicts the word \"all\" with the highest probability
# [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
# output[1] = [0.0 0.0 0.8 0.1 0.0 0.0 0.1 ]
# given the sequence [\"not\", \"all\"], the model predicts the word \"heroes\" with the highest probability
# [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
# output[-1] = [0.0 0.0 0.0 0.1 0.0 0.05 0.85 ]
# given the whole sequence [\"not\", \"all\", \"heroes\", \"wear\"], the model predicts the word \"capes\" with the highest probability
為了獲得整個序列的下一個指令預測,我們只需取 output[-1] 中概率最高的一個指令 :
vocab = [\"all\", \"not\", \"heroes\", \"the\", \"wear\", \".\", \"capes\"]
inputs = [1, 0, 2, 4] # \"not\" \"all\" \"heroes\" \"wear\"
output = gpt(inputs)
next_token_id = np.argmax(output[-1]) # next_token_id = 6
next_token = vocab[next_token_id] # next_token = \"capes\"
將概率最高的指令作為我們的最終預測,通常被稱為 greedy decoding 或 greedy sampling。
預測一個序列中的下一個邏輯詞的任務被稱為語言建模。 因此,我們可以把 GPT 稱為語言模型。
生成一個詞是很酷,但整個句子、段落等呢...?
生成文本
自回歸
我們可以通過反復詢問模型預測下一個指令來生成完整的句子。 在每次迭代時,我們將預測的指令追加到輸入中:
def generate(inputs, n_tokens_to_generate):
for _ in range(n_tokens_to_generate): # auto-regressive decode loop
output = gpt(inputs) # model forward pass
next_id = np.argmax(output[-1]) # greedy sampling
inputs = np.append(out, [next_id]) # append prediction to input
return list(inputs[len(inputs) - n_tokens_to_generate :]) # only return generated ids
input_ids = [1, 0] # \"not\" \"all\"
output_ids = generate(input_ids, 3) # output_ids = [2, 4, 6]
output_tokens = [vocab[i] for i in output_ids] # \"heroes\" \"wear\" \"capes\"
這個預測未來值(回歸),并將其加回輸入(自動)的過程就是為什么你可能看到 GPT 被描述為自回歸的原因。
采樣
我們可以通過從概率分布中抽樣而不是貪婪地樣,為我們的生成引入一些隨機性(隨機性):
inputs = [1, 0, 2, 4] # \"not\" \"all\" \"heroes\" \"wear\"
output = gpt(inputs)
np.random.choice(np.arange(vocab_size), p=output[-1]) # capes
np.random.choice(np.arange(vocab_size), p=output[-1]) # hats
np.random.choice(np.arange(vocab_size), p=output[-1]) # capes
np.random.choice(np.arange(vocab_size), p=output[-1]) # capes
np.random.choice(np.arange(vocab_size), p=output[-1]) # pants
它不僅允許我們?yōu)橄嗤妮斎肷刹煌木渥樱遗c greedy decoding 相比,它還提高了輸出的質(zhì)量。
在抽樣之前,使用 top-k、top-p 和溫度等技術來修改概率分布也是很常見的。 這進一步提高了生成的質(zhì)量,并引入了超參數(shù),我們可以通過這些參數(shù)來獲得不同的生成行為(例如,增加溫度使我們的模型承擔更多的風險,從而更有 \"創(chuàng)造性\")。
訓練
我們像其他神經(jīng)網(wǎng)絡一樣訓練 GPT,使用梯度下降法來訓練一些損失函數(shù)。 在 GPT 的情況下,我們將交叉熵損失用于語言建模任務:
def lm_loss(inputs: list[int], params) ->float:
# the labels y are just the input shifted 1 to the left
#
# inputs = [not, all, heros, wear, capes]
# x = [not, all, heroes, wear]
# y = [all, heroes, wear, capes]
#
# of course, we don"t have a label for inputs[-1], so we exclude it from x
#
# as such, for N inputs, we have N - 1 langauge modeling example pairs
x, y = inputs[:-1], inputs[1:]
# forward pass
# all the predicted next token probability distributions at each position
output = gpt(x, params)
# cross entropy loss
# we take the average over all N-1 examples
loss = np.mean(-np.log(output[y]))
return loss
def train(texts: list[list[str]], params) ->float:
for text in texts:
inputs = tokenizer.encode(text)
loss = lm_loss(inputs, params)
gradients = compute_gradients_via_backpropagation(loss, params)
params = gradient_descent_update_step(gradients, params)
return params
為了清楚起見,我們在 GPT 的輸入中添加了 params 參數(shù)。 在訓練循環(huán)的每一次迭代中,我們執(zhí)行梯度下降步驟來更新模型參數(shù),使我們的模型在看到每一段新的文本時都能更好地進行語言建模。 這是一個非常簡化的訓練設置。
請注意,我們沒有使用明確標記的數(shù)據(jù)。 相反,我們能夠從原始文本本身產(chǎn)生輸入/標簽對。 這就是所謂的自我監(jiān)督學習。
這將意味著我們可以非常容易地擴大訓練數(shù)據(jù),只需向模型展示盡可能多的原始文本。 例如,GPT-3 使用了來自互聯(lián)網(wǎng)和書籍的 3000 億個文本標記上進行訓練。
GPT-2 論文中的表 2.3
你需要一個足夠大的模型,以便能夠從所有數(shù)據(jù)中學習,這就是為什么 GPT-3 有 1750 億個參數(shù),并且可能需要花費 100 萬到 1000 萬美元的計算成本來訓練。
這種自我監(jiān)督的訓練步驟被稱為預訓練,因為我們可以重復使用 \"預訓練 \"的模型權重,以便在下游任務上進一步訓練模型,例如分類推文是否有毒。
在下游任務上訓練模型被稱為微調(diào),因為模型的權重已經(jīng)被預訓練成能夠理解語言,所以它只是針對當前的具體任務進行微調(diào)。
這種 \"一般任務進行預訓練+特定任務進行微調(diào) \"的策略被稱為轉移學習。
提示
原則上,最初的 GPT 只是關于預訓練轉換學習的轉化器模型的益處,類似于 BERT。
直到在 GPT-2 和 GPT-3 的論文中,我們才意識到一個預訓練好的 GPT 模型本身能夠執(zhí)行任何任務,只需提示它并進行自回歸語言建模,不需要微調(diào)。 這被稱為語境學習,因為該模型只是利用提示的語境來執(zhí)行任務。 語境學習可以是零次、一次或幾次。
當然,你可以將 GPT 看作是一個聊天機器人,而不是讓它明確地做 \"任務\"。 對話歷史被作為提示傳遞到模型中,也許會在前面加上一些描述,如 \"你是一個聊天機器人等\"。 如果你改變了提示,你甚至可以給你的聊天機器人一個角色。
有了這些,讓我們最后來看看實際的實現(xiàn)吧。
安裝
克隆本教程的存儲庫:
git clone https://github.com/jaymody/picoGPT
cd picoGPT
安裝依賴項:
pip install -r requirements.txt
請注意,如果你使用的是 M1 Macbook,則在運行 pip 安裝之前,需要在 requirements.txt 中將 tensorflow 更改為 tensorflow macos。 此代碼在 Python 3.9.10上 進行了測試。
每個文件的快速細分:
encoder.py 包含 OpenAI 的 BPE Tokenizer 的代碼;
utils.py 包含下載和加載 GPT-2 模型權重、標記器和超參數(shù)的代碼;
gpt2.py 包含實際的 GPT 模型和生成代碼,我們可以將其作為 python 腳本運行;
gpt2pico.py 與 gpt2.py 相同,但代碼行更少。
我們將從頭開始重新實現(xiàn) gpt2.py,因此讓我們刪除它并將其重新創(chuàng)建為空文件:
rm gpt2.py
touch gpt2.py
首先,將以下代碼粘貼到 : gpt2.py
import numpy as np
def gpt2(inputs, wte, wpe, blocks, ln_f, n_head):
pass # TODO: implement this
def generate(inputs, params, n_head, n_tokens_to_generate):
from tqdm import tqdm
for _ in tqdm(range(n_tokens_to_generate), \"generating\"): # auto-regressive decode loop
logits = gpt2(inputs, **params, n_head=n_head) # model forward pass
next_id = np.argmax(logits[-1]) # greedy sampling
inputs = np.append(inputs, [next_id]) # append prediction to input
return list(inputs[len(inputs) - n_tokens_to_generate :]) # only return generated ids
def main(prompt: str, n_tokens_to_generate: int = 40, model_size: str = \"124M\", models_dir: str = \"models\"):
from utils import load_encoder_hparams_and_params
# load encoder, hparams, and params from the released open-ai gpt-2 files
encoder, hparams, params = load_encoder_hparams_and_params(model_size, models_dir)
# encode the input string using the BPE tokenizer
input_ids = encoder.encode(prompt)
# make sure we are not surpassing the max sequence length of our model
assert len(input_ids) + n_tokens_to_generate < hparams[\"n_ctx\"]
# generate output ids
output_ids = generate(input_ids, params, hparams[\"n_head\"], n_tokens_to_generate)
# decode the ids back into a string
output_text = encoder.decode(output_ids)
return output_text
if __name__ == \"__main__\":
import fire
fire.Fire(main)
細分為 4 個部分:
1、gpt2 函數(shù)是我們將要實現(xiàn)的實際 GPT 代碼。 您會注意到,除了輸入之外,函數(shù)簽名還包含一些額外的內(nèi)容:
wte、wpe、block 和 lnf 是我們模型的參數(shù)。
n_head 是正向傳遞期間需要的超參數(shù)。
2、該函數(shù)是我們此前了解的自回歸解碼算法。 為了簡單起見,我們使用貪婪采樣。 tqdm 是一個進度條,幫助我們可視化解碼過程,因為它一次生成一個指令。
3、main( )主函數(shù)處理:
加載標記器(編碼器)、模型權重(參數(shù))和超參數(shù)(hparam)
使用 tokenizer 將輸入提示編碼為指令 ID
調(diào)用生成函數(shù)
將輸出 ID 解碼為字符串
4、fire.fire(main)只是將我們的文件轉換成一個 CLI 應用程序,因此我們最終可以使用:python-gpt2.py“some prompt here”運行代碼
讓我們仔細看看編碼器、hparam 和 params,在筆記本或交互式 Python 會話中,運行:
from utils import load_encoder_hparams_and_params
encoder, hparams, params = load_encoder_hparams_and_params(\"124M\", \"models\")
這將把必要的模型和標記器文件下載到我們的代碼中,并將編碼器、hparam 和 params 加載到我們的代碼中。
編碼器
encoder 是 GPT-2 使用的 BPE tokenizer。
>>>ids = encoder.encode(\"Not all heroes wear capes.\")
>>>ids
[3673, 477, 10281, 5806, 1451, 274, 13]
>>>encoder.decode(ids)
\"Not all heroes wear capes.\"
使用 tokenize r的詞匯(存儲在 encoder.decoder 中),我們可以看一下實際的指令是什么。
>>>[encoder.decoder[i] for i in ids]
["Not", "?all", "?heroes", "?wear", "?cap", "es", "."]
注意,有時我們的指令是單詞(如Not),有時是單詞但前面會有空格(如 ?all,? 代表空格),有時是單詞的一部分(如 capes 被分成 ?cap 和 es),有時是標點符號(如.)。
BPE 的一個好處是它可以對任何任意的字符串進行編碼。如果它遇到了詞匯表中沒有的東西,它只是將其分解為它所理解的子字符串:
>>>[encoder.decoder[i] for i in encoder.encode(\"zjqfl\")]
["z", "j", "q", "fl"]
我們還可以檢查詞匯的大?。?
>>>len(encoder.decoder)
50257
當我們加載 tokenizer 時,我們正在從一些文件中加載已經(jīng)訓練好的詞匯和字節(jié)對合并,這些文件是在運行 load_encoder_hparams_and_param 時與模型文件一起下載。
超參數(shù)
hparams 是一個字典,包含模型的超參數(shù):
>>>hparams
{
\"n_vocab\": 50257, # number of tokens in our vocabulary
\"n_ctx\": 1024, # maximum possible sequence length of the input
\"n_embd\": 768, # embedding dimension (determines the \"width\" of the network)
\"n_head\": 12, # number of attention heads (n_embd must be divisible by n_head)
\"n_layer\": 12 # number of layers (determines the \"depth\" of the network)
}
我們將在代碼的注釋中使用這些符號來顯示事物的基本形態(tài)。我們還將使用 n_seq 來表示我們輸入序列的長度(即n_seq = len(inputs))。
參數(shù)
params 是一個嵌套的 Json 字典,用來保存我們模型的訓練權重。Json 的葉節(jié)點是 NumPy 數(shù)組。如果我們打印 params,用它們的形狀替換數(shù)組,我們會得到:
>>>import numpy as np
>>>def shape_tree(d):
>>>if isinstance(d, np.ndarray):
>>>return list(d.shape)
>>>elif isinstance(d, list):
>>>return [shape_tree(v) for v in d]
>>>elif isinstance(d, dict):
>>>return {k: shape_tree(v) for k, v in d.items()}
>>>else:
>>>ValueError(\"uh oh\")
>>>
>>>print(shape_tree(params))
{
\"wpe\": [1024, 768],
\"wte\": [50257, 768],
\"ln_f\": {\"b\": [768], \"g\": [768]},
\"blocks\": [
{
\"attn\": {
\"c_attn\": {\"b\": [2304], \"w\": [768, 2304]},
\"c_proj\": {\"b\": [768], \"w\": [768, 768]},
},
\"ln_1\": {\"b\": [768], \"g\": [768]},
\"ln_2\": {\"b\": [768], \"g\": [768]},
\"mlp\": {
\"c_fc\": {\"b\": [3072], \"w\": [768, 3072]},
\"c_proj\": {\"b\": [768], \"w\": [3072, 768]},
},
},
... # repeat for n_layers
]
}
這些都是從最初的 OpenAI tensorflow 檢查點加載的:
>>>import tensorflow as tf
>>>tf_ckpt_path = tf.train.latest_checkpoint(\"models/124M\")
>>>for name, _ in tf.train.list_variables(tf_ckpt_path):
>>>arr = tf.train.load_variable(tf_ckpt_path, name).squeeze()
>>>print(f\"{name}: {arr.shape}\")
model/h0/attn/c_attn/b: (2304,)
model/h0/attn/c_attn/w: (768, 2304)
model/h0/attn/c_proj/b: (768,)
model/h0/attn/c_proj/w: (768, 768)
model/h0/ln_1/b: (768,)
model/h0/ln_1/g: (768,)
model/h0/ln_2/b: (768,)
model/h0/ln_2/g: (768,)
model/h0/mlp/c_fc/b: (3072,)
model/h0/mlp/c_fc/w: (768, 3072)
model/h0/mlp/c_proj/b: (768,)
model/h0/mlp/c_proj/w: (3072, 768)
model/h1/attn/c_attn/b: (2304,)
model/h1/attn/c_attn/w: (768, 2304)
...
model/h9/mlp/c_proj/b: (768,)
model/h9/mlp/c_proj/w: (3072, 768)
model/ln_f/b: (768,)
model/ln_f/g: (768,)
model/wpe: (1024, 768)
model/wte: (50257, 768)
以下代碼將上述 tensorflow 變量轉換為 params 字典。
作為參考,以下是參數(shù)的形狀,但數(shù)字由它們所代表的 hparams 代替:
{
\"wpe\": [n_ctx, n_embd],
\"wte\": [n_vocab, n_embd],
\"ln_f\": {\"b\": [n_embd], \"g\": [n_embd]},
\"blocks\": [
{
\"attn\": {
\"c_attn\": {\"b\": [3*n_embd], \"w\": [n_embd, 3*n_embd]},
\"c_proj\": {\"b\": [n_embd], \"w\": [n_embd, n_embd]},
},
\"ln_1\": {\"b\": [n_embd], \"g\": [n_embd]},
\"ln_2\": {\"b\": [n_embd], \"g\": [n_embd]},
\"mlp\": {
\"c_fc\": {\"b\": [4*n_embd], \"w\": [n_embd, 4*n_embd]},
\"c_proj\": {\"b\": [n_embd], \"w\": [4*n_embd, n_embd]},
},
},
... # repeat for n_layers
]
}
當我們實現(xiàn) GPT 時,你可能會需要回來參考這個字典來檢查權重的形狀。為了一致性,我們將把代碼中的變量名與此字典的關鍵字進行匹配。
基本層
在我們進入實際的 GPT 架構本身之前,讓我們實現(xiàn)一些對 GPT 不特定的更基本的神經(jīng)網(wǎng)絡層。
GELU
GPT-2 選擇的非線性(激活函數(shù))是 GELU(高斯誤差線性單位),是 ReLU 的替代方案。
該圖來自 GELU 論文
它與以下函數(shù)近似:
def gelu(x):
return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))
與 ReLU 一樣,GELU 對輸入元素進行操作:
>>>gelu(np.array([[1, 2], [-2, 0.5]]))
array([[ 0.84119, 1.9546 ],
[-0.0454 , 0.34571]])
BERT 普及了 GeLU 在 transformer 模型中的使用。
Softmax
好的 Softmax:
def softmax(x):
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
我們使用 max(x) 的技巧來實現(xiàn)數(shù)值的穩(wěn)定性。
Softmax 用于將一組實數(shù)轉換為概率(在 0 和 1 之間,數(shù)字的總和為 1)。我們在輸入的最后一個軸上應用 softmax。
>>>x = softmax(np.array([[2, 100], [-5, 0]]))
>>>x
array([[0.00034, 0.99966],
[0.26894, 0.73106]])
>>>x.sum(axis=-1)
array([1., 1.])
層標準化
層標準化將值標準化為平均值為 0,方差為 1:
def layer_norm(x, g, b, eps: float = 1e-5):
mean = np.mean(x, axis=-1, keepdims=True)
variance = np.var(x, axis=-1, keepdims=True)
x = (x - mean) / np.sqrt(variance + eps) # normalize x to have mean=0 and var=1 over last axis
return g * x + b # scale and offset with gamma/beta params
層標準化確保每層的輸入始終在一致的范圍內(nèi),這可以加快和穩(wěn)定訓練過程。與批量標準化一樣,標準化輸出隨后被縮放,并用兩個可學習向量 gamma 和 beta 進行偏移。分母中的小 ε 項用于避免除以零的誤差。
我們在輸入的最后一個軸上應用層標準化。
>>>x = np.array([[2, 2, 3], [-5, 0, 1]])
>>>x = layer_norm(x, g=np.ones(x.shape[-1]), b=np.zeros(x.shape[-1]))
>>>x
array([[-0.70709, -0.70709, 1.41418],
[-1.397 , 0.508 , 0.889 ]])
>>>x.var(axis=-1)
array([0.99996, 1. ]) # floating point shenanigans
>>>x.mean(axis=-1)
array([-0., -0.])
線性
標準矩陣乘法 + 偏差:
def linear(x, w, b): # [m, in], [in, out], [out] ->[m, out]
return x @ w + b
線性層通常被稱為投影(因為它們是從一個矢量空間投射到另一個矢量空間)。
GPT 架構
GPT 架構遵循 transformer 的架構:
但僅使用解碼器堆棧(圖表的右側部分):
GPT架構
概括來說,GPT 架構有三個部分:
文本+位置嵌入
一個 transformer 解碼器棧
一個投射到詞匯的步驟
在代碼中,它看起來像這樣:
def gpt2(inputs, wte, wpe, blocks, ln_f, n_head): # [n_seq] ->[n_seq, n_vocab]
# token + positional embeddings
x = wte[inputs] + wpe[range(len(inputs))] # [n_seq] ->[n_seq, n_embd]
# forward pass through n_layer transformer blocks
for block in blocks:
x = transformer_block(x, **block, n_head=n_head) # [n_seq, n_embd] ->[n_seq, n_embd]
# projection to vocab
x = layer_norm(x, **ln_f) # [n_seq, n_embd] ->[n_seq, n_embd]
return x @ wte.T # [n_seq, n_embd] ->[n_seq, n_vocab]
接下來我們將這三部分的內(nèi)容逐一細化。
嵌入
指令嵌入
對于神經(jīng)網(wǎng)絡來說,指令 ID 本身并不是很好的表示。首先,指令 ID 的相對大小錯誤地傳達了信息(例如,如果在我們的詞匯中Apple = 5Table = 10 ,那么我們就意味著 2 * Table = Apple)。其次,對于神經(jīng)網(wǎng)絡來說,單個數(shù)字的維度并不高。
為了解決這些限制,我們將利用詞向量的優(yōu)勢,尤其是通過學習嵌入矩陣:
wte[inputs] # [n_embd] ->[n_seq, n_embd]
會想一下,wte 是一個 [n_vocab, n_embd] 矩陣。它作為一個查找表,矩陣中的第 3 行對應于我們詞匯中第 1 個指令的學習向量。wte[inputs] 使用整數(shù)數(shù)組索引來檢索對應于我們輸入中每個指令的向量。
像我們網(wǎng)絡中的其他參數(shù)一樣,wte 是學習的。也就是說,它在訓練開始時是隨機初始化的,然后通過梯度下降進行更新。
位置嵌入
Transformer 架構的一個怪癖是它沒有考慮到位置。也就是說,如果我們隨機打亂輸入,然后相應地取消打亂輸出,輸出將與我們一開始從未打亂輸入的情況相同(輸入的排序?qū)敵鰶]有任何影響)。
當然,單詞的排序是語言的一個關鍵部分(duh),所以我們需要一些方法來將位置信息編碼到我們的輸入中。為此,我們可以直接使用另一個學習的嵌入矩陣:
wpe[range(len(inputs))] # [n_seq] ->[n_seq, n_embd]
回想一下,wpe 是一個 [n_ctx, n_embd] 矩陣。矩陣的第 3 行包含一個矢量,編碼輸入中第 1 個位置的信息。與 wte 類似,這個矩陣是在梯度下降過程中學習的。
注意,這將我們的模型限制在最大序列長度為 n_ctx。也就是說,len(inputs)<= n_ctx 必須成立。
組合
我們可以把我們的標記和位置嵌入加在一起,得到一個同時編碼標記和位置信息的組合嵌入。
# token + positional embeddings
x = wte[inputs] + wpe[range(len(inputs))] # [n_seq] ->[n_seq, n_embd]
# x[i] represents the word embedding for the ith word + the positional
# embedding for the ith position
解碼器棧
這是所有魔法發(fā)生的地方,也是深度學習中的 \"深度 \"所在。我們將傳遞 n_layer 轉化器-解碼器塊傳遞嵌入。
# forward pass through n_layer transformer blocks
for block in blocks:
x = transformer_block(x, **block, n_head=n_head) # [n_seq, n_embd] ->[n_seq, n_embd]
堆疊更多的層使我們能夠控制我們的網(wǎng)絡深度。例如,GPT-3 有高達 96 層。另一方面,選擇一個更大的 n_embd 值可以讓我們控制我們的網(wǎng)絡的寬度(例如,GPT-3 使用的嵌入尺寸為 12288)。
投影到Vocab
在我們的最后一步中,我們將最后的 transformer 塊的輸出投射到我們的詞匯表的概率分布上。
# projection to vocab
x = layer_norm(x, **ln_f) # [n_seq, n_embd] ->[n_seq, n_embd]
return x @ wte.T # [n_seq, n_embd] ->[n_seq, n_vocab]
注意:
1、在進行投射到 vocab 之前,我們首先將 x 通過最后一層標準化層。這是 GPT-2 架構所特有的。
2、我們正在重新使用嵌入矩陣 wte 進行投影。其他 GPT 實現(xiàn)可以選擇使用單獨的學習權重矩陣進行投影,但是共享嵌入矩陣有幾個好處。
你可以節(jié)省一些參數(shù)(盡管在GPT-3的規(guī)模下,也忽略不計)。
由于該矩陣既負責到詞的映射,又負責從詞的映射,所以從理論上講,與擁有兩個單獨的矩陣相比,它可能會學到更豐富的表示。
3、我們不在最后應用 softmax,所以我們的輸出將是邏輯,而不是 0 和 1 之間的概率。 這樣做有以下幾個原因:
softmax 是單調(diào),所以對于貪婪采樣來說,np.argmax(logits) 等同于np.argmax(softmax(logits)),使得 softmax 成為多余。
softmax 是不可逆,這意味著我們總是可以通過應用 softmax 從邏輯到概率,但我們不能從概率回到邏輯,所以為了獲得最大的靈活性,我們輸出邏輯數(shù)值穩(wěn)定(例如,為了計算交叉熵損失,與 log_softmax(logits)相比,取log(softmax(logits))在數(shù)值上是不穩(wěn)定的。
投射到詞匯表的步驟有時也被稱為語言建模的頭。 \"頭 \"是什么意思? 一旦你的 GPT 被預訓練,你可以用其他類型的投影來替換語言建模頭,比如分類頭,用于在某些分類任務上對模型進行微調(diào)。 所以你的模型可以有多個頭,有點像九頭蛇。
這就是高水平的 GPT 架構,讓我們實際深入了解一下解碼器塊在做什么。
解碼器塊
transformer 解碼器塊由兩個子層組成:
多頭因果自我關注
定位的前饋神經(jīng)網(wǎng)絡
def transformer_block(x, mlp, attn, ln_1, ln_2, n_head): # [n_seq, n_embd] ->[n_seq, n_embd]
# multi-head causal self attention
x = x + mha(layer_norm(x, **ln_1), **attn, n_head=n_head) # [n_seq, n_embd] ->[n_seq, n_embd]
# position-wise feed forward network
x = x + ffn(layer_norm(x, **ln_2), **mlp) # [n_seq, n_embd] ->[n_seq, n_embd]
return x
每個子層在其輸入上以及剩余連接都利用了層標準化(即將子層的輸入加到子層的輸出上)。
注意:
1、多頭因果自我關注是促進輸入之間交流的因素。 在網(wǎng)絡的其他任何地方,該模型都不允許輸入 \"看到 \"對方。 嵌入、位置前饋網(wǎng)絡、層規(guī)范和 vocab 的投影都是基于我們的輸入位置上操。 對輸入之間的關系進行建模的任務完全由注意力來完成。
2、位置式前饋神經(jīng)網(wǎng)絡只是一個普通的 2 層完全連接神經(jīng)網(wǎng)絡。這只是為我們的模型增加了一堆可學習的參數(shù),以促進學習。
3、在最初的變壓器論文中,層范數(shù)被放在輸出層 _norm(x + sublayer(x)) 上,而我們將層規(guī)范放在輸入 x + sublayer(layer_norm(x)) 上以匹配 GPT-2。這被稱為預規(guī)范,已被證明對提高變壓器的性能很重要。
4、剩余連接(由ResNet推廣)有不同的用途:
更容易優(yōu)化深度神經(jīng)網(wǎng)絡(即有很多層的網(wǎng)絡)。這里的想法是,我們?yōu)樘荻然亓骶W(wǎng)絡提供 \"捷徑\",使其更容易優(yōu)化網(wǎng)絡中的早期層。
如果沒有剩余連接,更深的模型在增加層數(shù)時性能會下降(可能是因為梯度很難在不丟失信息的情況下全部流回深度網(wǎng)絡)。剩余連接似乎給深層網(wǎng)絡帶來了一些準確性的提升。
可以幫助解決梯度消失/爆炸的問題。
讓我們對這兩個子層進行更深入地了解。
位置式前饋網(wǎng)絡
這只是一個具有 2 層的簡單多層感知器:
def ffn(x, c_fc, c_proj): # [n_seq, n_embd] ->[n_seq, n_embd]
# project up
a = gelu(linear(x, **c_fc)) # [n_seq, n_embd] ->[n_seq, 4*n_embd]
# project back down
x = linear(a, **c_proj) # [n_seq, 4*n_embd] ->[n_seq, n_embd]
return x
我們只是從 n_embd 投射到一個更高的維度 4*n_embd,然后再回落到 n_embd。
回顧一下,在我們的參數(shù)字典中,我們的 mlp 參數(shù)是這樣的:
\"mlp\": {
\"c_fc\": {\"b\": [4*n_embd], \"w\": [n_embd, 4*n_embd]},
\"c_proj\": {\"b\": [n_embd], \"w\": [4*n_embd, n_embd]},
}
多頭因果的自我關注
這一層可能是 transformer 中最難理解的部分。因此,讓我們通過把每個詞分解成自己的部分,來達到 \"多頭因果的自我關注\"。
注意力
自身
因果
多頭
注意力
我們從頭開始推導出原始變壓器論文中提出的縮放點積方程:
我們 只需從博文中改編我們的注意力實現(xiàn):
def attention(q, k, v): # [n_q, d_k], [n_k, d_k], [n_k, d_v] ->[n_q, d_v]
return softmax(q @ k.T / np.sqrt(q.shape[-1])) @ v
自我
當 q、k 和 v 都來自同一來源時,我們正在進行自我關注(即讓我們的輸入序列關注自己):
def self_attention(x): # [n_seq, n_embd] ->[n_seq, n_embd]
return attention(q=x, k=x, v=x)
我們可以通過引入 q、k、v 和注意力輸出的投射來加強自我注意。
def self_attention(x, w_k, w_q, w_v, w_proj): # [n_seq, n_embd] ->[n_seq, n_embd]
# qkv projections
q = x @ w_k # [n_seq, n_embd] @ [n_embd, n_embd] ->[n_seq, n_embd]
k = x @ w_q # [n_seq, n_embd] @ [n_embd, n_embd] ->[n_seq, n_embd]
v = x @ w_v # [n_seq, n_embd] @ [n_embd, n_embd] ->[n_seq, n_embd]
# perform self attention
x = attention(q, k, v) # [n_seq, n_embd] ->[n_seq, n_embd]
# out projection
x = x @ w_proj # [n_seq, n_embd] @ [n_embd, n_embd] ->[n_seq, n_embd]
return x
這使我們的模型能夠?qū)W習一個 q、k 和 v 的映射,最好地幫助注意力區(qū)分輸入之間的關系。
如果我們把 w_q、w_k 和 w_v 合并成一個單一的矩陣 w_fc,進行投影,然后分割結果,就可以把矩陣乘法的次數(shù)從 4 次減少到 2 次:
def self_attention(x, w_fc, w_proj): # [n_seq, n_embd] ->[n_seq, n_embd]
# qkv projections
x = x @ w_fc # [n_seq, n_embd] @ [n_embd, 3*n_embd] ->[n_seq, 3*n_embd]
# split into qkv
q, k, v = qkv = np.split(x, 3, axis=-1) # [n_seq, 3*n_embd] ->3 of [n_seq, n_embd]
# perform self attention
x = attention(q, k, v) # [n_seq, n_embd] ->[n_seq, n_embd]
# out projection
x = x @ w_proj # [n_seq, n_embd] @ [n_embd, n_embd] = [n_seq, n_embd]
return x
這樣做的效率更高一些,因為現(xiàn)代加速器(GPU)可以更好地利用一個大的矩陣乘法,而不是 3 個獨立的小的矩陣乘法順序發(fā)生。
最后,我們添加偏置向量以匹配 GPT-2 的實現(xiàn),使用我們的線性函數(shù),并重新命名我們的參數(shù)以匹配我們的字典 linear
params
。
def self_attention(x, c_attn, c_proj): # [n_seq, n_embd] ->[n_seq, n_embd]
# qkv projections
x = linear(x, **c_attn) # [n_seq, n_embd] ->[n_seq, 3*n_embd]
# split into qkv
q, k, v = qkv = np.split(x, 3, axis=-1) # [n_seq, 3*n_embd] ->3 of [n_seq, n_embd]
# perform self attention
x = attention(q, k, v) # [n_seq, n_embd] ->[n_seq, n_embd]
# out projection
x = linear(x, **c_proj) # [n_seq, n_embd] @ [n_embd, n_embd] = [n_seq, n_embd]
return x
回顧一下,從我們的參數(shù)字典中,我們的 attn 參數(shù)看起來像這樣:
\"attn\": {
\"c_attn\": {\"b\": [3*n_embd], \"w\": [n_embd, 3*n_embd]},
\"c_proj\": {\"b\": [n_embd], \"w\": [n_embd, n_embd]},
},
因果關系
我們目前的自我注意力設置有一點問題,我們的輸入可以看到未來!例如,如果我們的輸入是 [\"not\", \"all\", \"heroes\", \"wear\", \"capes\"],在自我關注期間,我們允許 \"wear\" 看到 \"capes\"。這意味著我們對 \"wear\" 的輸出概率會有偏差,因為模型已經(jīng)知道正確答案是 \"capes\"。這是不好的,由于我們的模型剛剛學會,輸入的正確答案可以從輸入中得到。
為了防止這種情況,我們需要以某種方式修改我們的注意力矩陣,以隱藏或掩蓋我們的輸入,使其無法看到未來。例如,讓我們假裝我們的注意力矩陣看起來像這樣:
not all heroes wear capes
not 0.116 0.159 0.055 0.226 0.443
all 0.180 0.397 0.142 0.106 0.175
heroes 0.156 0.453 0.028 0.129 0.234
wear 0.499 0.055 0.133 0.017 0.295
capes 0.089 0.290 0.240 0.228 0.153
每一行對應于一個查詢,每一列對應于一個鍵。在這種情況下,看一下 \"wear\"這一行,你可以看到它在最后一列參加 \"capes\",權重為0.295。為了防止這種情況,我們要將該條目設置為0.0。
not all heroes wear capes
not 0.116 0.159 0.055 0.226 0.443
all 0.180 0.397 0.142 0.106 0.175
heroes 0.156 0.453 0.028 0.129 0.234
wear 0.499 0.055 0.133 0.017 0.
capes 0.089 0.290 0.240 0.228 0.153
一般來說,為了防止我們的輸入中的所有查詢看向未來,我們把所有的位置都設置為0。
not all heroes wear capes
not 0.116 0. 0. 0. 0.
all 0.180 0.397 0. 0. 0.
heroes 0.156 0.453 0.028 0. 0.
wear 0.499 0.055 0.133 0.017 0.
capes 0.089 0.290 0.240 0.228 0.153
我們將這稱為掩蔽。上述掩蔽方法的一個問題是,我們的行數(shù)之和不再是 1(因為我們在應用 softmax 后將其設置為 0)。為了確保我們的行之和為 1,我們需要在應用 softmax 之前修改我們的注意矩陣。
這可以通過在 softmax 之前設置要被屏蔽的條目來實現(xiàn):
def attention(q, k, v, mask): # [n_q, d_k], [n_k, d_k], [n_k, d_v], [n_q, n_k] ->[n_q, d_v]
return softmax(q @ k.T / np.sqrt(q.shape[-1]) + mask) @ v
其中矩陣(用于):maskn_seq=5
我們使用 -1e10 而不是 -np.inf,因為 -np.inf 會導致 nans。
在我們的注意力矩陣中加入掩碼,而不是明確地將數(shù)值設置為 -1e10,因為實際上,任何數(shù)字加上 -inf 就是 -inf。
我們可以在 NumPy 中用(1-np.tri(n_seq))計算掩碼矩陣 * -1e10.
綜上所述,我們得到:
def attention(q, k, v, mask): # [n_q, d_k], [n_k, d_k], [n_k, d_v], [n_q, n_k] ->[n_q, d_v]
return softmax(q @ k.T / np.sqrt(q.shape[-1]) + mask) @ v
def causal_self_attention(x, c_attn, c_proj): # [n_seq, n_embd] ->[n_seq, n_embd]
# qkv projections
x = linear(x, **c_attn) # [n_seq, n_embd] ->[n_seq, 3*n_embd]
# split into qkv
q, k, v = qkv = np.split(x, 3, axis=-1) # [n_seq, 3*n_embd] ->3 of [n_seq, n_embd]
# causal mask to hide future inputs from being attended to
causal_mask = (1 - np.tri(x.shape[0])) * -1e10 # [n_seq, n_seq]
# perform causal self attention
x = attention(q, k, v, causal_mask) # [n_seq, n_embd] ->[n_seq, n_embd]
# out projection
x = linear(x, **c_proj) # [n_seq, n_embd] @ [n_embd, n_embd] = [n_seq, n_embd]
return x
多頭
我們可以通過執(zhí)行 n_head 單獨的注意力計算來進一步改進我們的實現(xiàn),將我們的查詢、鍵和值分割成頭:
def mha(x, c_attn, c_proj, n_head): # [n_seq, n_embd] ->[n_seq, n_embd]
# qkv projection
x = linear(x, **c_attn) # [n_seq, n_embd] ->[n_seq, 3*n_embd]
# split into qkv
qkv = np.split(x, 3, axis=-1) # [n_seq, 3*n_embd] ->[3, n_seq, n_embd]
# split into heads
qkv_heads = list(map(lambda x: np.split(x, n_head, axis=-1), qkv)) # [3, n_seq, n_embd] ->[3, n_head, n_seq, n_embd/n_head]
# causal mask to hide future inputs from being attended to
causal_mask = (1 - np.tri(x.shape[0])) * -1e10 # [n_seq, n_seq]
# perform attention over each head
out_heads = [attention(q, k, v, causal_mask) for q, k, v in zip(*qkv_heads)] # [3, n_head, n_seq, n_embd/n_head] ->[n_head, n_seq, n_embd/n_head]
# merge heads
x = np.hstack(out_heads) # [n_head, n_seq, n_embd/n_head] ->[n_seq, n_embd]
# out projection
x = linear(x, **c_proj) # [n_seq, n_embd] ->[n_seq, n_embd]
return x
這里增加了三個步驟:
1.將 q、k、v 分成 n_head 頭:
# split into heads
qkv_heads = list(map(lambda x: np.split(x, n_head, axis=-1), qkv)) # [3, n_seq, n_embd] ->[n_head, 3, n_seq, n_embd/n_head]
2.計算每個頭部的注意力:
# perform attention over each head
out_heads = [attention(q, k, v) for q, k, v in zip(*qkv_heads)] # [n_head, 3, n_seq, n_embd/n_head] ->[n_head, n_seq, n_embd/n_head]
3.合并每個頭的輸出:
# merge heads
x = np.hstack(out_heads) # [n_head, n_seq, n_embd/n_head] ->[n_seq, n_embd]
注意,這將每個注意力計算的維度從 n_embd 減少到 n_embd/n_head。為了降低維度,我們的模型在通過注意力建立關系模型時得到了額外的子空間。例如,也許一個注意力頭負責將代詞與代詞所指的人聯(lián)系起來。也許另一個可能負責按時期對句子進行分組。另一個可能只是負責識別哪些詞是實體,哪些不是。雖然,它可能只是另一個神經(jīng)網(wǎng)絡黑盒子。
我們編寫的代碼在一個循環(huán)中按順序?qū)γ總€頭進行注意力計算(一次一個),這樣的效率并不高。在實踐中,你會希望并行地進行這些計算。為了簡單起見,我們還是讓它按順序進行。
至此,我們終于完成了我們的 GPT 實現(xiàn),剩下的就是把它放在一起并運行我們的代碼。
整合
將所有內(nèi)容放在一起,我們得到 gpt2.py,整個代碼只有 120 行(如果刪除注釋和空格,則為 60 行)。
我們將通過一下的方式測試實現(xiàn):
python gpt2.py \
\"Alan Turing theorized that computers would one day become\" \
--n_tokens_to_generate 8
輸出:
the most powerful machines on the planet.
結果證明,它是有效的!
我們可以使用以下 Dockerfile 測試我們的實現(xiàn)是否與 OpenAI 的官方 GPT-2 存儲庫給出相同的結果:
docker build -t \"openai-gpt-2\" \"https://gist.githubusercontent.com/jaymody/9054ca64eeea7fad1b58a185696bb518/raw/Dockerfile\"
docker run -dt \"openai-gpt-2\" --name \"openai-gpt-2-app\"
docker exec -it \"openai-gpt-2-app\" /bin/bash -c "python3 src/interactive_conditional_samples.py --length 8 --model_type 124M --top_k 1"
# paste \"Alan Turing theorized that computers would one day become\" when prompted
這應該給出相同的結果:
the most powerful machines on the planet.
以上就是 Jay Mody 在博客的內(nèi)容,大家有興趣的可以自己試一下~忽略規(guī)模,GPT 的訓練是非常標準的,相對于語言建模損失的梯度下降。當然還有很多技巧,但這些都是標準的東西。訓練一個好的 GPT 模型的真正秘訣是能夠擴展數(shù)據(jù)和模型。對此,你有哪些看法呢~
標簽: 神經(jīng)網(wǎng)絡 也就是說 概率分布