第3讲 · 手把手语言建模(MLP 篇):从零构建 makemore 的多层感知机

本电子书由教学视频重构而成。 视频原标题:Building makemore Part 2: MLP 书中代码、数据集与图片版权归原作者 Andrej Karpathy 所有。本文为课堂讲解的中文书面重构,仅供学习交流。 👉 E02 特别值得做:它会让你第一次真切感到「初始化没调好,训练一开始就在瞎猜」。

讲师 Andrej Karpathy
视频地址 https://www.youtube.com/watch?v=TCH_1BHY58I
所属课程 Neural Networks: Zero to Hero — https://karpathy.ai/zero-to-hero.html
本讲课件 Notebook https://github.com/karpathy/nn-zero-to-hero/blob/master/lectures/makemore/makemore_part2_mlp.ipynb
🟢 Google Colab(免安装,浏览器里直接跑) https://colab.research.google.com/drive/1YIfmkftLrz6MPTOO9Vwqrop2Q5llHIGK?usp=sharing
完整实现(makemore 仓库) https://github.com/karpathy/makemore
📄 必读论文:Bengio et al. 2003 https://www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf
讨论区 官方 Discord:https://discord.gg/3zy8kqD9Cp
延伸阅读 PyTorch internals(张量/storage 原理):http://blog.ezyang.com/2019/05/pytorch-internals/
E01 调超参数,打败他给出的最佳验证损失 2.2。
E02(初始化) 他在视频里没有认真处理初始化。(1) 如果初始化时预测概率是完全均匀的,损失应该是多少?我们实际得到多少?(2) 能不能调初始化,让起始损失更接近 (1)?
E03 读 Bengio et al. 2003,实现并尝试论文里的任意一个想法。它管用吗?

对应讲次:第3讲(Karpathy: Neural Networks — Zero to Hero)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者拓展|📝 本讲的官方练习(Exercises):Karpathy 在这一讲的描述里给了 3 道题,强烈建议动手做——它们正好覆盖了本讲的三个核心:


引子:bigram 的天花板

(参考时间: 00:00)

上一讲我们用计数和一个单层线性网络实现了 bigram 语言模型:只看前一个字符,预测下一个字符的分布。

问题:只用一个字符的上下文,预测质量很差——生成出来的名字不太像名字。

那多看点上下文行不行?

「表的行数随上下文长度指数增长」——每行的计数样本又太少,整件事就爆炸了。

编者拓展|这就是"维度灾难"在语言建模里的第一次现身:这种「按 (上下文 → 下一词) 计数」的模型叫 n-gram 模型。它的致命伤是:参数(表的行数)随上下文长度指数增长,而每种上下文能观察到的样本却越来越少。所以到 n=3、4 之后就完全不可用了。

Bengio 2003 的关键突破就是绕开这张表:把"词"映射到一个低维稠密向量(embedding),让相似上下文共享统计强度。这也是现代 LLM 的思想起点。

因此今天要做的,是多层感知机(MLP)语言模型——它遵循 Bengio et al. 2003 那篇非常有影响力的论文。

bigram 的上下文表随长度指数爆炸

📷 bigram 的上下文表随长度指数爆炸
回到原视频 (00:02:10)


一、Bengio et al. 2003:从"查表"到"嵌入"

(参考时间: 01:48)

论文解决的问题和我们完全一样,只是它做的是词级(词表 17,000 个词),我们做字符级。

核心思想:给每个词关联一个(比如)30 维的特征向量——也就是把每个词"嵌入"到一个 30 维空间里。

这为什么能泛化? 论文举了个漂亮的例子:

假设你要预测 "A dog was running in a ___.",而这整句话在训练集里从未出现过(out-of-distribution)。 但你可能见过 "The dog was running in a ___."。如果网络学到 A 和 The 的嵌入很接近(它们常常可以互换),知识就能通过嵌入空间"迁移"过来。同理,网络可以学到"猫和狗都是动物、出现在相似的上下文里"。

换句话说:没见过的句子,也能靠"见过相似的词"来推断。

Bengio 2003 的 MLP 语言模型结构图

📷 Bengio 2003 的 MLP 语言模型结构图
回到原视频 (00:06:00)

网络结构(论文的图,也是我们本讲要实现的东西):

层 说明
输入 3 个前序词的索引(0 ~ 16999 的整数)
查找表 C 一个 17000 × 30 的矩阵;每个索引取出 C 的一行,得到 30 维嵌入
输入层 3 个词 × 30 维 = 90 个神经元(C 被所有词共享)
隐藏层 大小是超参数(比如 100),全连接到 90 个输入,后接 tanh
输出层 17000 个神经元(每个词一个),全连接到隐藏层——「大部分计算量都在这里」
Softmax 把 17000 个 logits 变成概率分布
损失 用真实的下一个词的索引取出它的概率,最大化它(即最小化负对数似然)

所有参数(输出层 W/b、隐藏层 W/b、嵌入表 C)一起用反向传播优化。

编者拓展|「embedding」到底是个什么东西:一句话——embedding 就是一个"可训练的查找表":把离散的符号(词、字符)映射成连续向量。 - 它没有魔法:就是矩阵 C,C[idx] 就是"索引 idx 的向量"; - 但它的威力在于:向量的"邻近"可以表达意义的相似,于是离散符号之间的"知识迁移"变成了连续空间里的几何; - 今天你听说的「词向量」(word2vec、GLoVe)、「token embedding」、「position embedding」,都是这个思想的不同变体。

本讲后面会亲眼看到:27 个字符训练完后,元音 a/e/i/o/u 会自动聚成一团,而 q 和 . 跑到很远的地方——没人告诉网络这件事。


二、重建数据集:滑动窗口

(参考时间: 09:03)

新建 notebook,读入 names.txt,建立字符 → 整数的映射(27 个符号)。

定义 block size(上下文长度) = 3:用前 3 个字符预测第 4 个。

block_size = 3
X, Y = [], []
for w in words[:5]:            # 开发时先只用前 5 个词
    context = [0] * block_size # 用 0(即 '.')填充
    for ch in w + '.':
        ix = stoi[ch]
        X.append(context)
        Y.append(ix)
        context = context[1:] + [ix]   # 滚动窗口

对 emma 一个词,就能造出 5 个训练样本(... → e、..e → m、.em → m、emm → a、mma → .)。

改 block size 只需要改一个数——4、5、10 都能自动工作,永远用 . 填充。

用滑动窗口从名字里构造 X(3 个整数)与 Y(下一个字符)

📷 用滑动窗口从名字里构造 X(3 个整数)与 Y(下一个字符)
回到原视频 (00:10:30)


三、嵌入查找表 C

(参考时间: 12:19)

我们只有 27 个字符,先嵌入到一个很小的空间(2 维),方便后面可视化。

C = torch.randn((27, 2))     # 27 行、2 列,随机初始化

对比论文:论文把 17,000 个词塞进 30 维——「一个很小的空间里塞了很多点」。我们只有 27 个字符,2 维就够了。

取出单个索引的嵌入,有两种看起来不同、实际完全等价的写法:

写法 A:直接索引

C[5]                    # 取 C 的第 5 行

写法 B:one-hot 乘 C

F.one_hot(torch.tensor(5), num_classes=27).float() @ C

结果完全相同!因为 one-hot 里只有一个 1,矩阵乘法把 C 的其余行全掩掉了,只"挑出"第 5 行。

编者拓展|一个重要洞察:嵌入层 = 没有非线性的第一层:上面两种写法等价,意味着——"查表"可以看成"神经网络的第一层":它接收 one-hot 输入、权重矩阵就是 C、没有非线性(纯线性神经元)。

但实践中我们只做索引(C[X]),因为快得多:不必真的把 27 维 one-hot 向量乘一遍矩阵。「我们丢掉了 one-hot 的解释,只保留索引」。

如何一次性嵌入 X 里所有的整数? PyTorch 的索引非常强:

C[X]        # X 是 (32, 3) 的整数张量 → 结果形状 (32, 3, 2)
嵌入:CX 一次性把 (32,3) 的整数变成 (32,3,2) 的向量

📷 嵌入:CX 一次性把 (32,3) 的整数变成 (32,3,2) 的向量
回到原视频 (00:17:30)

编者说明:PyTorch 不只是支持 C[5],还支持用列表、张量、甚至多维张量索引——所以 C[X] 一步到位。这正是它"索引很猛(awesome)"的地方。


四、隐藏层,以及 torch.Tensor 的内部结构

(参考时间: 18:35)

隐藏层:输入是 3 × 2 = 6 维,神经元数量是超参数(先取 100):

W1 = torch.randn((6, 100))
b1 = torch.randn(100)

但直接乘会失败:emb 的形状是 (32, 3, 2),而 W1 是 (6, 100)——形状对不上。我们需要先把它拼成 (32, 6)。

做法一(可读但较慢):torch.cat

torch.cat(torch.unbind(emb, 1), 1)     # 把 3 个 (32,2) 沿维度 1 拼起来 → (32,6)

(unbind 删掉并返回某维度的所有切片,于是这一段不依赖 block size,比手写下标更通用。)

做法二(更好、更快):view

这引出了 torch.Tensor 的内部原理:

a = torch.arange(18)          # 形状 (18,),底层 storage 就是 18 个数
a.view(2, 9)                  # 只改"怎么看",不改数据
a.view(9, 2)
a.view(3, 3, 2)               # 只要元素总数相同,随便 view

关键:view 不做任何内存拷贝/移动。每个张量底下有一个 storage(永远是一维的),而 view 只是调整几个属性:storage offset、strides、shape——让这一维序列被"看成"不同的 N 维数组。

emb.view(32, 6)               # 等价于上面的 cat,但零拷贝
emb.view(-1, 6)               # 用 -1 让 PyTorch 自己推断那一维

验证:emb.view(32,6) == torch.cat(...) 逐元素相等 ✅。

storage / view / strides:view 只改

📷 storage / view / strides:view 只改"怎么看",不拷贝数据
回到原视频 (00:24:30)

编者拓展|view 为什么重要:在深度学习里,张量变形(reshape/view/permute)无处不在,而且它们绝大多数是零拷贝的——只改元数据。这带来巨大的性能优势,也带来一大类 bug 的来源(比如 view 前必须保证内存连续,否则要用 .reshape() 或 .contiguous())。

想深入理解:读 PyTorch internals(本讲 front matter 里的链接)——它讲清了 storage / strides / view 的关系,和你在《操作系统》里学的「物理内存 vs 虚拟地址映射」是同一套思想。

隐藏层前向传播:

emb = C[X]                                  # (32, 3, 2)
embcat = emb.view(emb.shape[0], -1)         # (32, 6)
h = torch.tanh(embcat @ W1 + b1)            # (32, 100)

别忘了检查广播:h 是 (32,100),b1 是 (100,)。加法时 NumPy/PyTorch 会右对齐、补一个假维度变成 (1,100),再沿行复制——正好给每一行加上同一个 bias。但一定要自己确认,别"把自己脚打穿"。

隐藏层:view(32,6) 后 matmul + bias + tanh

📷 隐藏层:view(32,6) 后 matmul + bias + tanh
回到原视频 (00:26:30)


五、输出层、Softmax 与负对数似然

(参考时间: 29:15)

W2 = torch.randn((100, 27))
b2 = torch.randn(27)
logits = h @ W2 + b2          # (32, 27)

Softmax(和上一讲完全一样):

counts = logits.exp()
prob = counts / counts.sum(1, keepdims=True)   # 每行和为 1

负对数似然(NLL)损失:取正确字符的概率,取对数、求平均、取负:

loss = -prob[torch.arange(32), Y].log().mean()
输出层 → softmax → 取出正确类的概率 → NLL 损失

📷 输出层 → softmax → 取出正确类的概率 → NLL 损失
回到原视频 (00:30:00)

整理成"体面"的版本:把参数收进一个列表(总共 约 3,400 个参数),用 generator 保证可复现。

编者拓展|交叉熵 / NLL 的直觉:损失衡量的是「模型给正确答案分配的概率」: - 概率接近 1 → $-\log(p) \to 0$,损失几乎为 0; - 概率接近 0 → $-\log(p) \to \infty$,惩罚极大。

所以最小化 NLL = 让正确答案的概率尽量接近 1。这也是为什么语言模型(包括 GPT)的损失都叫 cross-entropy / NLL。

5.1 用 F.cross_entropy,别自己写

(参考时间: 32:49)

「我们没有在发明轮子」——PyTorch 内置了 F.cross_entropy(logits, Y),结果完全一样,但强烈推荐:

  1. 前向更快:PyTorch 会把这些操作融合成一个 kernel(fused kernel),不产生大量中间张量(我们手写会新建一堆 counts、prob);
  2. 反向更简单、更快:数学上可以化简——就像 micrograd 里 tanh 的反向只需要 1 - t²,而不必对 exp、加法、除法逐个回传;
  3. 数值更稳定:这是最隐蔽也最重要的一点——看下去就懂。

数值稳定性的演示:取 logits [-2, 3, -3, 0, 5] 时一切正常;但如果某个 logit 变成 +100,$e^{100}$ 超出浮点范围 → inf → 结果是 nan。(负得很多反而没关系——$e^{-100} \approx 0$。)

解法很聪明:由于 softmax 有归一化,给所有 logits 同时加任意常数,概率不变。所以 PyTorch 内部会先减去 logits 的最大值,于是最大的那个变成 0、其余都是负数——永远不溢出。

F.cross_entropy 的三点好处:融合 kernel、更简的反向、数值稳定(减去最大值)

📷 F.cross_entropy 的三点好处:融合 kernel、更简的反向、数值稳定(减去最大值)
回到原视频 (00:36:00)


六、训练循环与「过拟合一个 batch」

(参考时间: 37:56)

for p in parameters:
    p.grad = None           # 清梯度(等价于置零)
loss.backward()             # 反向传播,填 grad
for p in parameters:
    p.data += -lr * p.grad  # 梯度下降:沿负梯度走一步

(还要给每个参数 requires_grad = True。)

先只用 32 个样本:损失从 17 一路降到几乎 0——因为 3,400 个参数 vs 32 个样本,模型太容易记住这一点点数据了。这叫作「overfit 一个 batch」,是调试神经网络的标准第一步:

能不能把一个 batch 训到接近 0 损失? 如果不能,说明代码有 bug(或模型太小)——先别急着上全量数据。

为什么损失到不了恰好 0? 因为相同的输入可能对应不同的标签:... 后面可以是 e、o、a、s……模型只能给它们分配一个折中的概率分布(最优解 = 训练集里各标签的频率)。

训练循环:zero_grad → backward → update;先把一个 batch 过拟合到接近 0

📷 训练循环:zero_grad → backward → update;先把一个 batch 过拟合到接近 0
回到原视频 (00:39:30)


七、全量数据与 Mini-batch

(参考时间: 41:25)

去掉"只用前 5 个词"的限制,用全部数据:样本数从 32 变成 228,146。

但全量前向+反向太慢。实践中人们用 mini-batch:每次随机取一部分样本,只在这部分上做前向/反向/更新。

ix = torch.randint(0, X.shape[0], (32,))
Xb, Yb = X[ix], Y[ix]       # 只取 32 个样本

为什么"近似梯度"反而更好? 小批量算出的梯度方向不那么准,但——

「用一个粗略的梯度多走几步,胜过用精确的梯度少走几步。」

果然,加上小批量后"几乎瞬间"就能跑很多步。

Mini-batch:随机取 32 个样本做前向/反向/更新

📷 Mini-batch:随机取 32 个样本做前向/反向/更新
回到原视频 (00:44:00)


八、怎么找一个好的学习率?

(参考时间: 45:40)

学习率 0.1 是猜的。怎么科学地确定?

做法:在一个指数间隔的区间里扫一遍学习率,每个学习率都从相同的初始权重出发跑几步,然后画出"学习率 vs 损失"。

lre = torch.linspace(-3, 0, 1000)      # 指数:从 1e-3 到 1e0
lrs = 10 ** lre                        # 真正的候选学习率

图通常长这样:

本讲的结果:最优大约在 指数 −1,也就是 $10^{-1} = \mathbf{0.1}$——"原来猜的 0.1 还挺好"。

编者拓展|「学习率扫描」是每个深度学习从业者的第一天就会用到的技能:它便宜、直观、几乎总能帮你避开"训练半天没动静"和"损失直接爆炸"两个大坑。

配套的两个常识: - 学习率衰减(learning rate decay):训练后段把学习率调小 10 倍,让模型"慢下来"打磨细节; - 先写一个能过拟合小 batch 的循环,再上全量数据——顺序反了会浪费很多时间。

学习率扫描:太小学不动、太大爆炸,凹陷处最好

📷 学习率扫描:太小学不动、太大爆炸,凹陷处最好
回到原视频 (00:49:30)


九、划分 Train / Dev / Test —— 为什么必须这样做

(参考时间: 53:20)

前面说"损失 2.3,比 bigram 的 2.45 好"——但这不完全对。

因为模型容量可以无限增大:参数从 3,400 到上万、上百万。参数越多,越容易把训练集一字不差地背下来:

所以行业标准是三分数据:

划分 用途 常见比例
train 训练参数(梯度下降) 80%
dev / validation 调超参数(隐藏层大小、嵌入维度、正则强度、学习率……) 10%
test 最终评估,只在最后用极少数几次 10%

为什么 test 只能"少用":每次你在 test 上评估、并据此做决定,你就在"训练" test——你开始过拟合测试集。所以 test 只用于最终报告,是"一次性"的。

本讲的做法:把词表 shuffle,前 80% 造 train,中间 10% 造 dev,最后 10% 造 test;训练只在 Xtr/Ytr 上,评估用 Xdev/Ydev。

数据三分:train 训参数、dev 调超参数、test 只用于最终评估

📷 数据三分:train 训参数、dev 调超参数、test 只用于最终评估
回到原视频 (00:56:00)

编者拓展|underfitting vs overfitting,一句话判别: - train 损失 ≈ dev 损失(都不低) → 欠拟合(underfitting):模型太简单,加大模型会有帮助; - train 损失很低、但 dev 损失远高于它 → 过拟合(overfitting):模型太能记,需要更多数据 / 正则化 / 更小的模型。

本讲第一次评估时两者都是 2.3 左右——典型的欠拟合。于是接下来:把模型变大。


十、实验:加大隐藏层、可视化嵌入

(参考时间: 1:00:49)

实验一:隐藏层 100 → 300。参数从 3,400 涨到 10,000。但 dev 损失没有明显改善(依然 2.3 左右)——说明"瓶颈另有其人"。

降学习率能再抠出一点(2.32 → 2.23)。

可视化字符嵌入:嵌入维度是 2,正好可以画在平面上:

「这不是随机的,这些嵌入是有意义的。」

训练后的字符嵌入:元音聚成一团,q 与 . 是离群点

📷 训练后的字符嵌入:元音聚成一团,q 与 . 是离群点
回到原视频 (01:06:00)

编者拓展|为什么"能可视化"这件事本身就值得停下来:嵌入维度 = 2 时,你能用眼睛看见学习的结果。这是一次难得的"看见神经网络在想什么"的机会——元音聚团不是我们编程写死的,而是从 32,000 个名字里自动学出来的。

一旦维度 > 2(下一节就升到 10 维),你就画不出来了——这也解释了为什么降维可视化(t-SNE、UMAP、PCA)在研究和工程里如此常用。

实验二:嵌入维度 2 → 10(隐藏层同时 300 → 200,总参数约 11,000)。

「嵌入的维度很可能就是之前的瓶颈。」

加大嵌入维度到 10:损失进一步下降(2.16 / 2.19)

📷 加大嵌入维度到 10:损失进一步下降(2.16 / 2.19)
回到原视频 (01:09:30)

编者拓展|这一个小实验,把"调模型"的方法论讲透了: 1. 先判断是欠拟合还是过拟合(看 train 与 dev 的差距); 2. 欠拟合 → 增大模型(更多神经元、更大嵌入); 3. 每次只动一个旋钮,观察 dev 变化; 4. 收敛后衰减学习率,再刷几轮。

他说自己的最佳验证损失是 2.17,并把"打败它"作为练习留给你(E01)。


十一、总结、采样,以及「练手清单」

(参考时间: 1:11:46)

最终代码(很短):数据准备 → 三组参数(C、W1/b1、W2/b2)→ 前向传播(emb → view → tanh → logits → cross_entropy)→ 训练循环(mini-batch)。

从模型采样:

for _ in range(20):
    out = []
    context = [0] * block_size
    while True:
        emb = C[torch.tensor([context])]        # (1, block_size, emb_dim)
        h = torch.tanh(emb.view(1, -1) @ W1 + b1)
        logits = h @ W2 + b2
        probs = F.softmax(logits, dim=1)        # 同样做了数值稳定
        ix = torch.multinomial(probs, num_samples=1).item()
        context = context[1:] + [ix]
        out.append(ix)
        if ix == 0: break
    print(''.join(itos[i] for i in out))

生成出来的名字明显更像人名了(如 ham、joes、lela)。

采样:从 `...` 出发,逐步预测并滚动窗口,生成新的名字

📷 采样:从 `...` 出发,逐步预测并滚动窗口,生成新的名字
回到原视频 (01:14:30)

他留给你的"旋钮"(也是 E01 的解题方向):

并再次邀请你读那篇 19 页的论文——「现在你应该能读懂其中的很大一部分了」。

编者拓展|到这里,你已经把"现代语言模型"的骨架装起来了:embedding + MLP + softmax + cross-entropy + mini-batch 梯度下降 + train/dev/test。

后续讲次只是把这个骨架越搭越高: - Part 3(Activations & Gradients, BatchNorm)——让更深的网络能训得动; - Part 4(Becoming a Backprop Ninja)——手写反向传播,彻底搞懂梯度; - Part 5(WaveNet)——用层次化结构扩大上下文; - GPT——把 MLP 换成 Transformer。

但损失函数、优化器框架、训练循环,一路都不变。


附:官方参考与延伸阅读

版权说明:本书内容依据 Andrej Karpathy 的公开教学视频 Building makemore Part 2: MLP 重构,代码与数据集版权归原作者所有,遵循其仓库所附许可(MIT License)。本电子书仅用于学习交流,不用于商业用途。