bobofamilyNIU CHUN
08/36

两条路做同一个 bigram:数频次与梯度下降

2026-09-0416 分钟—阅读

这一集最妙的设计:同一个 bigram 模型用两种方式实现 —— 先数频次,再用神经网络训练 —— 最后证明两者结果几乎完全一致。

这个对照回答了一个一直悬着的问题:神经网络到底在学什么。

第一条路:数频次

数据与 bigram

32033 个真实英文人名,最短 2 个字母、最长 15 个。

bigram = bi(二)+ gram(元),属于 n-gram 家族:

名称看多远emma 拆出
unigram0e、m、m、a
bigram前 1 个em、mm、ma
trigram前 2 个emm、mma

拆分的标准写法:

chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
...

zip(chs, chs[1:]) 是取滑动窗口的标准手法 —— 列表和它错开一位的自己配对。

为什么末尾不会多出一对空的:zip 按最短的截断。这里恰好是想要的 —— 结束符只作为「答案」出现,不作为「输入」。同一个行为在第五篇的 Neuron.__call__ 里却是个隐患(输入长度不对也不报错)。

为什么起止符合并成一个 .

如果用两个符号,表就是 28×28。但起始符只出现在开头,永远不会作为「下一个字符」;结束符只出现在结尾,永远不会作为「当前字符」。所以会有一整行和一整列永远是 0。

合并成单个 . 后压缩成 27×27,而且没有全零行列。. 占 0 号位,所以字母从 1 开始:

stoi = {s: i+1 for i, s in enumerate(chars)}
stoi['.'] = 0
itos = {i: s for s, i in stoi.items()}

两个推导式里的变量顺序不同,由数据源决定:enumerate 给 (序号, 值) 所以是 for i, s;items() 给 (键, 值) 所以是 for s, i。写反不报错,但结果全错。

第一次用张量

N = torch.zeros((27, 27), dtype=torch.int32)
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
N[stoi[ch1], stoi[ch2]] += 1

一个对象装 729 个数,而不是 729 个 Value —— 这是从标量思维转向张量思维的起点。

为什么要换掉字典:字典能存数据,但做不了整体运算(要归一化得写循环),也无法可视化。

27×27 的 bigram 频次热力图,每格标着字符组合与出现次数
行 = 当前字符,列 = 下一个字符。颜色深浅是频次高低,每格里写着具体计数。看原图 ↗

从图上能直接读出的规律:

位置含义看到什么
第 0 列(x → .)以什么结尾n. 6763、a. 6640、e. 3983
第 0 行(. → x)以什么开头.a 4410、.k 2963、.m 2538
最深的几格高频组合an 5438、ar 3264、el 3248
q 那一整行几乎全白只有 qu = 206,其余几乎全 0
左上角 ..0空名字不存在

理论上限 729 种组合,实际出现 627 种 —— 缺 102 种。 这 102 个零后面会成为一个真问题。

采样:模型第一次「造」东西

g = torch.Generator().manual_seed(2147483647)
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()

multinomial 的原理是轮盘赌:把概率排成一条线段,每段长度 = 该项概率,然后在 0—1 之间取一个均匀随机数,看它落在哪一段。段越长越容易被砸中。

手写版本就是:

def pick(p, r):
cum = 0.0
for i, prob in enumerate(p):
cum += prob
if r < cum:
return i

随机数是唯一的不确定性来源。 给定 p 和 r,答案完全确定 —— 没有「二次随机」。所以固定种子后重跑,生成的名字完全相同。链条是:种子固定 → r 序列固定 → 每次抽签结果固定 → 名字固定。

为什么不能取最大值:p.argmax() 永远返回同一个,模型只会生成一个名字,而且是死循环。抽签才有多样性 —— 这也是 GPT 每次回答不同的原因。

生成循环:

ix = 0 # 从 . 出发
while True:
p = P[ix] # 查第 ix 行的分布
ix = torch.multinomial(p, 1, generator=g).item()
if ix == 0: break # 抽到 . 结束
out.append(itos[ix])

ix 的自我更新和第五篇 MLP.__call__ 里 x = layer(x) 是同一个套路 —— 上一步的输出成为下一步的输入。

生成结果:cexze、momasurailezitynn、konimittain、llayn、ka。

看着像名字,但都不是真名字。 对照完全均匀随机的 hjqzxvbn,bigram 虽然短视,确实学到了东西。质量不高是因为只看前一个字符 —— 生成 momasurai 中间那个 m 时,模型完全不知道前面已经有了什么。这就是上下文只有 1 的代价。

广播与 keepdim:本节最大的坑

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

dim 指的是「哪一维被消掉」,这点反直觉:

写法消掉哪维结果形状直观理解
A.sum(0)第 0 维(行)(3,)每列的和
A.sum(1)第 1 维(列)(3,)每行的和

keepdim 是 keep dimension —— 求和后那一维要不要留着(长度变成 1)。数值完全相同,区别只在形状:A.sum(1) 是 (2,),A.sum(1, keepdim=True) 是 (2, 1)。

广播规则是右对齐,1 在哪就沿哪复制。 用 3×3 举例,行和是 10、100、10:

正确版 (3,1) 是竖着一列,横向复制,每行都除以自己的行和 → 每行和为 1.0 ✓

错误版 (3,) 会在左边补 1 变成 (1,3),横着一行,纵向复制,于是每一列都除以同一个数 → 每行和变成 0.91、8.2、0.73 ✗

错误的本质是分母的下标从 i 变成了 j:

正确: P[i][j] = N[i][j] / rowsum[i]
错误: P[i][j] = N[i][j] / rowsum[j]

这个 bug 不报错。 (3,3) 和 (3,) 恰好能广播成功,PyTorch 认为你就是想这么算。唯一的防线是养成检查习惯:print(P.sum(1)) 必须全是 1.0。

顺带把三种「乘法」理清:A * B 是元素级(对应位置相乘),A @ B 是矩阵乘法(行乘列求和),A.sum(dim) 是归约(沿某维求和,消掉那一维)。PyTorch 里的 / 不是数学上的矩阵除法(那是乘逆矩阵),而是逐元素除;广播只是「补齐形状」的手段,真正的运算还是逐元素。

loss = 2.4544

上一篇推过的三步,这里落地:

log_likelihood += torch.log(prob) # 第一步:连乘变累加
nll = -log_likelihood # 第二步:加负号
print(nll / n) # 第三步:除以样本数 → 2.4544

为什么必须取对数:228146 个小于 1 的数连乘约等于 10⁻²⁹⁶⁰⁰⁰,而浮点下限是 10⁻³⁰⁸ —— 直接变 0。

为什么要除 N,三个理由:总和 56 万只说明样本多,说明不了模型好坏;不同大小的数据集要能横向比较;梯度不随批次大小剧烈变化,学习率不用跟着调。

翻译成困惑度:

模型loss相当于在几个里猜
完全随机log 27 = 3.3027
bigram2.454411.6
下一步的 MLP约 2.2约 9
理想01

loss 线性下降,候选数指数下降。 从 3.30 到 2.45 只降了 0.85,候选数却从 27 砍到 11.6,少了一半以上。这个直觉后面很有用:loss 从 2.0 降到 1.5(候选 7.4→4.5)是大改进,从 0.5 降到 0.45(1.65→1.57)就很小了 —— 同样降 0.05,在不同区间含义完全不同。

bigram 到不了更低的原因是只看前一个字符。看到 q 几乎确定下一个是 u(等效候选接近 1),看到 a 却可能跟很多字符(等效候选 15)。2.45 是所有位置平均下来的结果。

model smoothing:那 102 个零

试一个数据集里没有的组合:jq 从未出现过,概率 0,log 0 = -inf,loss 直接变无穷大。这就是上一篇那张对数图上「x 趋近 0 时垂直下坠」的实际后果。

解决办法是给所有计数加 k:

P = (N + k).float()
P /= P.sum(1, keepdim=True)
kloss等效候选说明
0最低—最准,但有 inf 风险
12.454411.6折中,实际用这个
102.5112.3开始抹平规律
1002.6914.7明显变差
10002.9519.1趋近随机

k 越大越差,是因为真实的高频组合被压低、低频的被抬高,分布趋于均匀。k 趋于无穷时概率全变成 1/27,loss 趋近 3.30。

这就是正则化的雏形。 k 控制的是「多相信数据 vs 多留余地」:k 小就完全拟合训练数据,可能过拟合;k 大就约束模型不让它太自信,牺牲训练 loss 换泛化能力。jq 没出现过不代表绝不可能 —— k=1 相当于说「每种组合我都假设见过一次」。后面会看到,神经网络里的 L2 正则化和这个 +k 在数学上是同一件事。

第二条路:神经网络

统计法已经做出一个能用的语言模型。现在用神经网络 + 梯度下降重做一遍。

为什么要费这个劲,三个理由:扩展性 —— 统计法看 3 个字符就要 27⁴ 格的表,看 10 个是 27¹¹,怎么都填不满;灵活性 —— 统计法只能「数」,神经网络能加激活、加层数、换结构;还有本集要证明的事 —— bigram 这么简单的情况下两种方法结果几乎一致,说明神经网络不是魔法。

建训练集:从「遍历」到「样本对」

统计法直接遍历所有名字数数,神经网络需要明确的(输入, 标签)对:

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)
ys = torch.tensor(ys)

emma 产生 5 个样本:(., e)、(e, m)、(m, m)、(m, a)、(a, .),编号是 (0,5)、(5,13)、(13,13)、(13,1)、(1,0)。

和统计法遍历的是同一批 bigram,只是这次要显式存成两个张量。

tensor 和 Tensor 的坑:torch.tensor([1,2,3]) 是 int64,保持整数;torch.Tensor([1,2,3]) 是 float32,强制转浮点。这里必须用小写 —— 索引得是整数,浮点数没法当下标。

one-hot 与单层网络

xenc = F.one_hot(xs, num_classes=27).float() # (N, 27)
W = torch.randn((27, 27), generator=g)
logits = xenc @ W # (N,27) @ (27,27) = (N,27)

没有隐藏层、没有偏置、没有激活函数 —— 就一个矩阵乘法。这是最小的神经网络。形状上中间那个 27 消掉了。

W 的形状和统计法那张 27×27 的表尺寸相同 —— 这不是巧合。

两个必须写的参数:num_classes=27,不指定的话 PyTorch 按数据里的最大值推断;.float(),因为 one_hot 返回 int64,后面要和浮点权重做矩阵乘法。

另外 @ 是矩阵乘法(行乘列求和),* 是元素级相乘,两者完全不同。

one-hot 乘矩阵 ≡ 查表

xenc[0] 是 [1,0,0,…,0],和 W 相乘:

[1,0,0,…] @ W = 1×W第0行 + 0×W第1行 + … = W第0行

其余 26 行全被 0 乘掉了。 验证:torch.allclose(logits[0], W[xs[0]]) → True。

这就是 embedding 的动机。 词表 5 万时,one-hot 是 5 万维向量,矩阵乘法要算 5 万次乘法(其中 49999 次是乘 0)—— 而查表只要一次索引。这不是优化技巧,是必须的。

同样的道理在上一篇的交叉熵里已经出现过一次:probs[target] 等价于 one-hot 相乘再求和,只是省掉了 26 次乘 0。两处的本质是同一件事。

softmax:与统计法的结构对照

counts = logits.exp() # 第一步:变正数
probs = counts / counts.sum(1, keepdim=True) # 第二步:归一化

keepdim=True 又出现了 —— 和前面归一化 N 时是同一个道理。

把 counts 理解成「模型认为的计数」:

统计法神经网络
计数N(数出来的整数)logits.exp()(算出来的正数)
概率N / N.sum(1, keepdim)counts / counts.sum(1, keepdim)

归一化那一步完全相同,区别只在于计数从哪来。exp 的作用正是把任意实数变成合法的计数 —— 计数必须为正,而 logits 可正可负。

训练要做的事:调整 W,让「算出来的计数」逼近「数出来的计数」。

loss 怎么算:没有「预期概率」可比

真实答案不是一个概率分布,而是一个确定的字符。数据集里 emma 的 e 后面就是 m,不是「70% 可能是 m」。

所以比较方式不是「两个分布相减」,而是一次查表 + 一个对数:

y = ys[i] # 正确答案的索引,比如 13
p = probs[i, y] # 去那一行里把第 13 列取出来
loss = -torch.log(p)
真实值是什么怎么比
平方误差(V1)一个数相减再平方
交叉熵(V2)一个类别查出该类的概率,取 −log

数值感(假设正确答案是 m):

模型的输出分布probs[i,13]loss
平均分散 [0.037]*270.0373.30
押错了 a:0.9、m:0.010.014.61
押对了 m:0.90.900.11

中间那行最惨 —— 因为它自信地错了。这正是交叉熵的设计意图。

批量写法用高级索引,一次取出全部正确项:

loss = -probs[torch.arange(N), ys].log().mean()

torch.arange(N) 是 [0,1,2,…],和 ys 按位置配对,等价于 probs[0, ys[0]]、probs[1, ys[1]]…… 两个等长的索引张量按位配对,没有循环。

一个实用的健康检查:W 是随机初始化的,所以初始 loss 应该在 log(类别数) 附近,也就是 3.30(实际会略高,约 3.5—4,因为随机的 W 会产生一些无意义的偏好)。如果高得离谱,通常是初始化有问题 —— 权重太大,模型一开始就自信地胡说。

训练循环:和 V1 一模一样

for k in range(300):
xenc = F.one_hot(xs, num_classes=27).float()
logits = xenc @ W
counts = logits.exp()
probs = counts / counts.sum(1, keepdim=True)
loss = -probs[torch.arange(num), ys].log().mean()
W.grad = None # 清零
loss.backward()
W.data += -50 * W.grad # 更新
第六篇手写版PyTorch 版
for p in n.parameters(): p.grad = 0.0W.grad = None
loss.backward()loss.backward()
p.data += -0.05 * p.gradW.data += -50 * W.grad

三个细节:

  • requires_grad=True —— 默认张量不追踪梯度,得显式开启。这就是第四篇里手写的 _prev / _backward,PyTorch 内置了
  • W.grad = None 而非 = 0 —— PyTorch 里设成 None 更高效,反向时会直接赋值而不是累加
  • W.data 而非 W —— .data 绕过梯度追踪,不然这次更新本身也会被记进计算图

学习率为什么是 50(第六篇用的是 0.05,差了一千倍):梯度被 228146 个样本取了平均,摊薄了;而且单层线性的损失曲面平缓,大步子不会震荡。

两条路殊途同归

统计法神经网络
参数27×27 计数表 N27×27 权重 W
内容计数(非负整数)logits(任意实数)
怎么得到数出来梯度下降训练出来
变概率除以行和先 exp 再除以行和
loss2.4544约 2.46

还可以直接比概率表:

Pcount = (N + 1).float(); Pcount /= Pcount.sum(1, keepdim=True)
with torch.no_grad():
Pnet = (torch.eye(27) @ W).exp()
Pnet = Pnet / Pnet.sum(1, keepdim=True)
print('最大差异:', (Pcount - Pnet).abs().max().item())

torch.eye(27) 是单位矩阵,每一行都是一个 one-hot,一次性得到全部 27 行 logits。

这回答了「神经网络到底在学什么」:它在逼近数据的统计规律。

而且这里有个真实项目里极其罕见的好事:统计法给出了这个任务的理论最优值。神经网络追平了它,说明训练完全到位,不是「还能再练练」。

那为什么还要用神经网络

上下文表的格子数统计法
1 个字符27² = 729能数,627 种真的出现过
3 个字符27⁴ = 531,44122.8 万条数据都填不满
5 个字符27⁶ ≈ 3.87 亿大部分格子是 0
10 个字符27¹¹ ≈ 5.6×10¹⁵完全不可能

数据再多也填不满那张表。 没见过的组合概率是 0,模型认为「绝不可能」—— 毫无泛化能力。

神经网络用固定参数量处理任意上下文。这就是它的全部价值。

采样策略是可调的

神经网络给出 27 个概率,multinomial 按概率抽一个,两者合起来才得到下一个字符。模型说的是「看到 a 之后,n 有 20% 可能、. 有 18%」,不是「一定是 n」 —— 因为数据本身就是多样的。

生成结果:junide、janasah、prelay、kohin、tolian。和统计法生成的具体名字不同(概率表有小数点后的微小差异,抽签会把差异放大),但风格一致:长度分布相似、元音辅音交替、结尾都是常见后缀。

策略做法效果
greedy取最大确定、保守、易重复
temperaturelogits 除以 T 再 softmaxT 小→保守,T 大→发散
top-k只在前 k 个里抽排除低概率的怪词
top-p只在累积概率 p 内抽动态截断
Pᵢ = e^(zᵢ/T) / Σⱼ e^(zⱼ/T)

API 里那个 temperature 参数就是这个。 它改的是概率分布的形状,不是随机数本身。

模型的输出是分布,不是答案。 从分布到答案这一步叫采样,它是独立的、可调的。同一个模型配不同策略,行为可以差别很大。

smoothing 就是 L2 正则化

loss = -probs[torch.arange(num), ys].log().mean() + 0.01 * (W**2).mean()
# └────────── 数据项 ──────────┘ └─── 正则项 ───┘

两项方向相反:数据项要 W 尽量拟合数据,需要大权重来区分高低频;正则项要 W 尽量接近 0,不管数据长什么样。λ 决定谁的话语权更大。

为什么「W → 0」等于「分布均匀」:

W = 0 → logits = [0,…,0] → exp → [1,…,1] → 归一化 → [1/27, …]

完全均匀分布,什么都没学到。 所以:

极端情况结果
smoothingk → ∞计数全被垫平 → 均匀
L2 正则λ → ∞W 全变 0 → 均匀

两者都是把分布往均匀方向拉,只是操作对象不同 —— 一个拉计数,一个拉权重。

从梯度上看更清楚。正则项对 W 的偏导是 2λ/729 · W,所以更新时相当于每轮把 W 乘上一个略小于 1 的数。这个操作叫 weight decay(权重衰减),PyTorch 优化器里有同名参数。

它是无条件的:不管数据说什么,每轮都往 0 拉一点。只有反复出现的规律能顶住这个拉力,偶然的噪声顶不住 —— 这就是它提升泛化的机制。

什么叫「训练到位」

loss = 0 在语言模型里既不可能也不该追求 —— 它意味着每个位置都 100% 确定,而 a 后面本来就可能是 n、.、r。这是数据的内在不确定性,不是模型缺陷。

三个判据:低于随机基线 3.30,说明至少学到了东西;接近同类模型的已知水平,bigram 的天花板就是 2.45;loss 曲线压平,再训 1000 轮也不动。

更重要的是验证集 —— 验证 loss 触底反弹的那一刻就该停。bigram 这里不用担心:参数只有 729 个而数据有 228146 个,模型没能力背下来。但往后参数会暴涨,这个判据就必须用上。


同一个 bigram 模型,数频次得到 2.4544,梯度下降得到 2.46。两条完全不同的路,几乎相同的终点 —— 神经网络不是魔法,它在逼近数据的统计规律。它的价值不在于「更准」,而在于「能扩展」。

V2 到此完结。新掌握的工具:张量与广播、keepdim 的坑、高级索引、multinomial 采样、softmax、交叉熵,以及 PyTorch 的 requires_grad / backward / .data。

下一步是 V3:上下文从 1 个字符扩到 3 个,loss 会从 2.45 降到约 2.2。会正式引入 embedding(今天那个「乘法等价于查表」的直接应用)、隐藏层、mini-batch 训练,以及训练 / 验证 / 测试集划分。