对于想要理解 GPT 工作原理的同学来说,picoGPT是个很好的项目,作者
Jay Mody 不但写了《GPT in 60 Lines of NumPy》 (https://jaykmody.com/blog/gpt-from-scratch/) ,还提供了源码 https://github.com/jaymody/picoGPT ,整个项目的目的就是提供一个简洁易懂的 GPT-2 实现,方便学习者理解其内部工作原理。
picoGPT 代码只演示了 GPT 模型的推理 (Inference) 过程,也就是如何使用已经训练好的模型生成文本。它并没有包含训练 (Training) 的部分。尽管如此,picoGPT 依然能够揭示 GPT 的核心原理,因为它清晰地展示了 GPT 的模型架构和运行机制。
你可以把推理过程想象成“使用说明书”或“操作演示”。 即使你没有看到如何制造汽车(训练),你仍然可以通过驾驶汽车(推理)来理解汽车的引擎(Transformer 架构)、方向盘(注意力机制)、油门(生成过程)是如何工作的。
下面我们就来看这个代码是如何运行的,以及如何实现的。
picoGPT 用起来
我们先把 picoGPT 项目运行起来,通过运行效果,反看代码实现。
首先将这个项目的仓库clone下来:
git config --global http.proxy http://127.0.0.1:7897
git clone https://github.com/jaymody/picoGPT
cd picoGPT
clone下来的代码文件如下:
- encoder.py: 包含了OpenAI的BPE分词器的代码,这是直接从gpt-2仓库复制过来的
- utils.py:包含下载并加载GPT-2模型的权重,分词器和超参数
- gpt2.py:包含了实际GPT模型以及生成的代码,这个代码可以作为python脚本直接运行
- gpt2_pico.py:和gpt2.py一样,删除了注释、合并了一些代码,行数只有60行。
安装依赖:
代码是在Python 3.9.10下测试通过。
conda create -n picogpt python=3.9
conda activate picogpt
pip install -r requirements.txt
运行代码:
% python gpt2.py "Alan Turing theorized that computers would one day become"
2025-01-09 11:44:04.975789: I tensorflow/core/platform/cpu_feature_guard.cc:193] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 FMA
To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
generating: 100%|███████████████████████████████| 40/40 [00:08<00:00, 4.49it/s]
the most powerful machines on the planet.
The computer is a machine that can perform complex calculations, and it can perform these calculations in a way that is very similar to the human brain.
运行中的提示忽略,其中我们问的是让他续写:
艾伦·图灵曾推测计算机
续写出来的中文意思:
艾伦·图灵曾推测,计算机有一天会成为地球上最强大的机器。
计算机是一种可以执行复杂计算的机器,它可以以与人脑非常相似的方式执行这些计算。
从 main 函数开始:总览全局
main 函数是程序的入口点,负责加载预训练的模型参数,对输入的 prompt 进行编码,调用 generate 函数生成文本,并将生成的 token ID 解码为文本。
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)
# 使用编码器将输入的文本 prompt 转换为 token ID 序列。 encode the input string using the BPE tokenizer
input_ids = encoder.encode(prompt)
# 确保生成的总 token 数量不超过模型的上下文长度。 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 函数生成文本 generate output ids
output_ids = generate(input_ids, params, hparams["n_head"], n_tokens_to_generate)
# 使用解码器将生成的 token ID 序列转换回文本。
output_text = encoder.decode(output_ids)
return output_text # 返回生成的文本
if __name__ == "__main__":
# fire 是一个 Python 库,可以将 Python 函数转换为命令行接口,使得函数的参数可以像命令行参数一样传递。
import fire
# 将一个函数转换为命令行接口,使得你可以在命令行中使用各种参数来调用 main 函数。
fire.Fire(main)
main 函数是程序的入口点,主要做了几件事:
-
加载模型 (load_encoder_hparams_and_params): 这部分负责加载预训练好的 GPT 模型参数,就像给 GPT 装上“大脑”。
-
编码输入 (encoder.encode): 将你输入的文本 prompt 转换成模型能够理解的数字 ID 序列。
-
核心生成 (generate): 这是 GPT 生成文本的关键步骤,我们稍后会深入了解。
-
解码输出 (encoder.decode): 将模型生成的数字 ID 序列转换回人类可读的文本。
聚焦核心:generate 函数的“自回归”魔法
接下来,我们深入到 generate 函数,看看 GPT 是如何一步步“说出”下个词的:
def generate(inputs, params, n_head, n_tokens_to_generate):
# 导入 tqdm 库用于显示进度条,方便查看生成过程。
from tqdm import tqdm
for _ in tqdm(range(n_tokens_to_generate), "generating"): # auto-regressive decode loop
# 预测下一个 token 的概率
logits = gpt2(inputs, **params, n_head=n_head) # model forward pass
# 选择概率最高的 token
next_id = np.argmax(logits[-1]) # greedy sampling
# 将预测的 token 加入输入,为下一次预测做准备
inputs.append(int(next_id)) # append prediction to input
return inputs[len(inputs) - n_tokens_to_generate :] # only return generated ids
generate 函数的核心思想是 自回归:
-
预测下一个词 (gpt2): 模型接收当前的输入 inputs,预测下一个可能出现的词的概率分布 (logits)。
-
选择最可能的词 (np.argmax): 从概率分布中选择概率最高的词作为本次生成的词 (next_id)。
-
加入输入 (inputs.append): 将生成的词添加到输入序列的末尾。
-
循环往复: 重复这个过程,直到生成指定数量的词。
深入“大脑”:gpt2 函数的 Transformer 结构
generate 函数中调用了 gpt2 函数,这正是 GPT 模型的核心结构所在:
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]
gpt2 函数主要由以下几部分组成:
-
嵌入层 (wte[inputs] + wpe[range(len(inputs))]): 将输入的词 (word embedding, wte) 和它们在句子中的位置 (positional embedding, wpe) 转换为向量表示,让模型能够理解词的含义和顺序。
-
Transformer 块 (transformer_block): 这是 GPT 的核心组成部分,通过堆叠多个这样的块来处理输入信息。
-
最终层 (layer_norm(x, **ln_f) @ wte.T) : 对 Transformer 块的输出进行处理,最终得到预测下一个词的概率分布。
拆解核心:transformer_block 和注意力机制
transformer_block 函数是理解 GPT 的关键,它包含了多头注意力机制 (mha) 和前馈网络 (ffn):
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
多头注意力 (mha) 让模型能够关注输入序列的不同部分,捕捉词语之间的关联性。这就像你在阅读理解时,会重点关注与问题相关的词语一样。
前馈网络 (ffn) 则对注意力的输出进行进一步处理,提取更高级的特征。
多头注意力函数 mha(x, c_attn, c_proj, n_head):
多头注意力是对自注意力的扩展,允许模型并行地关注不同的特征子空间,提升模型的表达能力。
def mha(x, c_attn, c_proj, n_head): # [n_seq, n_embd] -> [n_seq, n_embd]
# qkv projection 将输入x投影到一个更高维的空间,这个空间被分成三个部分:查询(query)、键(key)、值(value)。
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], dtype=x.dtype)) * -1e10 # [n_seq, n_seq]
# perform attention over each head 对每个头进行注意力计算。attention函数根据查询、键、值和掩码计算注意力分数,并对值进行加权求和。
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 将合并后的结果投影回原始的embedding维度。
x = linear(x, **c_proj) # [n_seq, n_embd] -> [n_seq, n_embd]
return x
这段代码实现了一个多头注意力机制,它通过将输入投影到多个子空间、计算注意力分数、并对值进行加权求和的方式,来捕捉序列数据中的复杂关系。
其核心思想:
-
多头: 通过多个头并行计算注意力,可以捕捉输入序列中不同位置之间的多种相关性。
-
自注意力: 查询、键、值都来自同一个输入序列,使得模型能够捕捉序列内部的依赖关系。
-
因果掩码: 确保模型在生成文本时只关注之前的输入,防止信息泄露。
更细致的观察:注意力机制 (attention)
如果你想更深入地了解,可以继续查看 attention 函数,它展示了注意力机制的具体计算过程:
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
这里可以看到 Query (Q), Key (K), Value (V) 的计算,以及如何通过 softmax 计算注意力权重,并最终加权 Value 向量。
前馈网络函数 ffn(x, c_fc, c_proj)
前馈网络是 Transformer 块的重要组成部分,通过两层线性变换和激活函数处理信息。
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
线性变换函数 linear(x, w, b):
def linear(x, w, b): # [m, in], [in, out], [out] -> [m, out]
return x @ w + b
这是神经网络最基础的操作,进行线性映射。
层归一化函数 layer_norm(x, g, b, eps=1e-5):
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
layer_norm 用于稳定训练过程,加速模型收敛。它对每个样本的特征进行归一化。
Softmax 函数 softmax(x):
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)
激活函数 gelu(x):
gelu 是一种平滑的激活函数,类似于 ReLU。它为神经网络引入非线性,让模型能够学习复杂的模式。
def gelu(x):
return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))
GELU 激活函数的作用是引入非线性。通过这个非线性变换,模型可以拟合更加复杂的数据分布。
总结:从“用起来”到“看明白”
通过这种“反向理解”的方式,我们先体验了 GPT 的使用,然后逐步深入到其核心代码,理解了 GPT 的推理流程和关键组件。虽然 picoGPT 没有包含训练部分,但它清晰地展现了 GPT 的核心架构和运行机制,让你对大模型的原理不再感到遥不可及。
记住,理解 GPT 的关键不在于记住所有细节,而在于理解其核心思想:
-
自回归生成: 一步步预测下一个词。
-
Transformer 架构: 通过注意力机制和前馈网络处理信息。
-
嵌入层: 将文本转换为模型可以理解的向量表示。
希望通过这种“用起来”再“扒开看”的方式,你能够更轻松地理解 GPT 的奥秘!
快去动手运行 picoGPT 的代码,亲自体验一下吧!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 记一次.NET内存居高不下排查解决与启示
2011-01-10 LoadRunner通过SiteScope监控MySQL的性能