200字
从计数矩阵到神经网络:Karpathy Bigram 模型复现

本文为 makemore 课程复现与个人学习笔记,部分内容使用 AI 辅助整理。
仓库地址:ruali-dev/makemore-notes: This repository contains my implementation and learning notes based on Andrej Karpathy's makemore lecture series.

如果数学公式没有正常显示,请刷新页面

前言

跟着 Karpathy 的 makemore 复现了下 Bigram。Bigram 是一个字符级语言模型。模型每次只接收当前字符,并预测下一个字符的概率分布。

例如,在单词 emma 中,模型会看到:

. → e
e → m
m → m
m → a
a → .

Bigram 不理解单词的含义,也不具备长期记忆。它只学习一个问题:

在训练数据中,某个字符出现之后,下一个字符通常是什么?

显而易见的,它生成的结果完全来自训练数据中的局部字符统计规律。

makemore 使用了两种方式实现这个模型:

  1. 直接统计所有字符对的出现次数,构造计数矩阵 N,再将每一行归一化为概率分布;
  2. 使用一个单层神经网络,通过负对数似然损失和梯度下降学习字符之间的转移概率。

模型对比

最终,这两种方法获得了非常接近的损失和生成效果。

这说明,神经网络版本并没有获得某种额外的"智能"。它实际上是在通过优化,重新学习统计矩阵中已经显式计算出来的字符规律。

但两者的意义并不完全相同:

计数模型告诉我们答案如何直接算出来;神经网络模型则展示了如何定义一个目标,让模型自己从数据中找到近似的答案。


Bigram 的定义

Bigram 是一种只使用前一个 Token 预测下一个 Token 的条件概率模型,也是最简单的自回归语言模型之一。

即已知当前位置的字符 ​x_t,下一个字符 ​x_{t+1} 分别可能是什么?

P(x_{t+1} \mid x_t)

统计矩阵直接建立模型

Karpathy 的操作大致为:统计字符对,构造 N,按行归一化,然后采样。

Token 编码

神经网络不能直接处理字符串,需要建立字符 ↔ 数字的映射。

  • chars:文本中所有唯一字符组成的词表
  • stoi(string to integer):字符 → id
  • itos(integer to string):id → 字符

示例

字符 id
'a' 0
'b' 1
'.'

统计矩阵

zip(chs, chs[1:]) 得到相邻 pair:


(<S> → e), (e → m), (m → m), (m → a), (a → <E>)

即:当前字符 → 下一个字符

如何存储这些关系?

  • <S> 后面出现 e 多少次?
  • e 后面出现 m 多少次?
  • m 后面出现 a 多少次?

最直观的方式:画一个表(这就是 N 矩阵):

<S> a e m <E>
<S> 0 0 1 0 0
a 0 0 0 0 1
e 0 0 0 1 0
m 0 1 0 1 0

N — Bigram 统计矩阵

  • N.shape = (vocab_size, vocab_size)
  • N[i, j] = Token i 后面紧跟 Token j 的次数

计数与归一化流程

  1. 遍历文本中所有相邻 Token 对 ​(x_t, x_{t+1})
  2. 执行 N[x_t, x_{t+1}] += 1
  3. 计数矩阵 → 概率矩阵(归一化)
for w in words:
    # chs = ['<S>'] + list(w) + ['<E>']
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        N[ix1, ix2] += 1 #用一个矩阵记录:每个字符后面跟每个字符出现了多少次。

统计矩阵(matplotlib 显示)
统计矩阵

平滑:对于训练中没有见过的数据,可能会直接预测概率为0,如果想不要"过度自信",引入平滑操作

P = (N + 1).float()
P /= P.sum(dim=1, keepdim=True)

如果某个 Bigram 的预测概率为 0,那么其对数为负无穷,NLL 会变为正无穷;计数平滑可以避免未见组合获得严格的零概率。


单层神经网络

Bigram 神经网络的输入是当前字符的 one-hot 向量:

xenc = F.one_hot(xs, num_classes=27).float()

权重矩阵:

W.shape == (27, 27)

每一行对应一个当前字符,每一列对应一个可能的下一个字符。
计算:

logits = xenc @ W

因为 xenc 是 one-hot,矩阵乘法实际上只是在 W 中选出当前字符对应的那一行。

所以这里的权重矩阵 W,在功能上和统计模型里的计数矩阵 N 十分相似:

N:通过人工计数获得
W:通过梯度下降学习获得

两者最终都描述了:

当前字符 → 下一个字符的概率分布

统计模型不是神经网络的对立面。这个最简单的神经网络,本质上是在用优化方法重新学习一个统计模型。


Logits 是什么

假设模型要预测 27 个字符。
执行:

logits = xenc @ W

得到的某一行可能是:

[-1.2, 0.4, 2.1, -0.7, ..., 1.3]

这些数就是 logits
可以理解为:

模型对每个候选结果给出的原始分数。

它们暂时不是概率,因为:

  • 可以是负数;
  • 不需要位于 0 到 1 之间;
  • 加起来不等于 1;
  • 数值本身没有直接的概率含义。

例如:

a 的 logit:-1.2
b 的 logit: 0.4
c 的 logit: 2.1

只能说明模型当前更偏向 c,其次是 b,最后是 a

但生成字符时,我们需要的是概率:

a:多少概率?
b:多少概率?
c:多少概率?

所以接下来需要 Softmax。

Softmax

Softmax 将任意实数 logits 转换为合法概率分布:

p_j = \frac{e^{z_j}}{\sum_k e^{z_k}}

其中 ​z_j 就是第 j 个 logit。

代码可以直接写成:

probs = torch.softmax(logits, dim=1)

Karpathy 为了展示内部过程,写成:

counts = logits.exp()
probs = counts / counts.sum(dim=1, keepdim=True)

两段代码的意义相同。

logits 经过 softmax,先求指数,再除以总和。

于是它变成了合法概率:

  • 全部大于 0
  • 全部小于 1
  • 总和等于 1

Softmax 的作用可以概括为:

保留各个候选结果之间的相对强弱,同时将它们归一化为概率分布。

负对数似然

如何衡量模型的效果?

Bigram 是一个概率模型。对于一个完整单词,模型给出的似然等于其中所有相邻字符条件概率的乘积。

模型希望真实数据的似然越大越好。但多个小于 1 的概率连续相乘后,结果可能变得非常小,并带来数值计算问题。因此,注意到:

\log(ab) = \log a + \log b

可以对似然取对数。原本的概率连乘可以转化为对数概率求和。

对数函数

但是直接取对数值为负,本来希望最大化似然,即最大化对数似然(由于对数函数单调递增),但神经网络训练通常统一写成最小化 loss,于是加一个负号,则为负对数似然,优化目标就是将这个值变小。

\mathrm{NLL} = -\log p

真实字符获得的预测概率越高,负对数似然就越小。因此,在同一份数据上,平均负对数似然越低,通常说明模型对真实字符的预测越准确。

代码:

log_likelihood = 0.0
n = 0

#for w in words:
for w in ['andrejq']:
    # chs = ['<S>'] + list(w) + ['<E>']
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        prob = P[ix1, ix2]
        logprob = torch.log(prob) # 对数是概率的单调变换,会保留概率的大小顺序。方便处理
        log_likelihood += logprob
        n += 1
        # print(f'{ch1}{ch2}:{prob:.4f} {logprob:.4f}')

print(f'{log_likelihood=}')
nll = -log_likelihood #负对数似然
print(f'{nll=}')
print(f'{nll/n}')

细节知识

详见 notebook 注释

  • 使用 zip(chs, chs[1:]) 构造相邻字符对;
  • 使用 enumerate 建立字符编号;
  • 使用广播完成矩阵按行归一化;
  • 使用随机数生成器复现实验结果。

Bigram 的局限

显而易见的,它只看前一个字符,所以:

  • 不理解完整单词结构;
  • 无法记忆更远的上下文;
  • 很容易生成局部合理、整体奇怪的名字;
  • 模型效果上限由上下文长度决定。

总结

makemore 的 Bigram 模型本身并不强大,但它提供了一个足够小的实验,使语言模型训练的完整流程变得清晰:将字符转化为输入,根据参数计算概率,用损失衡量预测误差,再通过反向传播更新参数。

统计矩阵和单层神经网络得到近似结果,并不是神经网络失去了意义。恰恰相反,它展示了神经网络最基本的能力:只要我们能够选择合适的参数表示,并定义一个可优化的目标,就可以让模型从数据中学习原本需要显式计算的规律。

从计数矩阵到神经网络:Karpathy Bigram 模型复现
作者
若离
发表于
2026-07-29
License
CC BY-NC-SA 4.0

评论