揭秘GPT内核之四

Karpathy's nanoGPT:从零理解莎士比亚生成器

立委按:鉴于语言大模型GPT的重要性,特此根据AI大神Karpathy的nanoGPT讲座,编纂此科普系列,计五篇,一篇没有代码和数学公式,是最通俗的科普。其他四篇包括一篇英文,均附带可验证的Python代码,并给予不同角度的详细解说,面对有一定工程背景的对象。

你可能已经听说过GPT(Generative Pre-trained Transformer)的鼎鼎大名,无论是能与你流畅对话的ChatGPT,还是能帮你写代码、写诗歌的AI助手,它们背后都有GPT的强大身影。但是,这个神奇的“黑箱”究竟是如何运作的呢?

今天,我们就以一个“迷你版”的莎士比亚风格文本生成器为例,一步步拆解GPT的构造,让你从零开始,彻底搞懂它的核心原理。别担心,我们会用最通俗易懂的语言,结合具体的代码示例,让你看清这背后的“魔法”。

核心思想:预测下一个“词”(词元或字符)

GPT最核心的任务,说白了就是预测序列中的下一个元素。对于文本来说,就是预测下一个单词或下一个字符。我们给它一段话,它会猜接下来最可能出现什么。

在我们的莎士比亚生成器中,模型学习的就是预测莎士比亚剧本中的下一个字符是什么。比如,看到 "To be or not to b",它应该能预测出下一个字符是 "e"。

# 训练数据中,y 就是 x 的下一个字符序列
# input x: "To be or not to b"
# output y: "o be or not to be"
# 比如 train_data[i:i+block_size] 是输入 x
# train_data[i+1:i+block_size+1] 就是目标 y

第一步:让计算机“认识”文字 - 数据与词汇表

计算机不认识人类的文字,它们只懂数字。所以,第一步就是把文字转换成计算机能理解的格式。

  1. 准备“教材”(输入数据):
    我们首先需要大量的文本数据作为模型的“教材”。在这个例子中,就是莎士比亚的剧作 (input.txt)。这些数据会被预处理并保存为二进制格式 (train.bin) 以便高效加载。
  2. 构建“字典”(词汇表与编码):
    我们需要一个包含所有可能出现的字符的“字典”(词汇表)。对于莎士比亚的文本,这个词汇表可能包含英文字母、数字、标点符号等。
  3. # data/shakespeare_char/input.txt 包含了所有莎士比亚文本

    chars = sorted(list(set(open(os.path.join(data_dir, 'input.txt'), 'r').read())))

    stoi = {ch: i for i, ch in enumerate(chars)} # 字符到索引的映射 (string to integer)

    itos = {i: ch for i, ch in enumerate(chars)} # 索引到字符的映射 (integer to string)

    vocab_size = len(chars) # 词汇表大小,比如65个唯一字符

    ```stoi` (string to integer) 将每个字符映射到一个唯一的数字索引(比如 'a' -> 0, 'b' -> 1)。`itos` (integer to string) 则反过来。

    # 这样,我们就可以用 `encode` 函数将一串字符转换成数字列表,用 `decode` 函数再转换回来。

    ```

    def encode(s): # "hello" -> [40, 37, 44, 44, 47] (假设的映射)

        return [stoi[c] for c in s]

    def decode(l): # [40, 37, 44, 44, 47] -> "hello"

        return ''.join([itos[i] for i in l])

    # 加载训练数据时,train.bin 文件中的内容已经是被 encode 过的数字序列了。

    train_data = torch.frombuffer(

        open(os.path.join(data_dir, 'train.bin'), 'rb').read(),

        dtype=torch.uint16 # 每个数字用16位无符号整数表示

    ).long() # 转换为PyTorch常用的长整型

第二步:赋予字符“意义” - 嵌入层 (Embedding)

虽然我们把字符变成了数字,但这些数字本身并没有“意义”。比如,数字5和数字10之间并没有“更像”或“更不像”的关系。我们需要一种方式来表示字符的含义及其在序列中的位置。这就是嵌入(Embedding)的作用。意义的本质体现在系统关系之中,正如马克思提到人的意义时所说:人是社会关系的总和。数字化实现就是建立一个高维向量的意义空间,用来定义每个词元相对于其他词元的位置,关系则以距离来表示。

  1. 字符嵌入 (Token Embedding):
    我们为词汇表中的每个字符学习一个固定长度的向量(一串数字),这个向量就代表了这个字符的“意义”或“特征”。想象一下,在一个高维空间中,意思相近的字符它们的向量也可能更接近。
    # n_embd 是嵌入向量的维度,比如128

    self.embedding = nn.Embedding(vocab_size, n_embd)

    # 输入一个字符索引,输出一个128维的向量
    例如,字符 'a' (索引可能是0) 会被映射成一个128维的向量 [0.1, -0.2, ..., 0.5]。
  2. 位置嵌入 (Positional Embedding):
    在语言中,顺序会影响意义。“国王杀了王后”和“王后杀了国王”意思完全不同。因此,我们还需要告诉模型每个字符在句子中的位置。位置嵌入就是为每个位置(比如第0个字符,第1个字符……)学习一个向量。
    # 假设句子最长不超过1000个字符

    self.pos_embedding = nn.Embedding(1000, n_embd)

    # 输入一个位置索引,输出一个128维的向量。
    # 最终,一个字符在特定位置的表示,是它的字符嵌入向量和它所在位置的嵌入向量相加得到的。
    # x 是输入的字符索引序列,形状为 (批量大小, 序列长度)
    # pos 是位置索引序列,形状为 (1, 序列长度)
    # 结果 x_embedded 的形状是 (批量大小, 序列长度, 嵌入维度)

    x_embedded = self.embedding(x) + self.pos_embedding(pos)

第三步:神奇的“思考机器” - Transformer 

这是GPT的核心部件,负责理解上下文信息并进行“思考”。我们的莎士比亚生成器用的是Transformer的解码器层 (Decoder Layer)

一个Transformer解码器层主要包含以下几个部分:

  1. 因果掩码 (Causal Mask):

    在预测下一个字符时,模型只能看到它前面的字符,不能“偷看”答案。因果掩码就像给模型戴上了“眼罩”,确保它在预测第 t 个字符时,只使用第 0 到 t-1 个字符的信息。
    # t 是序列长度

    # mask 是一个上三角矩阵,对角线以上为True (masked)

    # [[False,  True,  True,  True],
    #  [False, False,  True,  True],
    #  [False, False, False,  True],
    #  [False, False, False, False]]

    mask = torch.triu(torch.ones(t, t), diagonal=1).bool()
  2. 计算注意力权重的过程

    在自注意力层,每个token的Query矩阵与上下文窗口中所有tokens的Key 矩阵转置相乘,这样就得到了该token对所有tokens的注意力权重(如果掩码,则与下文的tokens权重全部置零)。对于一个包含 B 个序列、每个序列 T 个 token 的批次输入,Query 矩阵形状是 B * T * head_size,Key 矩阵转置后是 B * head_size * T。两者相乘得到一个形状为 B * T * T 的权重矩阵。这个 B * T * T 的矩阵,对于批次中的每一个序列(B 维度),都有一个 T * T 的子矩阵,其中的每一个元素 (i, j) 代表位置 i 的 Query 与位置 j 的 Key 的点积结果,也就是token-i 关注token-j 的原始“亲和力”或“相谐度”。

    上述描述解释了计算注意力分数的核心数学操作:
    Query 矩阵与 Key 矩阵的转置相乘 (Q @ K.transpose(-2, -1)),我们来拆解一下:
     
    假设你有一个序列,长度为 T。对于这个序列中的每一个 token,我们都计算得到一个 Query 向量和一个 Key 向量。假设每个 Q 和 K 向量的维度是 head_size (记为 D)对于整个序列,我们可以把所有 token 的 Query 向量堆叠起来形成一个 Query 矩阵,形状是 (T * D)。同样,所有 Key 向量堆叠形成一个 Key 矩阵,形状也是 (T * D)。
     
    我们想要计算的是:序列中每一个位置 i 的 Query 向量 (Q_i) 与序列中每一个位置 j 的 Key 向量 (K_j) 之间的点积。这个点积 (Q_i . K_j) 就是位置 i 对位置 j 的“注意力分数”或“亲和力”
     
    如果你熟悉矩阵乘法,矩阵 A 乘以矩阵 B 的结果矩阵 C,其元素 C_ij 是 A 的第 i 行与 B 的第 j 列的点积我们想让结果矩阵 C 的元素 C_ij 等于 Q 矩阵的第 i 行 (Q_i) 与 K 矩阵的第 j 行 (K_j) 的点积。要做到这一点,我们需要 Q 矩阵乘以 K 矩阵的转置 (K^T)。
     
    如果 Q 是 (T * D),K 是 (T * D),那么 K 的转置 K^T 就是 (D x T)。进行矩阵乘法
    Q @ K^T: (T * D) @ (D * T) = (T * T)。结果矩阵 (T * T) 的元素在第 i 行、第 j 列的值,正是 Q 矩阵的第 i 行 (Q_i) 与 K^T 矩阵的第 j 列的点积。由于 K^T 的第 j 列就是 K 矩阵的第 j 行 (K_j) 沿列方向排列,这个点积正是我们所要的 Q_i . K_j
     
    考虑批次 (Batch):  当处理多个序列(一个批次)时,PyTorch 中的张量会增加一个批次维度 B。所以 Query 矩阵形状是 (B * T * D),Key 矩阵形状是 (B * T * D)。为了对批次中的每一个序列独立进行上述 (T * D) @ (D * T) 的矩阵乘法,我们需要将 Key 矩阵进行转置,使其形状变为 (B * D * T)。 PyTorch 的批次矩阵乘法 (@torch.bmm) 可以处理这种形状的乘法:(B * T * D) @ (B * D * T) = (B * T * T)
     
    转置的维度: 转置倒数两个维度 (transpose(-2, -1)),这是因为 PyTorch 中批次张量的维度通常是 (Batch, Time, Feature)。Query 和 Key 的形状是 (B, T, head_size)。要得到 (B, head_size, T),我们需要交换 Time (维度 -2) 和 head_size (维度 -1) 这两个维度
     
    所以,转置 Key 矩阵是为了通过标准的矩阵乘法操作,高效地并行计算序列中每一个 Query 向量与每一个 Key 向量之间的点积,从而得到一个表示所有位置之间的 T * T 注意力分数矩阵 (对于每个批次中的序列而言)。

  3. 多头自注意力机制 (Multi-Head Self-Attention):

    这是Transformer的精髓!“自注意力”机制允许模型在处理一个字符时,去关注输入序列中所有其他字符,并判断哪些字符对当前字符的理解最重要。想象一下你在阅读 "The cat sat on the mat." 当你读到 "mat" 时,注意力机制可能会告诉你 "cat" 和 "sat on" 对理解 "mat" 的上下文很重要。

    “多头”则意味着模型可以从多个不同的“角度”或“子空间”去关注信息,捕捉更丰富的关系。比如一个头可能关注语法关系,另一个头可能关注语义关系。
    在解码器中,由于因果掩码的存在,注意力机制只会关注当前位置之前的字符。

    QKV 的分工(Query 用于寻找、Key 用于匹配、Value 用于承载信息)怎么实现的?

    Q, K, V 的分工
    是在自注意力机制的计算公式和结构中实现的。这个结构是固定的:计算 Query 和 Key 的点积得到注意力分数,然后用这些分数加权 Value 向量。这个数学操作本身定义了它们的角色。
     
    如何自然得到分工? 它们具体的“能力”(例如,某个 Query 如何有效地找到相关的 Key,某个 Key 如何有效地表明自身的内容,某个 Value 如何有效地编码有用的信息)是在训练过程中自然学习到的。模型的参数,包括 Q, K, V 线性投影层的权重,会通过反向传播和优化器进行调整,以最小化预测下一个 token 的损失。在这个过程中,这些投影层会学习到权值,使得输入表示 (X) 被投影到能够有效支持注意力计算以提高预测准确性的 Q, K, V 向量空间
     
    这些投影层的权重是在训练开始时初始化的,并且在训练过程中为所有 token 共享(即同一个线性层应用于所有 token 的 X 向量)。所以,不是每个 token 自身有一个固定的初始 Q, K, V 向量,而是每个 token 的初始表示 (X) 通过共享的、已初始化的线性层被投影成 Q, K, V。


  4. 前馈神经网络 (Feed-Forward Network):

    在注意力机制处理完信息后,每个位置的输出会再经过一个简单的前馈神经网络进行进一步的非线性变换,增强模型的表达能力。
    # d_model 是嵌入维度 (n_embd)
    # nhead 是注意力头的数量
    # dim_feedforward 通常是 d_model 的4倍

    nn.TransformerDecoderLayer(
      d_model=n_embd,
      nhead=n_head,
      dim_feedforward=n_embd * 4,
      batch_first=True, # 输入数据的维度顺序是 (批量, 序列, 特征)
      dropout=0.1      # 防止过拟合
    )
  5. 残差连接 (Residual Connections) 和层归一化 (Layer Normalization):

    这些是帮助深度神经网络更好训练的技巧。残差连接允许信息直接“跳过”某些层,避免梯度消失;层归一化则将每层的数据分布稳定在一定范围内,加速训练。

在我们的SimpleGPT模型中,我们堆叠了多个这样的Transformer解码器层 (n_layer个)。信息会逐层传递并被更深入地处理。

self.transformer = nn.ModuleList([
    nn.TransformerDecoderLayer(...) for _ in range(n_layer)
])

# 在前向传播中:
for transformer_layer in self.transformer:
    x = transformer_layer(x, x, tgt_mask=mask) # 注意这里 query, key, value 都是 x
 
 
Transformer 每一个组块的具体计算流程(基于nn.TransformerDecoderLayer 的结构)如下:
 
输入: 每个块的输入是前一个块的输出表示向量(对于第一个块,输入是 token embedding 和 positional embedding 的叠加)。我们称之为 X_input
 
自注意力层: X_input 首先进入自注意力层。在这里,X_input 被投影为 Q, K, V 向量。通过 Q 与 K 的点积、因果掩码、Softmax 和与 V 的乘法(加权求和),自注意力机制输出了一个向量。这个输出向量融合了该 token 自身以及其之前所有 token 的 Value 信息,权重取决于 Query-Key 的相似度
 
自注意力层的输出会加回到原始输入 X_input 上(残差连接),然后进行层归一化。这一步的结果是一个新的表示,我们称之为 X_attn_out这个 X_attn_out 就是经过上下文信息聚合(通过自注意力)后,该 token 位置的表示。
 
X_attn_out 接着进入前馈网络 (FFN)。FFN 是一个简单的、独立作用于每个 token 位置的多层感知机。它允许模型在聚合了上下文信息后,对这些信息进行进一步的、独立的非线性处理和特征转换
 
FFN 的输出会加回到 X_attn_out 上(残差连接),然后再次进行层归一化。这一步的结果就是该 token 位置经过当前 Transformer 块处理后的最终输出表示。这个输出表示会成为下一个 Transformer 块的输入
 
总结来说,token 的表示更新是通过一个层叠的处理管道实现的:输入表示 -> 自注意力层(QKV 投影、点积、掩码、Softmax、加权 Value 聚合)-> 残差连接 + 层归一化 -> 前馈网络 -> 残差连接 + 层归一化 -> 输出表示。每一个块都对 token 的表示进行这样的转换,使其逐步吸收更多上下文信息并进行更复杂的特征提取。

第四步:做出最终预测 - 输出层

经过多层Transformer的“深思熟虑”后,模型对每个输入位置都得到了一个丰富的上下文表示(一个n_embd维的向量)。现在,我们需要将这个表示转换成对下一个字符的预测。

  1. 最后的层归一化:
    x = self.ln_f(x) # self.ln_f = nn.LayerNorm(n_embd)

  2. 线性层 (Linear Layer) / 头部 (Head):
    一个线性层会将Transformer输出的n_embd维向量映射回词汇表大小(vocab_size)的维度。这个输出的每个维度对应词汇表中的一个字符,其值(称为logits)可以看作是模型认为该字符是下一个字符的“原始分数”或“置信度”。
    # self.head = nn.Linear(n_embd, vocab_size)

    logits = self.head(x)

    # logits 的形状是 (批量大小, 序列长度, 词汇表大小)

    例如,对于输入序列的最后一个字符位置,logits中与字符'a'对应的分数可能是2.5,与'b'对应的分数是-0.1,等等。分数越高的字符,模型认为它越有可能是下一个。

第五步:从错误中学习 - 训练模型

模型一开始是“随机”的,它需要通过学习大量的例子来提升预测能力。

  1. 准备输入和目标:
    我们从训练数据中随机抽取一批序列(x)以及它们对应的正确下一个字符序列(y)。
    block_size = 32 # 模型一次处理的序列长度
    # ix: 随机选择8个起始位置
    ix = torch.randint(len(train_data) - block_size, (8,))

    # x: 8个长度为32的输入序列
    x = torch.stack([train_data[i:i+block_size] for i in ix])

    # y: 对应的8个目标序列 (x中每个字符的下一个字符)
    y = torch.stack([train_data[i+1:i+block_size+1] for i in ix])
  2. 计算损失 (Loss):
    模型根据输入 x 得到预测的 logits。我们需要一个方法来衡量这个预测与真实目标 y 之间的差距。这就是损失函数 (Loss Function),常用的是交叉熵损失 (Cross-Entropy Loss)。损失越小,说明模型预测得越准。
    logits = model(x) # 通过模型得到预测

    # logits.view(-1, len(chars)) 将形状变为 (批量*序列长度, 词汇表大小)
    # y.view(-1) 将形状变为 (批量*序列长度)

    loss = nn.functional.cross_entropy(logits.view(-1, vocab_size), y.view(-1))
  3. 优化参数 (Optimization):
    我们的目标是最小化损失。优化器 (Optimizer)(如Adam)会根据损失值,通过反向传播 (Backpropagation) 算法计算出模型中每个参数(权重和偏置)应该如何调整,才能让损失变小一点。
    optimizer = torch.optim.Adam(model.parameters(), lr=3e-4) # lr是学习率
    optimizer.zero_grad() # 清除上一轮的梯度
    loss.backward()       # 计算梯度
    optimizer.step()        # 更新参数
    这个过程会重复很多次(很多step),模型逐渐学会莎士比亚的语言模式。

第六步:生成莎士比亚风格文本 - 推理 (Inference)

当模型训练到一定程度后,我们就可以用它来生成新的文本了。

    • 起始提示 (Prompt):

      我们可以给模型一个起始的文本片段(prompt),比如 "HAMLET: To be or not to be"。如果没给,就从一个默认字符开始。
      tokens = encode(prompt) # 将提示词编码成数字序列
    • 迭代生成:

      模型会根据当前的 tokens 序列(只取最后 block_size 个作为上下文),预测下一个最可能的字符。
      context = torch.tensor([tokens[-block_size:]])
      logits = model(context)[0, -1, :] # 取最后一个时间步的logits
      与训练不同,这里的 [0, -1, :] 表示我们只关心这个批次中(虽然推理时批次大小通常是1)最后一个字符位置的预测,因为我们要预测的是 下一个 字符。
  • 控制生成的多样性:

    直接选择概率最高的字符可能会让生成的文本很单调。我们用一些技巧来增加多样性:
      • Temperature (温度):
        logits = logits / temperature
        温度较低(<1)时,概率分布更“尖锐”,模型倾向于选择高概率字符,生成结果更保守、更像训练数据。
        温度较高(>1)时,概率分布更“平滑”,模型可能选择一些低概率字符,生成结果更有创意,但也可能更混乱。
      • Top-K 采样:
        只从概率最高的 k 个字符中进行采样。这可以避免选到非常不靠谱的字符。
        if top_k > 0:

          # 找到第k大的logit值
            kth_value = torch.topk(logits, top_k)[0][..., -1, None]

          # 将所有小于该值的logit设为负无穷 (采样概率为0)
            indices_to_remove = logits < kth_value

            logits[indices_to_remove] = float('-inf')
      • 让我们解说代码:
    kth_value = torch.topk(logits, top_k)[0][..., -1, None]

torch.topk(logits, top_k):  这个函数会从logits中找出分数最高的top_k个值,并且返回它们的值和它们在原始logits中的位置(索引)。它返回的是一个元组(values, indices)values: 包含了这top_k个最高的分数,默认是降序排列的(从高到低)。indices: 包含了这些最高分数对应的原始位置。 例如,如果logits如上例,top_k = 3,那么torch.topk(logits, 3),可能返回:values = torch.tensor([3.0, 2.5, 1.5])(最高的3个分数),indices = torch.tensor([3, 1, ...]) (这3个分数在原logits中的位置)。[0]: 因为torch.topk返回的是(values, indices)这个元组,我们只关心分数本身,所以用[0]来取出values部分。 现在,我们得到的是values这个张量,即torch.tensor([3.0, 2.5, 1.5])[..., -1, None]:

        • 采样与解码:

          根据调整后的 logits 计算概率分布 (torch.softmax),然后从这个分布中随机采样一个字符作为下一个字符,torch.multinomial(probs, 1) 中的 1 就表示我们只进行一次这样的抽取。将采样到的字符(数字形式)添加到 tokens 序列中。
          probs = torch.softmax(logits, dim=-1)
          next_token = torch.multinomial(probs, 1).item()
          tokens.append(next_token)
          重复这个过程,直到达到最大长度 (max_tokens) 或生成了特定的结束标记(比如换行符)。最后,用 decode 函数将整个 tokens 数字序列转换回人类可读的文本。

    我们的莎士比亚GPT在行动

    脚本中通过调整 temperature 和 top_k 参数,展示了不同风格的生成结果:

        • 保守生成: temperature=0.5, top_k=10 -> 更接近原文,但可能缺乏新意。
        • 平衡生成: temperature=0.8, top_k=20 -> 在忠实和创意间取得平衡。
        • 创意生成: temperature=1.2, top_k=30 -> 可能产生惊喜,也可能不那么连贯。

    由于我们的模型只训练了非常少的步数(50步),生成的质量不会很高,但足以让你看到它学习语言模式的过程。

    从迷你GPT到巨型GPT

    这个莎士比亚生成器是一个非常简化的字符级GPT。现实中的大型语言模型(如ChatGPT)与它的核心原理是相似的,但在以下方面有差异:

        • 模型规模: 参数量可能达到千亿甚至万亿级别(我们的例子只有几十万参数)。
        • 数据量: 训练数据是TB级别的海量文本和代码,远不止莎士比亚全集。
        • Tokenization: 通常使用更高级的词元化方法(如BPE或WordPiece),处理的是词或子词(subword),而不是单个字符,能更好地捕捉语义。
        • 训练技巧: 使用了更复杂的训练策略、更长的训练时间以及巨量的计算资源。
        • 架构细节: 可能包含更精巧的架构调整。
        • 对齐技术: 通过指令微调 (Instruction Fine-tuning) 和人类反馈强化学习 (RLHF) 等技术,使模型输出更符合人类期望、更有用、更无害。

    结语

    通过解剖这个小小的莎士比亚生成器,我们窥见了GPT内部运作的冰山一角。从简单的字符预测任务出发,通过嵌入、强大的Transformer层、巧妙的训练和生成策略,GPT能够学习并模仿复杂的语言模式。

    希望这篇科普能帮你揭开GPT的神秘面纱,理解它并非遥不可及的魔法,而是一系列精妙算法和海量数据共同作用的产物。下一次当你与AI对话时,或许就能想到它背后那些默默计算着的数字和向量了!

    GPT科普系列

发布者

立委

立委博士,多模态大模型应用高级咨询。出门问问大模型团队前工程副总裁,聚焦大模型及其AIGC应用。Netbase前首席科学家10年,期间指挥研发了18种语言的理解和应用系统,鲁棒、线速,scale up to 社会媒体大数据,语义落地到舆情挖掘产品,成为美国NLP工业落地的领跑者。Cymfony前研发副总八年,曾荣获第一届问答系统第一名(TREC-8 QA Track),并赢得17个小企业创新研究的信息抽取项目(PI for 17 SBIRs)。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理