ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零实现字符级语言模型:Makemore项目精讲与PyTorch实战

从零实现字符级语言模型:Makemore项目精讲与PyTorch实战 1. 这个项目到底在教什么一句话说清 makemore 的定位如果你刷到过 Andrej Karpathy 的 Neural Networks: Zero to Hero 系列一定对 makemore 不陌生。一句话概括它是一个字符级别的语言模型输入几万个英文名字模型学会名字内部的字母组合规律然后自动生成一批新的、看起来像模像样的名字。我最初接触这个项目时刚看完一堆深度学习理论处于公式都认识、代码一行写不出来的状态makemore 几乎是为这种阶段量身定做的代码量不多没有复杂的数据 pipeline没有分布式训练但把神经网络最核心的几块——embedding、线性层、激活函数、softmax、交叉熵、反向传播——全部串起来了。很多人会误以为 makemore 是又一个调包项目用 HuggingFace 加载个预训练模型然后生成文本。恰恰相反它的价值在于从零这两个字。你亲手实现数据预处理、建词表、数 bigram 计数、构造训练样本、写 MLP 前向传播、写训练循环、调超参数、采样生成每一条代码背后都是可解释的数学逻辑。就算你以后去做大语言模型makemore 里这些基础件仍然在底层跑着只是规模和细节变了而已。除了生成名字这套思路可以平移到任何序列生成任务拼音纠错、代码补全、歌词生成、变异词检测。核心都是给定前面几个字符预测下一个字符。所以别看它 demo 简单吃透了这个项目你对语言模型到底在做什么这件事会有非常落地的理解而不是停留在一个模糊的它很厉害的层面。2. 数据侧的准备把名字变成模型能消化的数字2.1 字符表与特殊标记的设计makemore 使用的原始数据是 names.txt里面大约有三万多个英文名字一行一个例如 emma、liam、noah 之类。模型不认识字母只认识数字所以第一步是把所有出现过的字符收集起来做成词表。这里有个很容易被忽略的细节开始和结束标记怎么处理。Karpathy 的处理方式很朴素用一个特殊的.字符同时表示句首和句尾。也就是说名字 emma 会被表示成.emma.第一个.告诉模型句子开始最后一个.告诉模型句子结束。在训练时模型看到.时要学会预测第一个字母看到最后一个字母时要学会预测.来宣告名字结束。这个设计中词表大小为 2726 个小写字母加 1 个句点。为什么不用单独的s和/s两个 token因为多一个 token 意味着多一维输入、多一份参数而且对于 makemore 这种简单任务单个.完全够用。但你要知道这个设计的代价模型无法区分名字开头和名字结尾这两件事只能靠上下文去推断。如果在真实项目里做更复杂的生成任务通常会把开始、结束、填充都用不同的 token避免歧义。2.2 字符到索引的映射接下来要建立两个字典char_to_idx和idx_to_char分别负责字符转数字、数字转字符。代码逻辑非常简单但这一步是整个数据管线的地基后面所有环节都依赖它。words open(names.txt, r).read().splitlines() chars sorted(list(set(.join(words)))) stoi {s: i 1 for i, s in enumerate(chars)} stoi[.] 0 itos {i: s for s, i in stoi.items()} vocab_size len(itos) # 27这里注意一个小习惯把.的索引设为 0而不是追加在最后。这样做的原因是 PyTorch 的nn.Embedding默认会把索引 0 当作 padding 位置虽然 makemore 没有用 padding但把特殊字符放 0 位是一种约定俗成的做法后面如果要接 RNN 或 Transformer 会省很多事。2.3 构造输入-目标样本对训练样本的格式是给定前block_size个字符预测下一个字符。如果block_size3对于名字 emma 展开成 .emma. 后我们需要构造以下训练对输入前 3 个字符目标下一个字符. . .e. . em. e mme m mam m a.前三个输入中出现的.其实是空位填充。因为 emma 的开头是e但我们需要 3 个历史字符所以要在前面补两个.。这里就引出了block_size的概念它决定模型最多能看到多长的历史。block_size3意味着模型假设下一个字符只依赖前 3 个字符这是一个很强的马尔可夫假设也决定了模型能力的上限。实际代码里一般会把所有名字切分成(context, target)对再组装成张量def build_dataset(words, block_size3): X, Y [], [] for w in words: context [0] * block_size for ch in w .: ix stoi[ch] X.append(context) Y.append(ix) context context[1:] [ix] X torch.tensor(X) Y torch.tensor(Y) return X, Y这段代码值得细看context列表用滑动窗口的方式维护每读一个真实字符就把窗口左移一格再加入新字符。整个训练集就是由这种前block_size个字符 → 下一个字符的映射构成的最后喂给模型的就是几千几万个这样的整数索引样本。3. 先跑一个 bigram 统计基线知道下限才知道模型好不好很多人第一次写神经网络就直接上 MLP跳过 bigram。我建议不要跳因为一个没有任何神经网络的统计基线能帮你回答一个关键问题模型效果做到什么程度才算行3.1 bigram 的本质就是数字母对Bigram 模型假设下一个字符只取决于当前这一个字符。也就是说在名字 emma 里看到e就预测下一个字符的分布看到m就预测下一个字符的分布。怎么得到这个分布训练集里数次数就行。比如统计所有(a, b)字符对在名字中出现的次数形成一个 27×27 的计数矩阵N那么N[i][j]表示字符i后面跟着字符j的次数。转移概率就是按行归一化P N.float() P / P.sum(dim1, keepdimTrue)这里的概率完全是统计频次没有任何学习参数。如果某个字符对一次都没出现过它的概率是 0生成时一旦遇到这种组合就会崩溃所以实际使用中通常会加一个很小的平滑项。3.2 用最大似然估计来评估 bigram评估语言模型的标准指标是负对数似然Negative Log Likelihood也就是交叉熵损失。对于 bigram 模型每个样本(i, j)的损失是-log(P[i][j])把所有样本累加起来取平均得到的就是平均损失。这个数字能告诉我们什么首先随机猜测 27 个字符时损失大约是-ln(1/27) ≈ 3.23这是最差情况。如果 bigram 模型训练出来的损失远低于 3.23就说明它确实学到了一些规律。我跑下来的经验值大致是 2.4~2.6 之间。再用小技巧让概率显得更合理一点采样时不是每次选概率最大的字符而是从概率分布中抽样。这样生成的字符串会有随机性不会每次都是同一个名字。你可以写个循环从.出发反复按当前字符的概率分布采样下一个字符直到采到.结束这样就完成了一个名字的生成。3.3 bigram 的局限在哪里Bigram 生成的名字通常很短而且经常出现一些奇怪的组合因为它的视野只有一个字符完全看不到更长的依赖关系。比如名字 olivia 里i后面是v但如果前面出现过lbigram 压根不知道。这就是为什么我们需要引入block_size3甚至更大的上下文。另一个问题是数据稀疏。27×27 的计数矩阵已经相对密集但如果字符集扩大到几千、几万bigram 矩阵就会变得极度稀疏大量组合频次为 0平滑都救不回来。神经网络用 embedding 把字符映射为连续向量天然能处理虽然这个字符对没出现过但它们在向量空间中相邻的泛化问题。所以 bigram 的真正价值是给我们一个地板如果你的神经网络模型损失还没有显著低于 bigram那大概率是代码有 bug 或超参有问题而不是模型结构有问题。4. 换到神经网络为什么有了统计模型还要神经网络4.1 统计模型的短板无法泛化到没见过的组合沿用刚才的框架bigram 把每个字符当成一个离散的、互不相关的符号。a是第 1 个符号b是第 2 个符号它们在数学上没有相似这个概念。但真实语言里a、e、o这些元音有相似的行为模式s、t、n这些常见辅音也有相似的行为模式。统计模型无法把这种相似性迁移到新组合上。假设训练集里出现过 qu 这个组合从没出现过 qe那么 bigram 给q后接e的概率几乎是 0模型会认为这个名字是非法的。但你我都知道qe只是很少见并非不可能。神经网络通过 embedding 向量把u和e映射到向量空间中比较接近的位置然后u后面接元音的模式可以平滑地迁移到e上。4.2 神经网络语言模型的核心理念现代语言模型从 Bengio 2003 年的经典论文到 Transformer的核心都是这么一件事为每个 token 学习一个向量表示embedding然后用某种神经网络结构把这些向量融合起来输出下一个 token 的概率分布。makemore 里的 MLP 是这类模型里最简单的一种。具体来说模型做以下几步把最近的block_size个字符的索引查表得到对应的 embedding 向量。把这几个向量拼接concat成一个长向量。经过一个隐藏层激活函数用 tanh。经过输出层得到 27 个 logits。用 softmax 把这些 logits 转成概率分布和真实字符的 one-hot 向量算交叉熵损失。用 PyTorch 的nn.Module写出来就是import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(42) class MLP(nn.Module): def __init__(self, vocab_size, block_size, emb_dim10, hidden_dim200): super().__init__() self.emb nn.Embedding(vocab_size, emb_dim) self.fc1 nn.Linear(block_size * emb_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, vocab_size) def forward(self, x): e self.emb(x) # (batch, block_size, emb_dim) e e.view(e.size(0), -1) # 拼接 h torch.tanh(self.fc1(e)) logits self.fc2(h) return logits这里nn.Embedding是查表操作给一个索引返回一行可训练向量。整个网络的学习过程本质上是让 embedding 向量不断调整最终把行为模式相近的字符放到向量空间中相近的位置。你可以把 embedding 理解为模型自己总结出来的字符相似度表。4.3 从 bigram 到 MLP 的损失预期同一份数据上我跑出来的参考值大致是bigram 验证集 loss 约 2.5MLP 可以压到 2.1 左右。别看只降了 0.4它代表生成的名字质量有了明显提升至少不再频繁出现q后面接x这种明显违反英文拼写直觉的组合。这 0.4 的差距就是泛化能力的价格bigram 只会背训练集里的字符对MLP 则学会了元音后面更可能接辅音、q后面几乎总是u这类抽象规律并且能把这些规律组合使用。5. 实战用 PyTorch 从零手写 MLP 语言模型5.1 初始化参数和训练集划分先把数据分成训练集、验证集、测试集。不要偷懒只分训练和测试因为你需要一个验证集来观察是否过拟合。通常的做法是打乱后按 80% / 10% / 10% 划分import random random.seed(42) random.shuffle(words) n1 int(0.8 * len(words)) n2 int(0.9 * len(words)) Xtr, Ytr build_dataset(words[:n1]) # 训练 Xdev, Ydev build_dataset(words[n1:n2]) # 验证 Xte, Yte build_dataset(words[n2:]) # 测试如果你的机器内存很小可以用torch.utils.data.TensorDataset和DataLoader但 makemore 数据量很小直接建张量也完全没压力。5.2 训练循环里的关键选择MLP 的训练循环看着简单但每个选择背后都有讲究model MLP(vocab_size27, block_size3, emb_dim10, hidden_dim200) optimizer torch.optim.Adam(model.parameters(), lr0.01) batch_size 64 for step in range(200000): ix torch.randint(0, Xtr.shape[0], (batch_size,)) xb, yb Xtr[ix], Ytr[ix] logits model(xb) loss F.cross_entropy(logits, yb) optimizer.zero_grad() loss.backward() optimizer.step() if step % 10000 0: print(fstep {step}, train loss {loss.item():.4f})损失函数用F.cross_entropy它会自动把 logits 转成 softmax 概率再算交叉熵既数值稳定又省代码。如果用Adam学习率 0.01 是个比较稳的起点如果用SGD需要把学习率调高到 0.1 甚至 1并且效果通常不如 Adam 好收敛。原因很简单Adam 每个参数有自适应学习率对缩放不敏感适合新手SGD 需要手动调学习率但对最终泛化性能的打磨有帮助。关于batch_size我试过 32、64、128在 makemore 这个任务上差别不大64 是个稳妥的中间值。更大的 batch 能更充分利用 GPU但这里数据量小没必要纠结。训练 20 万步有点奢侈通常 5 万步损失已经接近收敛了但多跑一些能让损失曲线更平滑。5.3 损失曲线怎么看训练过程中我最关注两件事初始损失是否接近ln(27) ≈ 3.29下降速度是否合理。如果你的初始损失明显低于 3.29比如 1 点几那说明模型初始化有问题或者数据有泄漏网络一开始就作弊了。如果初始损失远大于 3.29比如 4、5 甚至更高多半是 embedding 初始化范围过大、或者输出层初始化导致 logits 极端需要仔细检查。正常情况下loss 曲线会呈现前几千步快速下降后面慢慢收敛的趋势。我跑出来的典型轨迹是第 0 步约 3.3第 1 万步约 2.4第 5 万步约 2.2之后下降非常缓慢最终验证集 loss 约 2.15~2.2。如果训练集 loss 持续下降但验证集 loss 开始反弹说明模型开始过拟合。5.4 采样生成温度参数的意义训练完成后用模型生成名字和 bigram 类似用循环采样但每次要维护一个滑动窗口def generate_name(model, block_size3, max_len20): model.eval() context [0] * block_size name [] with torch.no_grad(): for _ in range(max_len): x torch.tensor([context]) logits model(x) probs F.softmax(logits, dim-1).squeeze() ix torch.multinomial(probs, num_samples1).item() if ix 0: break name.append(itos[ix]) context context[1:] [ix] return .join(name)这里出现一个非常重要的概念torch.multinomial不是取概率最大的 token而是按概率分布抽样这样才能保证生成结果的多样性。如果想控制生成结果的冒险程度可以引入温度参数temperature把 logits 除以温度再做 softmax。温度越小分布越尖锐生成的字符越保守可能反复生成高概率的组合温度越大分布越平坦生成结果越随机、越容易出错。实践里temperature 设置在 0.8~1.0 之间生成的名字比较自然调到 0.5 左右会显示出一个很明显的特征生成结果高度集中在少数高频模式上名字可能重复出现。温度调到 1.5 以上出来的东西就开始发疯了偶尔会出现qzx这种明显不合理的组合。做 demo 时我通常直接展示不同温度下的采样结果观众能立刻理解温度的作用。5.5 训练集 loss 与验证集 loss 的差距最后用验证集算一次完整 loss看看和训练集差多少。如果训练集 2.0、验证集 2.5说明过拟合得厉害可以考虑增大数据量这里没有、减小模型容量降低hidden_dim、加 dropout 或 weight decay。如果两者都停在 2.5 左右说明模型容量不够可以加大 hidden 或 embedding 维度。一个容易被忽视的坑验证时也要走model.eval()并在torch.no_grad()下进行否则你算出来的 loss 可能包含 dropout 等训练时才有的随机噪声。makemore 这个模型没有 dropout影响不大但养成这个习惯对以后有用。6. 调试实录训练中我踩过的坑和排查思路6.1 损失一直卡在 3.3 不下降如果你发现 loss 从第一步开始就一直在 3.3 附近震荡怎么训练都不动先别急着怪优化器或学习率99% 的情况是模型的输出和标签没有对上。常见原因有三个第一block_size和输入维度不匹配导致输入张量形状错误但 PyTorch 有时不报错而是暗中广播成错误的计算。第二nn.CrossEntropyLoss的输入 logits 形状是(batch, vocab_size)标签形状是(batch,)如果你的 logits 形状变成(batch, 1, vocab_size)或标签维度多了交叉熵计算出的 loss 会表现为看似正常但永远不变。第三数据预处理中字符映射写反了stoi和itos没对齐导致模型一直在预测错误的字符。排查思路拿一个样本手动过一遍前向传播打印中间变量的形状确认logits和Y能对上。再用随机初始化的模型跑一个 batch检查初始 loss 是否接近ln(vocab_size)。如果接近说明前向传播没问题问题出在训练循环的梯度更新上。6.2 梯度爆炸与 NaNMLP 版本不太容易遇到 NaN但如果你把hidden_dim设得很大、学习率设得很大或者激活函数从 tanh 换成 ReLU 后没有处理死亡神经元就可能在训练后期出现 loss 突然变成nan。原因通常是大数值 logits 经过 softmax 后产生下溢或者参数更新过大导致权重发散。建议调参时先固定一个保守配置hidden200, lr0.01, Adam跑通了再逐步放大。如果非要用大学习率给梯度加一个nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)能显著降低 NaN 概率。在 makemore 这个项目里加了 clip 不会影响最终效果但会给你更多调参空间。6.3 embedding 参数和全连接层参数的学习速度不一样我早期观察过每个参数梯度范数的变化发现 embedding 层的梯度范数通常比全连接层小一两个数量级。这其实很合理每个字符的 embedding 只被包含该字符的训练样本更新而全连接层参数被所有样本更新所以更新频率和幅度天然不同。但这会影响整体收敛速度。如果发现 loss 下降很慢可以单独给 embedding 设更高的学习率或者干脆用 Adam 让它自适应。Kaggle 比赛里很多 NLP 模型对 embedding 用较小的初始化范围np.random.randn 乘 0.01这样能避免初始 embedding 向量距离太远导致训练初期震荡。6.4 验证集 loss 比训练集低可能有人觉得奇怪验证集 loss 怎么可能比训练集低这不是 bug反而是正常现象尤其在训练初期。原因有两个验证集里的名字可能整体更容易预测或者训练集里有很多罕见的字母组合拉高了平均损失。我之前跑出过验证集 2.16、训练集 2.21 的情况不要慌这不代表模型在验证集上更好只是数据分布的自然波动。真正需要警惕的是训练集 loss 持续下降、验证集 loss 开始反弹的交叉点那才是过拟合的起点。makemore 上这个交叉点出现得很晚因为数据量够大而模型很小但这不意味着你可以完全不管正则化尤其是以后把模型做大之后。7. 从 MLP 到更大世界makemore 之后还能做什么7.1 为什么 MLP 不够用了MLP 用的是固定窗口block_size3它的数学模型假设下一个字符只依赖前 3 个字符。这在名字生成任务里够用但到了真实语言里就远远不够了。比如预测句子里最后一个单词时前面的关键信息可能出现在 20 个词之前MLP 根本看不到。这也就是为什么后来的 RNN、LSTM、Transformer 要设计成能处理变长序列的结构核心变化就是把滑动窗口换成循环或注意力机制。从 makemore 往后走最自然的路线是 RNN。RNN 在每个时间步接收当前字符并维护一个隐藏状态向量这个隐藏状态记住了之前看到的所有信息。虽然实际中 RNN 很难记住非常久远的信息但至少它没有固定窗口的限制能处理任意长的输入。7.2 我试过的几个扩展方向我自己在 makemore 基础上做过三个方向的扩展都很有意思第一个把模型从 MLP 换成 GRU。GRU 是对 LSTM 的简化参数量更少训练更快。在 names 数据集上GRU 的 loss 可以比 MLP 再低 0.05~0.1。第二个把字符换成字节对编码BPE也就是把字符级升级成子词级这是大语言模型里最常用的 tokenization 方式。在名字数据集上 BPE 的提升不算明显但换个更大的语料库就能感觉到差距。第三个用 makemore 的思路做一个中文 App 名字生成器把字符表换成几千个常用汉字block_size 和 embedding 维度适当加大效果居然也不错。7.3 给初学者的最后建议如果你刚学完理论、还不会写代码我的建议是不要直接去看成品的 makemore 源码而是先自己尝试在只有大方向提示的情况下写一遍。卡住了再回去看。这个先卡住再看答案的过程比我单纯读十遍源码管用得多。具体可以这样先不看任何参考自己写一个 bigram 统计模型并完成采样。再尝试写一个最简单的 MLP用nn.Embedding和nn.Linear跑通训练循环。记录每次实验的 loss 和生成结果建立自己的基准数据。最后才打开 Karpathy 的 notebook 对照你会发现他的代码比你简洁得多但你能看懂每一行的原因了。makemore 不大但它是一条完整的、从数据到训练到生成再到调试的流水线。把这条流水线吃透后面再学 RNN、Transformer、大模型微调你会发现很多概念早就见过了只是规模变大了而已。对我来说它最大的价值不是生成名字那一刻的成就感而是第一次真切地感觉到神经网络是在用梯度一步步调整自己的行为这种手感是看再多书也换不来的。
返回列表