Stephen 技术博客

AI和数据平台的工程实践

← 返回文章列表

Makemore Part 1 总结:Bigram 字符级语言模型

对应 Karpathy "Building makemore Part 1: bigrams"
学习日:Day 6–7

一、为什么从 Micrograd 升级到 Bigram

Micrograd 阶段的局限性:

维度MicrogradMakemore Part 1 (Bigram)
任务4 个 2D 点的分类真实的语言建模任务
数据规模4 条32000 个名字
模型类型玩具 MLP字符级生成模型(能采样新名字)
概率视角没有第一次系统接触概率语言模型
两种思路对比没有计数法 vs 神经网络法等价
核心洞察:这一节最反直觉、最重要的发现是 —— 基于频率统计的方法和基于神经网络的方法在数学上是等价的softmax(W) 训练后会逼近 N / N.sum() —— 这是理解后面所有 LLM 的关键起点。

二、两种思路与代码对应

Bigram 两种方法的等价性
看似完全不同的两条路径,训练后产出同一张概率表

这是整个 Karpathy 系列的第一个"啊哈时刻":原来神经网络在做的事情,就是用梯度下降"学出"那张统计概率表。

三、关键超参数

参数取值含义
vocab_size2726 字母 + . 起止符
神经网络权重W [27, 27]729 个参数
学习率50(比常规网络大得多,因为只有 729 个参数)
训练步数100 ~ 200全量梯度下降
数据来源names.txt32033 个名字

参数量对比:

  • 计数法:27×27 = 729 个浮点数(频率)
  • 神经网络法:27×27 = 729 个浮点数(权重)—— 完全相同!

四、关键代码

1. 数据准备:字符到索引的映射

words = open('names.txt').read().splitlines()
# words = ['emma', 'olivia', 'ava', 'isabella', ...]

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()}
# stoi['a']=1, stoi['b']=2, ..., stoi['.']=0

2. 思路 A:构建频率表

N = torch.zeros((27, 27), dtype=torch.int32)

for w in words:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        N[ix1, ix2] += 1

# 加一平滑(避免 log(0))
P = (N + 1).float()
P /= P.sum(1, keepdim=True)   # 按行归一化

(N + 1)Laplace 平滑,防止某个字符组合从未出现导致 log(0) = -inf

训练完后 N 的可视化(用 ~200 个常见名字生成的小数据集示例):

Bigram 频率热力图
每格上行 = 字符对,下行 = 出现次数。深蓝 = 高频组合(如 'an', 'le', 'a.')

3. 思路 A:从频率表采样新名字

g = torch.Generator().manual_seed(2147483647)

for _ in range(5):
    out = []
    ix = 0  # 从 . 开始
    while True:
        p = P[ix]
        ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
        out.append(itos[ix])
        if ix == 0:
            break
    print(''.join(out))

自回归采样:用上一个字符的概率分布采样下一个字符,循环直到生成 .

4. 思路 A:评估(NLL)

log_likelihood = 0.0
n = 0
for w in words:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1, ix2 = stoi[ch1], stoi[ch2]
        prob = P[ix1, ix2]
        log_likelihood += torch.log(prob)
        n += 1
print(f'nll = {-log_likelihood/n:.4f}')   # 平均负对数似然

5. 思路 B:神经网络版

# 构建训练集
xs, ys = [], []
for w in words:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        xs.append(stoi[ch1])
        ys.append(stoi[ch2])
xs = torch.tensor(xs)   # [N]
ys = torch.tensor(ys)   # [N]

# 模型参数
g = torch.Generator().manual_seed(2147483647)
W = torch.randn((27, 27), generator=g, requires_grad=True)

# 训练循环
for k in range(200):
    # forward
    xenc = F.one_hot(xs, num_classes=27).float()    # [N, 27]
    logits = xenc @ W                               # [N, 27]
    counts = logits.exp()                           # softmax 上半部分
    probs = counts / counts.sum(1, keepdims=True)   # softmax 下半部分
    loss = -probs[torch.arange(num), ys].log().mean()  # NLL

    # backward
    W.grad = None
    loss.backward()

    # update
    W.data += -50 * W.grad

6. 验证两种方法等价

# 训练完后比较
softmax_W = F.softmax(W, dim=1)
P_count   = (N + 1).float() / (N + 1).sum(1, keepdim=True)

print((softmax_W - P_count).abs().max())   # 接近 0

两份概率表几乎完全一致 —— 神经网络确实在"学出"统计表。

五、本节引入的核心训练技巧

虽然只是 bigram,但 Karpathy 在这里第一次系统引入了几个会贯穿后面所有 LLM 的概念:

1. Token 化与词表(stoi / itos)

  • 把字符 / 词 映射到整数 ID
  • 27 个字符(26 字母 + .)→ 0~26
  • 后面 GPT 用的 BPE tokenizer 思想完全一致,只是词表更大(50000+)

2. One-hot 编码 → 矩阵乘法 = 查表

  • F.one_hot(x) @ W 数学上等价于 W[x](直接索引行)
  • 这是后面 embedding 的雏形 —— embedding 就是把这个 W 看作"可学习的查表"

3. Softmax + Cross Entropy

  • softmax(logits) 把任意实数变成概率分布
  • 交叉熵 = NLL(负对数似然)
  • 最大化似然 ⟺ 最小化 NLL —— 这是所有概率模型训练的统一目标

4. 自回归采样

  • 从概率分布 P[当前字符]multinomial 采样
  • 把采样结果作为下一步的输入
  • 这是 GPT 生成文本的核心机制,只是上下文从 1 个字符扩展到几千个 token

5. 平滑(Smoothing)

  • (N + 1) 防止某些字符组合概率为 0
  • 现代 LLM 用 label smoothing、温度采样等更精细的方法,但思想一致

6. 评估指标:NLL / Perplexity

  • NLL 衡量模型对真实数据的"惊讶程度"
  • Perplexity = exp(NLL) 是更直观的版本
  • 整个 LLM 行业都用这两个指标衡量预训练质量

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

  1. 不加平滑会导致 log(0) = -inf,loss 爆炸
    (N + 1) 或者神经网络的"L2 正则"等价于平滑。

  2. one-hot @ W 不是计算,是查表
    F.one_hot(x) @ WW[x] 完全等价,但前者慢得多。PyTorch 提供 nn.Embedding 就是后者的封装。

  3. W 初始化太大会让 softmax 一边倒
    randn 默认方差 1,logits 范围可能 ±3,softmax 后非常 peaked。学习率得相应调大才能跳出,或者初始化时缩小。

  4. NLL 的输入是概率,cross_entropy 的输入是 logits
    F.cross_entropy(logits, y) 内部已经做了 log_softmax。不要再自己 softmax 然后送 cross_entropy,会数值不稳定。

  5. probs[torch.arange(N), ys] 这种"花式索引"很关键
    取每一行 ys[i] 那一列的概率,这是 PyTorch 计算 NLL 的标准写法。

七、与后面 Part 2 / Transformer 的关系

Bigram 是所有现代 LLM 的"最简化版":

Bigram (Part 1)MLP (Part 2)GPT / Transformer
上下文 = 1 字符上下文 = 3 字符上下文 = 2048+ token
模型 = W [27, 27]模型 = 嵌入 + MLP模型 = 嵌入 + N 层 Transformer
参数量 = 729参数量 = 10000+参数量 = 1B+
训练 = 全量梯度训练 = mini-batch SGD训练 = 大规模分布式
采样 = multinomial采样 = multinomial采样 = multinomial(+top-k、温度等技巧)

理解了 Bigram,你已经看到了语言模型的"灵魂": 输入 token 序列 → 模型给每个位置一个概率分布 → 用 NLL 训练 → 自回归采样生成。后面的所有进步,本质上都是在让"模型部分"变强。

八、Day 8 之前的建议练习

  • 不看代码,自己默写出 bigram 计数法的完整流程
  • 用一个简单的 trigram 计数法(看前 2 个字符)实现,比较 NLL 是否下降
  • 故意去掉平滑 (N + 1),观察 loss 怎么变 NaN
  • 在神经网络版本里,把学习率从 50 改成 0.5,观察训练速度变化
  • 核心检验:你能不能在脑子里清楚回答 —— "为什么 softmax(W)N/N.sum() 会收敛到同一个东西?"