本文为 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 使用了两种方式实现这个模型:
- 直接统计所有字符对的出现次数,构造计数矩阵
N,再将每一行归一化为概率分布; - 使用一个单层神经网络,通过负对数似然损失和梯度下降学习字符之间的转移概率。

最终,这两种方法获得了非常接近的损失和生成效果。
这说明,神经网络版本并没有获得某种额外的"智能"。它实际上是在通过优化,重新学习统计矩阵中已经显式计算出来的字符规律。
但两者的意义并不完全相同:
计数模型告诉我们答案如何直接算出来;神经网络模型则展示了如何定义一个目标,让模型自己从数据中找到近似的答案。
Bigram 的定义
Bigram 是一种只使用前一个 Token 预测下一个 Token 的条件概率模型,也是最简单的自回归语言模型之一。
即已知当前位置的字符 x_t,下一个字符 x_{t+1} 分别可能是什么?
统计矩阵直接建立模型
Karpathy 的操作大致为:统计字符对,构造 N,按行归一化,然后采样。
Token 编码
神经网络不能直接处理字符串,需要建立字符 ↔ 数字的映射。
chars:文本中所有唯一字符组成的词表stoi(string to integer):字符 → iditos(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 的次数
计数与归一化流程
- 遍历文本中所有相邻 Token 对 (x_t, x_{t+1})
- 执行
N[x_t, x_{t+1}] += 1 - 计数矩阵 → 概率矩阵(归一化)
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 转换为合法概率分布:
其中 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 的概率连续相乘后,结果可能变得非常小,并带来数值计算问题。因此,注意到:
可以对似然取对数。原本的概率连乘可以转化为对数概率求和。

但是直接取对数值为负,本来希望最大化似然,即最大化对数似然(由于对数函数单调递增),但神经网络训练通常统一写成最小化 loss,于是加一个负号,则为负对数似然,优化目标就是将这个值变小。
真实字符获得的预测概率越高,负对数似然就越小。因此,在同一份数据上,平均负对数似然越低,通常说明模型对真实字符的预测越准确。
代码:
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 模型本身并不强大,但它提供了一个足够小的实验,使语言模型训练的完整流程变得清晰:将字符转化为输入,根据参数计算概率,用损失衡量预测误差,再通过反向传播更新参数。
统计矩阵和单层神经网络得到近似结果,并不是神经网络失去了意义。恰恰相反,它展示了神经网络最基本的能力:只要我们能够选择合适的参数表示,并定义一个可优化的目标,就可以让模型从数据中学习原本需要显式计算的规律。