Makemore Part 2 总结:MLP 字符级语言模型
一、为什么从 Bigram 升级到 MLP
Bigram 模型的根本问题:
| 维度 | Bigram | MLP (NPLM) |
|---|---|---|
| 上下文 | 只看前 1 个字符 | 可以看前 N 个字符 (block_size) |
| 参数化方式 | 27×27 计数表 | 神经网络 + embedding |
| 上下文扩展 | 表大小 = 27^N 指数爆炸 | 参数量线性增长 |
| 字符相似性 | 没有"a 和 e 都是元音"的概念 | embedding 自动学到相似性 |
核心创新:把每个字符映射到一个低维向量(embedding),让"语义相近"的字符共享统计强度。这是后来所有 LLM 的起点。
二、架构图与代码对应(Bengio 2003 NPLM)
从下到上的数据流:字符索引 → 查表得 embedding → 拼接 → tanh 隐层 → 输出 logits → 交叉熵 loss
关键设计:
- embedding 表 C 是可学习的,反向传播会更新它
- 拼接操作:3 个字符各 2 维 → 拼成 6 维向量送给隐藏层
- tanh 引入非线性,让多层组合有意义
- 输出层不显式做 softmax,而是把 logits 直接喂给
F.cross_entropy(数值更稳定)
三、关键超参数
| 参数 | 取值 | 含义 |
|---|---|---|
block_size | 3 | 上下文窗口大小,看前几个字符预测下一个 |
embedding_dim | 2 ~ 10 | 每个字符向量的维度 |
hidden_size | 100 ~ 200 | 隐藏层神经元数 |
vocab_size | 27 | 26 字母 + . 终止符 |
batch_size | 32 | 每次梯度更新用多少样本 |
参数量估算:27 × emb_dim + (block_size × emb_dim) × hidden + hidden × 27
四、关键代码
1. 构建数据集(滑动窗口)
block_size = 3
def build_dataset(words):
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] # 滑动窗口
return torch.tensor(X), torch.tensor(Y)
要点:每个样本是 (前 block_size 个字符, 下一个字符),整个名字会被切成多个训练样本。
2. 模型参数
g = torch.Generator().manual_seed(2147483647)
C = torch.randn((27, 10), generator=g) # embedding 表
W1 = torch.randn((30, 200), generator=g) # 30 = 3×10
b1 = torch.randn(200, generator=g)
W2 = torch.randn((200, 27), generator=g)
b2 = torch.randn(27, generator=g)
parameters = [C, W1, b1, W2, b2]
for p in parameters:
p.requires_grad = True
3. Forward + 训练循环
for step in range(200000):
# ---- minibatch ----
ix = torch.randint(0, Xtr.shape[0], (32,))
# ---- forward ----
emb = C[Xtr[ix]] # [32, 3, 10]
h = torch.tanh(emb.view(-1, 30) @ W1 + b1) # [32, 200]
logits = h @ W2 + b2 # [32, 27]
loss = F.cross_entropy(logits, Ytr[ix])
# ---- backward ----
for p in parameters:
p.grad = None
loss.backward()
# ---- update ----
lr = 0.1 if step < 100000 else 0.01 # 学习率衰减
for p in parameters:
p.data += -lr * p.grad
训练过程典型的 loss 曲线(模拟):
蓝色 = 每步 loss(有 mini-batch 噪声),红色 = 滑动平均;竖虚线 = 学习率衰减点
4. 在 train/val/test 上各算一次最终 loss
@torch.no_grad()
def split_loss(split):
x, y = {'train': (Xtr, Ytr), 'val': (Xdev, Ydev), 'test': (Xte, Yte)}[split]
emb = C[x]
h = torch.tanh(emb.view(-1, 30) @ W1 + b1)
logits = h @ W2 + b2
return F.cross_entropy(logits, y).item()
5. 从训练好的模型采样
g = torch.Generator().manual_seed(2147483647 + 10)
for _ in range(20):
out = []
context = [0] * block_size
while True:
emb = C[torch.tensor([context])]
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, generator=g).item()
context = context[1:] + [ix]
out.append(ix)
if ix == 0: break
print(''.join(itos[i] for i in out))
五、本节引入的核心训练技巧
这是整个课程第一次系统出现"工程化训练"的套路,每一个后面都会一直用:
1. Mini-batch SGD
- 不再用全量数据算梯度,而是随机抽 32 个样本
- 每步梯度精细度低,但每步快 1000 倍,总迭代次数大幅增加
- 这是所有大模型训练的基础
2. Learning Rate Finder
- 在
1e-3~1之间用 logspace 取候选 - 跑几百步,画
loss vs lr,找曲线下降最快的拐点 - 经验:初始 lr = 0.1,训练后期降到 0.01 ~ 0.001
3. Train / Val / Test 三分法
- 80% / 10% / 10%
- train:算梯度
- val (dev):调超参(block_size、emb_dim、hidden_size、lr)
- test:最终评估,只看一次,不能根据 test 调参
- 如果 train loss 远小于 val loss → 过拟合,需要正则化或更多数据
4. 过拟合作为调试手段
- 先在极小数据集(比如 32 个样本)上跑,看能不能 loss 降到接近 0
- 降不下去 → 模型/优化器/数据流水线有 bug
- 这是最快的"模型能跑通"的 sanity check
5. 可视化诊断
- loss 曲线:是否平滑下降,是否震荡,是否平台期
- 2D embedding 散点图(emb_dim=2 时):肉眼看元音是否聚类
- logits 分布:初始化好不好(后面 Part 3 会讲)
六、值得记住的几个"坑"
不要忘记把参数加进 parameters 列表,否则 backward 不更新
emb.view(-1, 30)不是 reshape,是 concatenation 的工程实现
[N, 3, 10]→[N, 30]等价于"把 3 个 10 维 embedding 首尾相接"F.cross_entropy(logits, Y) 内部已经做了 softmax
不要自己再softmax + log,会数值不稳定p.grad = None比p.grad.zero_()更高效
PyTorch 会跳过空梯度的累加学习率太大初期 loss 会爆炸,太小训练慢
用 lr finder 而不是拍脑袋
七、与后面 Transformer 的关系
这节的架构是所有现代 LLM 的"原型":
| Bengio 2003 / Karpathy MLP | Transformer (GPT) |
|---|---|
| Embedding table C | Token embedding + positional embedding |
Fixed block_size 上下文 | Self-attention 看整个上下文 |
tanh(emb.view(-1, N) @ W1 + b1) | Attention + FFN |
| 单层 hidden | 多层堆叠 (Layer Norm + Residual) |
| Cross-entropy on next token | 完全一样 |
理解了 NPLM,Transformer 的训练目标和损失函数对你来说就没有新东西了 —— 改变的只是中间那一层怎么计算上下文。
八、Day 11–12 建议练习
- 把
block_size从 3 改成 5、8,看 val loss 怎么变 - 把
emb_dim从 2 改成 10、30,看 val loss 怎么变 - 把
hidden_size从 100 改成 300,看是否过拟合 - 故意只用 train 100 个名字,观察 val/train 差距 → 直观感受过拟合
- 自己写一遍 lr finder,画出曲线