李维
从0实现并理解GPT
2025-6-4 00:43
阅读:390

立委按:鉴于语言大模型GPT的重要性,特此根据AI大神Karpathy的nanoGPT讲座,编纂此科普系列。

你可能已经听说过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)最后一个字符位置的预测,因为我们要预测的是 下一个 字符。

  • 控制生成的多样性:

    直接选择概率最高的字符可能会让生成的文本很单调。我们用一些技巧来增加多样性:

  • kth_value = torch.topk(logits, top_k)[0][..., -1, None]

    • 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')

    • 让我们解说代码:

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]:

我们的莎士比亚GPT在行动

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

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

从迷你GPT到巨型GPT

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

结语

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

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

    • 模型规模: 参数量可能达到千亿甚至万亿级别(我们的例子只有几十万参数)。

    • 数据量: 训练数据是TB级别的海量文本和代码,远不止莎士比亚全集。

    • Tokenization: 通常使用更高级的词元化方法(如BPE或WordPiece),处理的是词或子词(subword),而不是单个字符,能更好地捕捉语义。

    • 训练技巧: 使用了更复杂的训练策略、更长的训练时间以及巨量的计算资源。

    • 架构细节: 可能包含更精巧的架构调整。

    • 对齐技术: 通过指令微调 (Instruction Fine-tuning) 和人类反馈强化学习 (RLHF) 等技术,使模型输出更符合人类期望、更有用、更无害。

    • 保守生成: temperature=0.5, top_k=10 -> 更接近原文,但可能缺乏新意。

    • 平衡生成: temperature=0.8, top_k=20 -> 在忠实和创意间取得平衡。

    • 创意生成: temperature=1.2, top_k=30 -> 可能产生惊喜,也可能不那么连贯。

      采样与解码:

      根据调整后的 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 数字序列转换回人类可读的文本。

下面这篇科普文章,以Karpathy讲座“从零实现莎士比亚风 GPT”为例,把每一行代码背后的思路拆透,感受“技术硬核”的魅力。

一、引子:为什么要自己写一个“小 GPT”?

  • 脚踏实地:商用 GPT 模型动辄上百亿参数,看不清内部;自己写一个小模型,才有机会把每个细节掰开了啃。

  • 入门示范:字符级模型更轻量,50 步训练就能出点儿“莎士比亚味”,足以演示 Transformer 的核心套路。

  • 学习曲线:从准备数据、编码、搭网络、训练到采样生成,完整跑一趟,就能圆满理解 GPT 的「流水线」。

二、数据篇:把“文字”编码成“数字”

  1. 原始文本把莎士比亚全集放到 data/shakespeare_char/input.txt,整个文本可能上百万字符。

  2. 字符表(Vocabulary)pythonchars = sorted(list(set(open(...).read())))stoi = {ch:i for i,ch in enumerate(chars)}itos = {i:ch for i,ch in enumerate(chars)}

  3. 代码解释:

    • set (字符集合)自然去重后有 65 个字符,包括字母、标点、换行符等。

    • stoi、itos 分别是一一映射,可做「文字 ↔ 索引」互转,即编码解码。

  4. 高效加载训练前把所有文本编码成 uint16 二进制文件 train.bin,运行时直接:train_data = torch.frombuffer(open(…,'rb').read(), dtype=torch.uint16).long()一次性读入张量,快又省事。

三、模型篇:一个“小型 GPT”长啥样class SimpleGPT(nn.Module):     def __init__(self, vocab_size, n_embd=128, n_head=4, n_layer=3):         super().__init__()         # 1. Token 嵌入 & 位置嵌入         self.embedding     = nn.Embedding(vocab_size, n_embd)         self.pos_embedding = nn.Embedding(1000, n_embd)         # 2. N 层 TransformerDecoderLayer         self.transformer = nn.ModuleList([             nn.TransformerDecoderLayer(                 d_model=n_embd, nhead=n_head,                 dim_feedforward=n_embd*4,                 batch_first=True, dropout=0.1             ) for _ in range(n_layer)         ])         # 3. 归一化 + 线性头         self.ln_f = nn.LayerNorm(n_embd)         self.head = nn.Linear(n_embd, vocab_size)

  • Embedding

    • Token 嵌入:把每个字符索引映射成 128 维向量;

    • 位置嵌入:告诉模型「这个字符是句子中的第几位」。

  • TransformerDecoderLayer

    • 多头自注意力(Multi-Head Self-Attention):在无掩码时,让每个位置都能「看」到其他位置,用不同的“视角”捕捉语义关联;

    • 前馈网络 FFN:FFN内部是两层全连接,扩大特征维度后再压回增强非线性表达:第一层linear1把维度从 n_embd(128)→ dim_feedforward(512);Relu激活;第二层linear2把维度再从 dim_feedforward(512)→ n_embd(128));

    • 残差连接 + LayerNorm + Dropout:保证信息流通、稳定训练、防止过拟合。

    • 两层全连接(Feed-Forward Network, FFN)和残差连接(Residual Connection)部分被 PyTorch 的 nn.TransformerDecoderLayer 给封装起来了。

    • 每个 block 里总共是两层前馈线性变换(加一个激活);*4 把隐藏层的宽度调成原来的 4 倍(128*4=512)。 n_head=4 是指 注意力头 数量是 4 个。

  • 输出头最后把每个位置的 128 维特征 → 65 个字符的分数(logits),为下一步采样做准备。

每个 TransformerBlock(TransformerDecoderLayer)内部:

# 注意力子层(Self-Attention) _attn_output = self.self_attn(x, x, x, attn_mask=tgt_mask) x = x + self.dropout1(_attn_output)    # 残差 + Dropout x = self.norm1(x)                      # LayerNorm # 前馈全连接子层(Feed-Forward) _ffn_output = self.linear2(self.dropout(self.activation(self.linear1(x)))) x = x + self.dropout2(_ffn_output)     # 残差 + Dropout x = self.norm2(x)                      # LayerNorm残差连接(Residual Connection)在哪里?

同样,TransformerDecoderLayer 在每个子层的输出上都做了:

x = x + SubLayer(x)

也就是将子层(注意力/前馈)的输出与原输入相加,然后再做 LayerNorm。这能让梯度更容易向前/向后流动,避免深层网络训练困难。

为什么常常用这样的封装?

  • 代码简洁:把注意力、FFN、残差、归一化、Dropout——所有常见操作都打包好,调用一行就能用。

  • 可配置:你可以在构造时传参数,比如 activation='gelu'、norm_first=True(预归一化)等。

想要完全掌握内部细节,你可以自己写一个自定义的 DecoderLayer,大概长这样:

class MyDecoderLayer(nn.Module):     def __init__(self, d_model, nhead, dim_ff):         super().__init__()         self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)         self.linear1   = nn.Linear(d_model, dim_ff)         self.linear2   = nn.Linear(dim_ff, d_model)         self.norm1     = nn.LayerNorm(d_model)         self.norm2     = nn.LayerNorm(d_model)         self.dropout   = nn.Dropout(0.1)         self.act       = nn.ReLU()     def forward(self, x, mask=None):         # 注意力 + 残差 + 归一化         attn_out, _ = self.self_attn(x, x, x, attn_mask=mask)         x = x + self.dropout(attn_out)         x = self.norm1(x)         # FFN + 残差 + 归一化         ffn_out = self.linear2(self.dropout(self.act(self.linear1(x))))         x = x + self.dropout(ffn_out)         x = self.norm2(x)         return x

把 N 层这样的 MyDecoderLayer 串起来,就和 nn.TransformerDecoderLayer 是一模一样的套路。

这样你就明确地知道:GPT 的每一层都是「注意力→残差→LayerNorm→前馈→残差→LayerNorm」的循环组合。希望这下彻底搞明白了!

四、前向传播:一步步把输入变预测def forward(self, x):     b, t = x.shape                  # batch 大小 b,序列长度 t     pos  = torch.arange(t).unsqueeze(0)     x = self.embedding(x) + self.pos_embedding(pos)     mask = torch.triu(torch.ones(t, t), diagonal=1).bool()     for layer in self.transformer:         x = layer(x, x, tgt_mask=mask)     x = self.ln_f(x)     return self.head(x)             # (b, t, vocab_size)

  1. 拼接嵌入每个字符向量 + 对应位置向量,融合语义与顺序信息。

  2. 因果掩码用上三角布尔矩阵屏蔽未来位置,确保模型只能用“历史”信息预测“下一步”。

  3. 层叠计算N 层解码器层交替执行「注意力→前馈→残差→归一化」,不断提炼上下文特征。

  4. 输出 logits每个位置都对应一个 vocab_size 维的分数向量,代表模型对下一个字符的「喜好程度」。

五、训练篇:教模型学“接龙文字”optimizer = torch.optim.Adam(model.parameters(), lr=3e-4) model.train() for step in range(50):     # 1. 随机抓 8 段长度为 block_size 的序列     ix = torch.randint(len(train_data)-block_size, (8,))     x  = torch.stack([train_data[i:i+block_size]       for i in ix])     y  = torch.stack([train_data[i+1:i+block_size+1]   for i in ix])     # 2. 前向 + 损失     logits = model(x)     loss   = nn.functional.cross_entropy(                 logits.view(-1, vocab_size), y.view(-1)              )     # 3. 反向 + 更新     optimizer.zero_grad()     loss.backward()     optimizer.step()     if step%10==0: print(f"Step {step}: loss={loss.item():.4f}")

  • 随机采样:每次从不同起点取小段,让模型见识多种上下文,避免只学到固定模式。

  • 交叉熵损失:衡量预测分布 vs. 真实下一个字符的差距。

  • Adam 优化器:智能调整各参数的学习率,加快收敛。

六、生成篇:让模型「写莎翁诗」def generate_text(prompt="", max_tokens=200, temperature=0.8, top_k=20):     model.eval()     tokens = encode(prompt) if prompt else [encode("ROMEO:")[0]]     with torch.no_grad():         for _ in range(max_tokens):             ctx    = torch.tensor([tokens[-block_size:]])             logits = model(ctx)[0, -1]       # 取最新位置的 logits             logits = logits / temperature    # 温度调节             if top_k>0:                 kth, _ = torch.topk(logits, top_k)                 logits[logits < kth[-1]] = -float('inf')             probs = torch.softmax(logits, dim=-1)             nxt   = torch.multinomial(probs, 1).item()             tokens.append(nxt)             if nxt==encode('\n')[0] and len(tokens)>10: break     return decode(tokens)

  • 温度(Temperature)

    • <1:分布更陡峭,生成更“保守”;

    • >1:分布更平坦,生成更“大胆”。

  • Top-k 采样只保留概率最高的 k 个候选,把其余置零,然后再做随机采样,平衡“连贯”与“创造”。

七、核心技术要点小结

技术环节功能与作用
嵌入层离散字符→连续向量,便于神经网络处理
位置编码注入顺序信息,让模型区分“先后”
自注意力动态计算序列各位置间的相互影响,捕捉长程依赖
因果掩码严格屏蔽「未来信息」,模拟人写作时只能一步步推进
前馈网络增加非线性表达能力
残差+LayerNorm保持梯度稳定,助力深层网络收敛
温度 & Top-k控制生成文本的“保守度”与“多样性”

八、结语

  • 小模型 ≠ 小原理:虽然参数量小,但骨架设计、数据流程、采样策略与大规模 GPT 完全一致。

  • 动手才真懂:自己从头跑一遍,不仅能看懂代码,更能体会每一层、每一个技巧为何如此设计。

  • 一路上升:掌握这些基础,你就拥有了阅读和改造任何 Transformer-based 模型的「通行证」。

下次想要扩充到单词级、加上多 GPU、混合精度训练,或者接入更大语料,就能顺理成章地在这些模块之上“造船”了。Go build your own GPT!

cat > shakespeare_generator.py << 'EOF'import torchimport torch.nn as nnimport pickleimport os

print("莎士比亚风格文本生成器")print("=" * 50)

加载数据和词汇表

data_dir = 'data/shakespeare_char'with open(os.path.join(data_dir, 'meta.pkl'), 'rb') as f:meta = pickle.load(f)

获取编解码函数

chars = sorted(list(set(open(os.path.join(data_dir, 'input.txt'), 'r').read())))stoi = {ch: i for i, ch in enumerate(chars)}itos = {i: ch for i, ch in enumerate(chars)}

print(f"词汇表大小: {len(chars)}")print(f"字符集: {''.join(chars[:20])}…")

def encode(s):return [stoi[c] for c in s]

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

加载训练数据

train_data = torch.frombuffer(open(os.path.join(data_dir, 'train.bin'), 'rb').read(),dtype=torch.uint16).long()

print(f"📖 训练数据长度: {len(train_data):,} tokens")

超简单的字符级语言模型

class SimpleGPT(nn.Module):def init(self, vocab_size, n_embd=128, n_head=4, n_layer=3):super().init()self.embedding = nn.Embedding(vocab_size, n_embd)self.pos_embedding = nn.Embedding(1000, n_embd)self.transformer = nn.ModuleList([nn.TransformerDecoderLayer(d_model=n_embd,nhead=n_head,dim_feedforward=n_embd * 4,batch_first=True,dropout=0.1) for _ in range(n_layer)])self.ln_f = nn.LayerNorm(n_embd)self.head = nn.Linear(n_embd, vocab_size)

def forward(self, x):     b, t = x.shape     pos = torch.arange(0, t, dtype=torch.long).unsqueeze(0)     x = self.embedding(x) + self.pos_embedding(pos)     # 创建因果mask     mask = torch.triu(torch.ones(t, t), diagonal=1).bool()     for transformer in self.transformer:         x = transformer(x, x, tgt_mask=mask)     x = self.ln_f(x)     logits = self.head(x)     return logits创建和训练模型

print("\n 创建模型…")model = SimpleGPT(vocab_size=len(chars))optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)

print(f"模型参数: {sum(p.numel() for p in model.parameters()):,}")

快速训练

print("\n 快速训练…")block_size = 32model.train()

for step in range(50): # 只训练50步,快速看效果ix = torch.randint(len(train_data) - block_size, (8,))x = torch.stack([train_data[i:i+block_size] for i in ix])y = torch.stack([train_data[i+1:i+block_size+1] for i in ix])

logits = model(x) loss = nn.functional.cross_entropy(logits.view(-1, len(chars)), y.view(-1)) optimizer.zero_grad() loss.backward() optimizer.step() if step % 10 == 0:     print(f"  Step {step:2d}: loss = {loss.item():.4f}")

print("\n 开始生成莎士比亚风格文本…")

def generate_text(prompt="", max_tokens=200, temperature=0.8, top_k=20):model.eval()

# 编码提示词 if prompt:     tokens = encode(prompt) else:     tokens = [encode("ROMEO:")[0]]  # 默认以ROMEO开始 with torch.no_grad():     for _ in range(max_tokens):         # 取最后block_size个tokens         context = torch.tensor([tokens[-block_size:]])         logits = model(context)[0, -1, :]         # 应用temperature         logits = logits / temperature         # Top-k采样         if top_k > 0:             indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]             logits[indices_to_remove] = float('-inf')         probs = torch.softmax(logits, dim=-1)         next_token = torch.multinomial(probs, 1).item()         tokens.append(next_token)         # 如果生成了换行符,可能是一个好的停止点         if len(tokens) > 10 and next_token == encode('\n')[0]:             break return decode(tokens)生成不同风格的文本

print("\n" + "="60) print(" 生成结果展示") print("="60)

print("\n1️⃣ 保守生成 (temperature=0.5, top_k=10):")torch.manual_seed(42)text1 = generate_text("HAMLET: To be or not to be", max_tokens=100, temperature=0.5, top_k=10)print(f"'{text1}'")

print("\n2️⃣ 平衡生成 (temperature=0.8, top_k=20):")torch.manual_seed(42)text2 = generate_text("JULIET: Romeo, Romeo", max_tokens=100, temperature=0.8, top_k=20)print(f"'{text2}'")

print("\n3️⃣ 创意生成 (temperature=1.2, top_k=30):")torch.manual_seed(42)text3 = generate_text("KING: What news", max_tokens=100, temperature=1.2, top_k=30)print(f"'{text3}'")

print("\n4️⃣ 无提示生成:")torch.manual_seed(123)text4 = generate_text("", max_tokens=150, temperature=0.8, top_k=25)print(f"'{text4}'")

print(f"\n 参数解释:")print(f"• temperature越低 → 更保守,更像训练数据")print(f"• temperature越高 → 更有创意,但可能更乱")print(f"• top_k越小 → 只从最可能的k个字符中选择")print(f"• 对于65字符的词汇表,top_k=5到30比较合理")

print(f"\n 注意: 由于只训练了50步,质量不会很高")print(f" 但您可以看到模型学习语言模式的过程!")EOF

运行莎士比亚生成器

python shakespeare_generator.py

下面逐行详细解释这个莎士比亚生成器脚本,好理解GPT的实现原理。

🔍 逐行代码解析:莎士比亚GPT生成器

1. 导入和基础设置

import torch import torch.nn as nn import pickle import os

详细解释

  • torch: PyTorch核心库,提供张量计算和神经网络功能

  • torch.nn: 神经网络模块,包含各种层(Linear, Embedding等)

  • pickle: Python序列化库,用于加载.pkl格式的数据文件

  • os: 操作系统接口,用于文件路径操作

2. 打印标题和设置

print(" 莎士比亚风格文本生成器") print("=" * 50)

解释:简单的用户界面,"=" * 50创建50个等号的分隔线

3. 数据加载部分

# 加载数据和词汇表 data_dir = 'data/shakespeare_char' with open(os.path.join(data_dir, 'meta.pkl'), 'rb') as f:     meta = pickle.load(f)

详细解释

  • data_dir: 设置数据目录路径

  • os.path.join(): 跨平台的路径连接,相当于 data_dir + '/' + 'meta.pkl'

  • with open(..., 'rb'): 以二进制读取模式打开文件,with确保文件自动关闭

  • pickle.load(f): 加载.pkl文件,读取之前保存的字符对应表

4. 字符编码设置

# 获取编解码函数 chars = sorted(list(set(open(os.path.join(data_dir, 'input.txt'), 'r').read()))) stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for i, ch in enumerate(chars)}

逐行解析

# 第1行:获取所有唯一字符 chars = sorted(list(set(open(...).read())))

  • open(...).read(): 读取整个莎士比亚文本文件

  • set(...): 创建集合,自动去除重复字符

  • list(...): 转换为列表

  • sorted(...): 按ASCII码排序,确保字符顺序固定

# 第2行:创建字符到索引的映射stoi(string2integer) stoi = {ch: i for i, ch in enumerate(chars)}

  • enumerate(chars): 产生 (索引, 字符) 对

  • 字典推导式创建映射:{'a': 0, 'b': 1, 'c': 2, ...}

# 第3行:创建索引到字符的映射itos(integer2string) itos = {i: ch for i, ch in enumerate(chars)}

  • 反向映射:{0: 'a', 1: 'b', 2: 'c', ...}

5. 编解码函数

def encode(s):     return [stoi[c] for c in s] def decode(l):     return ''.join([itos[i] for i in l])

详细解释

def encode(s):     return [stoi[c] for c in s]

  • 输入:字符串 "hello"

  • 过程:['h', 'e', 'l', 'l', 'o'] → [104, 101, 108, 108, 111]

  • 输出:数字列表

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

  • 输入:数字列表 [104, 101, 108, 108, 111]

  • 过程:[104, 101, 108, 108, 111] → ['h', 'e', 'l', 'l', 'o']

  • 输出:字符串 "hello"

6. 加载训练数据

train_data = torch.frombuffer(     open(os.path.join(data_dir, 'train.bin'), 'rb').read(),      dtype=torch.uint16 ).long()

逐步解析

  1. open(..., 'rb').read(): 以'rb'(read-binary)二进制模式读取train.bin文件,得到的是二进制原始字节

  2. torch.frombuffer(..., dtype=torch.uint16): 将二进制数据转换为16位无符号整数张量,uint16 = 16位无符号整数 = 0到65535的数字

  3. .long(): 转换为长整型张量(64位),long() = 64位长整数,训练时常用

为什么这样做?

  • train.bin是预处理好的数字化文本数据

  • 每个字符已经被转换为对应的索引数字

  • 直接加载比重新编码要快得多

  • train.bin文件 → 读出字节 → 变成数字列表 → 转换成PyTorch能用的格式

7. GPT模型定义

class SimpleGPT(nn.Module):     def __init__(self, vocab_size, n_embd=128, n_head=4, n_layer=3):         super().__init__()

详细解释

  • nn.Module: PyTorch中所有神经网络模块的基类

  • super().__init__(): 调用父类构造函数

  • 参数:

    • vocab_size: 词汇表大小(65个字符)

    • n_embd=128: 嵌入维度(每个字符用128维向量表示)

    • n_head=4: 注意力头数量

    • n_layer=3: Transformer层数

嵌入层

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

详细解释

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

  • 创建一个查找表:vocab_size × n_embd 的矩阵

  • 每个字符索引对应一个128维向量

  • 例如:字符 'a' (索引0) → 128维向量 [0.1, -0.2, 0.3, ...]

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

  • 位置嵌入:告诉模型每个字符在序列中的位置

  • 支持最大1000个位置

  • 位置0 → 向量1,位置1 → 向量2,...

Transformer层

self.transformer = nn.ModuleList([     nn.TransformerDecoderLayer(         d_model=n_embd,         nhead=n_head,         dim_feedforward=n_embd * 4,         batch_first=True,         dropout=0.1     ) for _ in range(n_layer) ])

详细解释

  • nn.ModuleList: 存储多个神经网络层的容器

  • nn.TransformerDecoderLayer: PyTorch内置的Transformer解码器层

  • 参数详解:

    • d_model=n_embd: 输入维度(128)

    • nhead=n_head: 多头注意力的头数(4)

    • dim_feedforward=n_embd * 4: 前馈网络维度(512)

    • batch_first=True: 维度顺序以批次维度在前 (batch, seq, feature),先选句子,再选词元,数据排列像 [句子1][句子2][句子3]

    • 数据的三个维度batch = 同时处理几个句子;seq = 每个句子有多少个词元;feature = 每个词元用多少个数字表示(例如128个数字)

    • dropout=0.1: 10%的dropout防止过拟合

输出层

self.ln_f = nn.LayerNorm(n_embd) self.head = nn.Linear(n_embd, vocab_size)

详细解释

  • nn.LayerNorm(n_embd): 层归一化,数据清洗,稳定训练。数据'洗干净' - 平均值接近0,标准差接近1,避免数字太大或太小,给数据做标准化处理。

  • nn.Linear(n_embd, vocab_size): 线性层把特征变成字符概率,将128维特征映射到65个字符的概率

8. 前向传播函数

def forward(self, x):     b, t = x.shape     pos = torch.arange(0, t, dtype=torch.long).unsqueeze(0)

详细解释

  • 标量(0维),向量(1维),矩阵(2维),张量(n维向量)

  • x.shape: 输入张量的形状,例如 (batch_size=8, seq_len=32)

  • b, t = x.shape: 解包得到批次大小和序列长度

  • torch.arange(0, t): 创建位置索引 [0, 1, 2, ..., t-1]

  • .unsqueeze(0): 增加一个维度,变成 (1, t)

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

详细解释

  • self.embedding(x): 字符嵌入,形状 (b, t, n_embd)

  • self.pos_embedding(pos): 位置嵌入,形状 (1, t, n_embd)

  • 相加得到最终嵌入:字符信息 + 位置信息

# 创建因果mask mask = torch.triu(torch.ones(t, t), diagonal=1).bool()

详细解释

  • torch.ones(t, t): 创建全1的 t×t 矩阵

  • torch.triu(..., diagonal=1): 取上三角矩阵(对角线上方)

  • .bool(): 转换为布尔值

  • 作用:防止模型"偷看"未来的字符

举例:如果t=4,mask矩阵是:

[[False, True,  True,  True ],  [False, False, True,  True ],  [False, False, False, True ],  [False, False, False, False]]for transformer in self.transformer:     x = transformer(x, x, tgt_mask=mask)

详细解释

  • 循环通过每个Transformer层

  • transformer(x, x, tgt_mask=mask):

    • 第一个x: 查询(query)

    • 第二个x: 键值(key, value)

    • tgt_mask=mask: 应用因果掩码

x = self.ln_f(x) logits = self.head(x) return logits

详细解释

  • self.ln_f(x): 最终层归一化

  • logits = self.head(x): 线性变换,输出每个字符的未归一化概率(logits),表示模型对next token的"偏好程度",logits[0, 31, :] 就是第0个句子第31个位置对65个字符的评分等价于:logits = x @ W + b输入特征: [0.2, -0.1, 0.8, 0.3] (128维)权重矩阵: W (128×65)偏置向量: b (65维)输出logits: [2.1, -0.5, 1.3, ...] (65维)

  • 返回形状:(batch_size, seq_len, vocab_size)

  • head层就像一个"翻译器"输入:复杂的上下文特征表示(模型的"理解")输出:简单直观的字符选择评分(具体的"预测")作用:将模型的智慧转换为可操作的概率分布

  • head层是模型的"最后一公里",将前面所有层的计算结果汇总成最终的字符选择,决定了模型生成文本的质量和多样性

  • 关键:logits不是最终答案,而是为后续采样提供的概率性依据,通过softmax转换和采样策略,最终生成具体的字符。

  • 预测流程:输入: "hello wor" ↓嵌入层: 转换为向量序列 ↓Transformer: 处理上下文,每个位置得到特征向量 ↓最后位置(即next token)特征: [0.2, -0.1, 0.8, 0.3, ...] (128维) ↓head层: 线性变换 ↓logits: 对每个字符的评分 [2.1, -0.5, 1.3, ...] (65维) ↓softmax: 转换为概率分布 ↓采样: 选择下一个字符 "l"

9. 模型创建和训练

model = SimpleGPT(vocab_size=len(chars)) optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)

详细解释

  • SimpleGPT(vocab_size=len(chars)): 创建模型实例

  • torch.optim.Adam: Adam优化器,自适应学习率,根据梯度历史智能调整

  • model.parameters(): 获取所有可训练参数

  • lr=3e-4: 学习率 0.0003,默认经验值

block_size = 32 model.train()

详细解释

  • block_size = 32: 序列长度(训练窗口大小),模型一次处理32个字符

  • model.train(): 设置模型为训练模式(启用dropout等)

10. 训练循环

for step in range(50):     ix = torch.randint(len(train_data) - block_size, (8,))     x = torch.stack([train_data[i:i+block_size] for i in ix])     y = torch.stack([train_data[i+1:i+block_size+1] for i in ix])

逐行解析

ix = torch.randint(len(train_data) - block_size, (8,))

  • 随机选择8个起始位置,假设len(train_data) = 1000,block_size = 32:torch.randint(1000-32=968, (8,)), 这里,(8,) 是张量的 shape

  • 确保不超出数据边界,每个位置都在 [0, 967] 范围内

  • 假设在随机起始位置156:输入x = train_data[156:188] # 32个token目标y = train_data[157:189] # 下一个32个token

为什么需要随机采样? 优势: 避免顺序偏见:不总是从头开始训练 增加数据多样性:每个epoch看到不同的序列组合 提高泛化能力:模型学会处理各种上下文 加速收敛:随机性帮助跳出局部最优 对比:  ❌ 顺序采样起始位置: [0, 32, 64, 96, 128, 160, 192, 224] 总是相同的序列,缺乏多样性 ✅ 随机采样   起始位置,例如: [156, 743, 12, 891, 445, 623, 88, 334] # 每次都不同,增加训练多样性 x = torch.stack([train_data[i:i+block_size] for i in ix])

  • 从每个起始位置取32个字符作为输入

  • torch.stack: 将列表转换为张量

y = torch.stack([train_data[i+1:i+block_size+1] for i in ix])

  • 取下一个字符作为目标(预测目标)

  • 这是语言模型的核心:预测下一个字符

举例

  • 输入x: "To be or not to be, that is th"

  • 目标y: "o be or not to be, that is the"

logits = model(x) loss = nn.functional.cross_entropy(logits.view(-1, len(chars)), y.view(-1))

详细解释

  • model(x): 前向传播得到预测

  • view/reshape = 重新排列相同的数据

  • 为什么要reshape:交叉熵函数期望输入格式:

    • logits: (N, C) - N个样本,码本中的C个类别

  • logits.view(-1, len(chars)): 重塑为 (batch*seq, vocab_size),在形状参数中,-1 作为维度大小本来就无意义,PyTorch定义它为自动计算维度大小,相当于 auto

  • y.view(-1): 重塑为 (batch*seq,)

  • cross_entropy: 计算交叉熵损失

optimizer.zero_grad() loss.backward() optimizer.step()

详细解释

  • zero_grad(): 清零之前的梯度

  • backward(): 反向传播计算梯度

  • step(): 更新模型参数

11. 文本生成函数

def generate_text(prompt="", max_tokens=200, temperature=0.8, top_k=20):     model.eval()

详细解释

  • model.eval(): 设置为评估模式(关闭dropout)

if prompt:     tokens = encode(prompt) else:     tokens = [encode("ROMEO:")[0]]  # 只要'R',让模型自由发挥

详细解释

  • 如果有提示词,编码为数字列表作为上文(为了预测下一个token)

  • 否则用"ROMEO:"的第一个字符开始编码为上文,也可以不加[0]:则用"ROMEO:" 开始生成

with torch.no_grad():     for _ in range(max_tokens):         context = torch.tensor([tokens[-block_size:]])         logits = model(context)[0, -1, :]

详细解释

  • torch.no_grad(): 这是推理阶段不是训练阶段,禁用梯度计算(节省内存),只要结果,不存历史

  • tokens[-block_size:]: 取最后32个字符作为上下文

  • logits = model(context)[0, -1, :]:

    • [0, -1, :]: 取第一个批次的最后一个位置的所有词汇概率,为了 next token prediction 采样,next token 即最后一个位置。

# 应用temperature logits = logits / temperature

详细解释

  • temperature < 1: 让分布更尖锐,更保守

  • temperature > 1: 让分布更平坦,更随机

  • temperature = 1: 不改变分布

# Top-k采样 if top_k > 0:     indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]     logits[indices_to_remove] = float('-inf')

详细解释

  • torch.topk(logits, top_k): 找到最大的k个值

  • indices_to_remove: 标记要移除的索引

  • logits[indices_to_remove] = float('-inf'): 设置为负无穷(概率为0)

probs = torch.softmax(logits, dim=-1) next_token = torch.multinomial(probs, 1).item() tokens.append(next_token)

详细解释

  • torch.softmax: 将logits转换为概率分布

  • torch.multinomial: 根据概率分布随机采样

  • .item(): 将张量转换为Python数字

  • tokens.append(): 添加到生成序列

12. 生成展示

torch.manual_seed(42) text1 = generate_text("HAMLET: To be or not to be", max_tokens=100, temperature=0.5, top_k=10)

详细解释

  • torch.manual_seed(42): 设置随机种子确保可重现

  • 不同参数组合展示不同的生成风格

核心概念总结GPT的本质

  1. 输入:字符序列 → 数字序列

  2. 处理:Transformer层处理上下文信息

  3. 输出:预测下一个字符的概率分布

  4. 生成:根据概率分布采样下一个字符

关键技术

  • 嵌入:字符 → 向量

  • 位置编码:告诉模型字符位置

  • 自注意力:字符之间的关系

  • 因果掩码:防止看到未来

  • 温度采样:控制创造性

  • Top-k采样:控制选择范围

这就是GPT的完整实现,每一行代码都有其特定的作用,组合起来就能生成连贯的莎士比亚文风的文本。

转载本文请联系原作者获取授权,同时请注明本文来自李维科学网博客。

链接地址:https://wap.sciencenet.cn/blog-362400-1488386.html?mobile=1

收藏

分享到:

下一篇
当前推荐数:2
推荐人:
推荐到博客首页
网友评论0 条评论
确定删除指定的回复吗?
确定删除本博文吗?