随笔 - 934, 文章 - 0, 评论 - 249, 阅读 - 345万

导航

< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

60 行 NumPy 代码带你学习GPT

Posted on   蝈蝈俊  阅读(58)  评论(0编辑  收藏  举报

对于想要理解 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 的代码,亲自体验一下吧!

相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 记一次.NET内存居高不下排查解决与启示
历史上的今天:
2011-01-10 LoadRunner通过SiteScope监控MySQL的性能
点击右上角即可分享
微信分享提示