从零实现一个极简 GPT 系列开到第二篇,说实话百感交集。上一篇我们还在纠结怎么把文本变成数字,这一篇终于要接触真正的"模型"了。但别急着把 Transformer、多头注意力这些庞然大物搬进来——在动手写 attention 之前,我强烈建议先实现一个结构简单到极点、但五脏俱全的 Bigram 语言模型,也就是所谓的二元语法模型。它只用一个 Embedding 层,没有任何上下文窗口,却能把"数据管线、损失函数、训练循环、采样生成"这条主链路完整跑通一遍。等这条链路在你手里变得像呼吸一样自然,后面再加注意力机制时,你才会知道每一步改动到底在解决什么问题。
我写这篇内容时的心态是:假设你已经能用 PyTorch 写出一个基础的全连接网络,对 tensor 的 shape 操作有一定直觉,但没碰过任何语言模型。接下来所有代码你都可以直接复制到 Jupyter 或脚本里跑,数据用任何纯文本都行。
1. 为什么第一个模型选 Bigram:一个不需要"注意力"的语言模型
1.1 语言模型的最小定义
语言模型的任务一句话就能说清楚:给定一段已经出现的字符序列,预测下一个字符是什么。形式化地讲,我们希望建模一个条件概率分布P(next_char | previous_chars)。比如说你给模型看 "I like cheese",它应该给 "I" 后面的所有候选字符分配一个概率,其中像空格、逗号这类在训练语料中高频出现在 "I" 后的字符,概率就会偏大。
那么这个任务的最小实现是什么?如果我们彻底放弃"previous_chars"里面所有历史信息,只保留最后一个字符,模型就退化成只看当前字符来预测下一个字符。这就是 Bigram 名称的来源:它等价于统计语料中每两个相邻字符的共现频率。比如语料里 "t" 后面跟着 "h" 的次数很多,模型就会学到 "t" 之后输出 "h" 概率较高。
在代码层面,Bigram 模型甚至可以不用神经网络——手写一个二维计数矩阵就能完成。但为了后续平滑过渡到 GPT,我们会用 PyTorch 的nn.Embedding来把它写成一个标准模型。这样它和最终 Transformer 模型的输入输出接口完全一致:输入一个形状为(Batch, Time)的 token 序列,输出一个形状为(Batch, Time, VocabSize)的 logits 序列。
1.2 Bigram 的局限与它的不可替代价值
你肯定会问,这种只能看一个字符的模型能有什么用?它连最基本的语法结构都学不会,更不用说理解语义了。确实,Bigram 生成的文本几乎就是随机字符排列,最多在字符共现频率上显得"像人话"。但它的价值不在生成质量,而在于它把整个训练链路压缩到了最小可复现的范围。
我见过很多直接上手实现 Transformer 的同学,一上来就被多头注意力的 shape 变换劝退。问题往往不是注意力机制本身难理解,而是他们还没有一个能快速反馈的完整工具链:数据加载有问题时,会误以为是模型写错了;损失下降不下去时,也分不清是优化器问题还是模型设计问题。先用 Bigram 跑通一遍,等于先给整个流程做了一次"冒烟测试"。后面无论增加什么模块,数据部分和训练框架都不需要再动,我们只需要替换模型内部的计算逻辑。这个价值在调试复杂模型时会体现得淋漓尽致。
此外,Bigram 也是我们之后验证梯度是否正确的好参照物。当 Transformer 版模型损失怎么也降不到 Bigram 以下时,至少能证明底层数据管线没问题。
2. 数据管线:把一段文本变成模型能吃的 (输入, 目标) 对
2.1 字符级 tokenizer 的实现
我们需要一个能反复调用的文本处理器。我的做法和大多数入门教程一致:使用字符级 tokenizer,也就是把每个字符当成一个 token。这样做的好处是词汇表极小,方便可视化,而且不会引入 BPE 等分词算法的额外复杂度。对于 1MB 左右的英文语料,字符集合通常只有 50~100 个,足够我们研究模型行为。
先把原始文本读进来,然后构建两个映射表:字符到整数的stoi,整数到字符的itos。Python 代码一般长这样:
with open('input.txt', 'r', encoding='utf-8') as f: text = f.read() chars = sorted(list(set(text))) vocab_size = len(chars) print(f"语料总字符数: {len(text)},去重后字符数: {vocab_size}") stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for i, ch in enumerate(chars)} def encode(s): """字符串 -> token 列表""" return [stoi[c] for c in s] def decode(l): """token 列表 -> 字符串""" return ''.join([itos[i] for i in l])这里有一个值得注意的细节:用set(text)去重会丢失字符顺序信息,但因为我们之后用sorted重新排序,所以结果可复现。另一个细节是encode函数需要处理数据中遇到的每个字符,一旦语料里出现未登录字符就会报 KeyError,因此如果你换数据集,务必确保chars是基于新文本重新生成的,而不是复用旧词汇表。
2.2 构造输入序列与目标序列:核心是 shift 一位
把整个语料编码成一个长整数张量后,就该构造模型训练用的 (x, y) 对。语言模型的监督信号并不是显式标注好的,而是从文本自身生成的:对于任意位置的字符,它的"正确答案"就是紧接着它的下一个字符。所以我们需要把原始序列和它的移位版本配对。
假设原始 token 序列是[1, 2, 3, 4, 5],如果我们想让模型通过[1, 2, 3]预测[2, 3, 4],那么输入就是data[0:3],目标就是data[1:4]。在 PyTorch 里,我们通常一次处理一批形状为(batch_size, block_size)的序列。
data = torch.tensor(encode(text), dtype=torch.long) # 分训练集和验证集 n = int(0.9 * len(data)) train_data = data[:n] val_data = data[n:] batch_size = 32 block_size = 8 # 上下文长度,Bigram 模型其实用不到,但留着方便后续扩展 def get_batch(split): data = train_data if split == 'train' else val_data # 随机选择 batch_size 个起始位置 ix = torch.randint(len(data) - block_size, (batch_size,)) x = torch.stack([data[i:i+block_size] for i in ix]) y = torch.stack([data[i+1:i+block_size+1] for i in ix]) return x, y这段代码中的torch.randint是异步随机采样,不用像有些教程那样手动 shuffle 整个语料。需要说明的是,我们并没有把语料切成互不重叠的 batch,而是每次随机选一段连续子序列,所以同一个字符会以不同上下文出现在不同训练步里,这极大地增加了样本多样性,也符合现代语言模型的做法。
还要提醒一下:block_size在这里虽然是 8,但 Bigram 模型并不真正利用它——因为nn.Embedding对每个位置的输出只由该位置的 token 决定,不涉及跨位置的信息混合。但保留这个维度,能让代码接口上和后续的 Transformer 保持一致,到时候只需要改模型内部,不需要改数据加载。
3. Bigram 模型的前向实现:一张表和一堆 logits
3.1 用 nn.Embedding 实现查表
现在到了最核心的部分:定义一个前向传播能直接输出 logits 的模型。对于 Bigram,我们要做的只是把每个输入 token 映射到一个长度等于vocab_size的向量,这个向量里的数值是缩放前的分数,也就是 logits。用nn.Embedding(vocab_size, vocab_size)一行就能搞定:Embedding 本质上就是一张可学习的vocab_size × vocab_size的权重矩阵,取第i行就是输入 tokeni对应的输出向量。
import torch import torch.nn as nn import torch.nn.functional as F class BigramLanguageModel(nn.Module): def __init__(self, vocab_size): super().__init__() self.token_embedding_table = nn.Embedding(vocab_size, vocab_size) def forward(self, idx, targets=None): logits = self.token_embedding_table(idx) # logits shape: (B, T, C) if targets is not None: # 交叉熵需要把 (B, T, C) 变成 (B*T, C),targets 变成 (B*T) B, T, C = logits.shape logits = logits.view(B * T, C) targets = targets.view(B * T) loss = F.cross_entropy(logits, targets) else: loss = None return logits, loss def generate(self, idx, max_new_tokens): for _ in range(max_new_tokens): logits, loss = self(idx) # 取最后一个时间步的输出 logits = logits[:, -1, :] probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat((idx, idx_next), dim=1) return idx这里最反直觉的地方在于:明明说是 Bigram,为什么forward接受一个长度为block_size=8的序列,而不是单个字符?原因很简单——参数共享。上面那张 Embedding 表对所有位置都是同一张,所以无论我们输入长度为 1 还是长度 8,本质上模型在做的事情没变:对于输入序列的每个位置,查表得到对应输出。换句话说,block_size=8只是并行计算了 8 个单独的 Bigram 预测而已。
3.2 为什么没有 softmax 也能训练(交叉熵内部处理)
很多刚接触代码的同学会有一个疑问:语言模型的输出不应该是概率吗?怎么代码里只有 logits,没有看到softmax呢?这个疑问其实源于对损失函数实现细节的不熟悉。F.cross_entropy这个函数在 PyTorch 里的标准用法就是直接接收 logits,而不是经过 softmax 之后的概率向量。原因很简单:交叉熵损失定义为-log(softmax(logits)[target]),其中涉及一个 log 和一个 softmax,而数学上这两步可以合并成一个数值上更稳定的计算过程,能避免因为中间概率值过小导致的对数溢出。所以训练阶段我们不需要手动加 softmax,模型输出的 logits 直接喂给损失函数就行。
至于generate函数里的F.softmax和torch.multinomial,那是为了在推理时把 logits 变成概率分布,再从分布中随机采样一个 token。采样的随机性很重要,如果直接每次都取概率最大的 token,模型会陷入重复输出;反之,如果按概率采样,就能让模型偶尔说出一些"意料之外但概率合理"的内容,这正是生成式模型的魅力所在。
这个模型总参数量就是vocab_size * vocab_size。如果词汇表是 65 个字符,那么参数只有 4225 个,小到甚至可以在 CPU 上瞬间完成推理。
4. 训练循环:loss 从 4.8 降到 2.5 的过程中发生了什么
4.1 数据按 batch 切分
我们已经在get_batch里实现了随机 batch 采样。接下来就是标准的训练循环。这里有一个容易踩的坑:我见过有人为了追求简单,一次性把整个训练集喂给模型做梯度下降。对于语言模型,这种做法几乎不可行,因为全语料的损失是一个超级大的 batch,计算图会占用大量内存。所以我们一定用 mini-batch 随机采样,每个 step 只喂32 × 8 = 256个字符,让模型在一个 batch 内同时看到 32 个不同位置的上下文片段。
model = BigramLanguageModel(vocab_size) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) for step in range(10000): xb, yb = get_batch('train') logits, loss = model(xb, yb) optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() if step % 1000 == 0: print(f"step {step}: train_loss {loss.item():.4f}")设置zero_grad(set_to_none=True)是比zero_grad()更高效的做法,因为直接把梯度置为 None 能减少不必要的内存清零操作,这在模型规模变大后收益明显。
4.2 优化器与超参数
我选择的优化器是AdamW,它比原版 Adam 做了权重衰减解耦,是目前训练语言模型最常用的默认选择。学习率1e-3对于这么小的模型是一个相对安全的初始值。你可以自己尝试把学习率改成1e-2,大概率会出现 loss 震荡甚至直接发散;改成1e-4则会发现 loss 下降变得异常缓慢。对于这个 Bigram 模型,1e-3是我实测比较稳的位置。
另一个超参数是 batch size。我这里用了 32,你可以按需调整。但要注意,batch size 变大后,每个 step 的计算量会增加,而 loss 的噪声会减小;反之,batch size 变小虽然噪声大,但 step 数相同的情况下更不容易过拟合。对于这个玩具级模型,32 完全够用。
4.3 训练日志与 loss 变化的意义
当 vocab_size 为 65 时,模型初始化的 Embedding 权重是从标准正态分布随机采样的。此时每个 token 对应的输出 logits 大致随机,经过 softmax 后每个类别的概率近似相等,所以初始 loss 约等于ln(65) ≈ 4.17。如果考虑字符频率不均,真正的初始 loss 可能略高或略低,但大致在这个范围。
训练几分钟后(CPU 上 1 万步大概需要 3~5 分钟),loss 通常会稳定下降到 2.5 左右。这一步下降说明模型学到了什么?它学到的不是语法,而是字符共现频率:比如字母q后面大概率是u,句号后面大概率是空格,空格后面大概率是某个常见字母。这类统计规律恰恰是 Bigram 模型能够捕捉的最有价值信息。如果你拿验证集去算 loss,会发现验证 loss 也在 2.5 附近,说明这个模型还没有严重过拟合——毕竟容量太小,想记下整个语料也记不住。
在训练过程中,我建议你刻意观察每个 step 的 loss 变化曲线。你会发现 loss 不是单调下降的,而是伴随着大量小波动,这是 mini-batch 随机采样带来的正常现象。不要因为某个 step 的 loss 突然升高就怀疑代码写错了,连续观察几百步的整体趋势才有意义。
5. 文本采样:让模型输出第一个"看起来像英语"的句子
5.1 采样函数与 temperature
训练结束后,我们最想做的就是让模型开口说话。给定一个初始字符(或者干脆用换行符作为起始 token),不断调用generate,把上一次输出的新 token 拼回输入序列,再预测下一个。这个过程会持续max_new_tokens次。
为了让生成结果更有趣,可以在采样前加入一个 temperature 参数:把 logits 除以 temperature,再送进 softmax。temperature 大于 1 会让概率分布变得更平坦,生成结果更随机;小于 1 会让分布更尖锐,更倾向于选择高频 token。对于 Bigram 模型,我建议 temperature 设在 0.8 到 1.2 之间,因为原始概率分布本来就已经很平坦了,温度太低反而会生成大量重复的常见字符序列。
def generate_with_temp(idx, max_new_tokens, temperature=1.0): for _ in range(max_new_tokens): logits, _ = model(idx) logits = logits[:, -1, :] / temperature probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat((idx, idx_next), dim=1) return idx5.2 第一个生成结果解析
我拿一段 1MB 左右的英文语料做实验,训练 5000 步后,以换行符作为起始 token,设置 temperature=1.0,生成的文本大概像这样:
the fout far the terther I the the foung the and wor, and ton thy t. "Wher ber tat the the thar ton说实话,这段文本在人类眼里几乎就是乱码。但请你看几个现象:它已经能产生许多合法的英文单词片段,比如 "the"、"and"、"ton"、"thy",而且空格的分布位置也基本符合英文文本习惯。你还能注意到大量 "th"、"he"、"an" 这样的高频双字符组合。这说明模型确实学到了字符级的转移概率,只是它没有能力组织成一句话,因为它根本看不到超过一个字符的历史信息。
如果你把 temperature 调低到 0.5,生成结果可能会变成一堆 "the the the the" 这样的重复,因为高频字符组合被过度放大;调高到 1.5,则会看到大量低频字符甚至是标点符号的混杂。这些现象都是理解语言模型采样机制的最好素材。
6. 从 Bigram 走向 Transformer:下一步的进化点和容易踩的坑
6.1 为什么 Bigram 本质上是"一张静态表"
现在我们已经跑通了第一个语言模型,是时候跳出代码,站在更高维度审视它。Bigram 模型的参数就是一个vocab_size × vocab_size的矩阵,其中每行i表示:如果当前字符是i,下一字符各个候选的分数是多少。这个矩阵一旦训练完成,就固定下来,永远不会根据上下文动态变化。
换句话说,无论输入是 "I love cheese" 还是 "I hate cheese",只要当前要预测的下一个位置前面的字符是同一个(比如空格),模型给出的概率分布就是完全相同的。它完全忽略了空格之前的love和hate这两个词在语义上的巨大差异。这也意味着,Bigram 模型没有能力处理任何需要长期依赖的任务,比如代词指代、语法一致性、主题连贯性等等。
要解决这个问题,必须让模型在预测时"看到"更长的历史。最直接的想法就是把最近若干个字符拼接成一个向量喂给网络,但如果只是简单拼接,那也不过是一个更长上下文的 n-gram,仍然无法处理任意长度的依赖。真正能解决这个问题的是注意力机制:它允许每个位置动态地从所有历史位置中聚合信息,而且这种聚合是由当前内容决定的,不是静态查表。
6.2 引入上下文能力的路线图
很多从零实现 GPT 的教程会在这一步直接跳到多头注意力,我觉得这个跨度还是有点大。比较平滑的过渡方式是:先实现一个 Block 类,包含一个单头注意力 + 一个 MLP;验证它在小型语料上的损失能明显低于 Bigram 后,再扩展为多头,加上残差连接和 LayerNorm。下一篇内容我们会沿着这条路线走,但在动手之前,我强烈建议你先自己完成一个简单的实验:手工把 Bigram 的 loss 记下来,当作 baseline,后续所有模型改进的 loss 都必须低于这个值,否则说明新增的模块根本没有起到作用。
在这个过程中有几个高频坑必须提前说。第一个坑是get_batch中把x和y的索引写反,导致模型输入输出完全错位,训练 loss 永远降不动。解决方法是在写完后用几个已知的短文本测试,打印decode(x[0])和decode(y[0]),确认逐字符 shift 一位。第二个坑是generate时将idx直接传入模型,忘记block_size限制——一旦输入序列超过训练时的上下文长度,后续真正引入注意力时就会出现 shape 不一致。虽然 Bigram 当前不依赖上下文,但这个好习惯现在就要养成:生成时一定只取最后一个block_size的 token。第三个坑是直接用model(idx)而不是model(idx, None),导致前向返回的 loss 为 None 时被误用,从而报错。
我个人在实际操作中的体会是,Bigram 模型虽然简单,但它是检验你对语言模型基本概念是否透彻的最佳尺子。如果你能不看任何参考代码,完全靠自己的理解写出训练和采样流程,并且能把每一行 shape 变化都解释清楚,那后面面对 Transformer 时你的心态会完全不一样。下一步,我们就要在这个模型里加入真正的"上下文"了,到时候你再回头看这张 Embedding 表,会明白它只是完整模型的第一层。