Stephen 技术博客

AI和数据平台的工程实践

← 返回文章列表

Makemore Part 2 总结:MLP 字符级语言模型

对应 Bengio 2003《A Neural Probabilistic Language Model》论文复现
学习日:Day 10–12

一、为什么从 Bigram 升级到 MLP

Bigram 模型的根本问题:

维度BigramMLP (NPLM)
上下文只看前 1 个字符可以看前 N 个字符 (block_size)
参数化方式27×27 计数表神经网络 + embedding
上下文扩展表大小 = 27^N 指数爆炸参数量线性增长
字符相似性没有"a 和 e 都是元音"的概念embedding 自动学到相似性
核心创新:把每个字符映射到一个低维向量(embedding),让"语义相近"的字符共享统计强度。这是后来所有 LLM 的起点。

二、架构图与代码对应(Bengio 2003 NPLM)

Bengio 2003 NPLM 架构图
从下到上的数据流:字符索引 → 查表得 embedding → 拼接 → tanh 隐层 → 输出 logits → 交叉熵 loss

关键设计

  • embedding 表 C 是可学习的,反向传播会更新它
  • 拼接操作:3 个字符各 2 维 → 拼成 6 维向量送给隐藏层
  • tanh 引入非线性,让多层组合有意义
  • 输出层不显式做 softmax,而是把 logits 直接喂给 F.cross_entropy(数值更稳定)

三、关键超参数

参数取值含义
block_size3上下文窗口大小,看前几个字符预测下一个
embedding_dim2 ~ 10每个字符向量的维度
hidden_size100 ~ 200隐藏层神经元数
vocab_size2726 字母 + . 终止符
batch_size32每次梯度更新用多少样本

参数量估算: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 曲线(模拟):

MLP 训练 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 会讲)

六、值得记住的几个"坑"

  1. 不要忘记把参数加进 parameters 列表,否则 backward 不更新

  2. emb.view(-1, 30) 不是 reshape,是 concatenation 的工程实现
    [N, 3, 10][N, 30] 等价于"把 3 个 10 维 embedding 首尾相接"

  3. F.cross_entropy(logits, Y) 内部已经做了 softmax
    不要自己再 softmax + log,会数值不稳定

  4. p.grad = Nonep.grad.zero_() 更高效
    PyTorch 会跳过空梯度的累加

  5. 学习率太大初期 loss 会爆炸,太小训练慢
    用 lr finder 而不是拍脑袋

七、与后面 Transformer 的关系

这节的架构是所有现代 LLM 的"原型":

Bengio 2003 / Karpathy MLPTransformer (GPT)
Embedding table CToken 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,画出曲线