Skip to content

第5章 · 字符语言模型与概率目标 ​

前置要求:掌握 第4章 · 自动微分与计算图 的标量梯度与 Loss 概念,以及基础数组运算直觉。

第4章你手写了自动微分引擎,但它还空转着——没有真实任务。本章给它接上第一个任务:看前面的字符,猜下一个字符。你会用两条由简到繁的路线做这件事:先是一张纯计数表(Bigram),再是一个可学习的神经网络(MLP)。这两条路线就是全部大语言模型的雏形。

本章目标 ​

学完后你能做到:

  1. 从计数 Bigram 过渡到神经 Bigram 和上下文 MLP,说清各自的表达能力和短板。
  2. 从最大似然推导 NLL/交叉熵,区分 logits、概率、目标 token 和采样。
  3. 在 train/validation 分离的数据上证明参数真的更新了,并解释过拟合、temperature、top-k 的影响。

阶段一:语言模型在做什么 ​

语言模型只做一件事:给定前面的内容,给"下一个 token 是什么"打出概率分布。整段文本的概率被拆解成一步步的条件概率连乘:

P(x1:T)=∏t=1TP(xt∣x<t)

读法:一段话出现的概率 = 第一个词的概率 × 知道第一个词后第二个词的概率 × …… 一直乘到最后。这样,"生成一段话"就变成了重复回答同一个问题:下一个是什么?

链式连乘:P(我爱吃) = 0.30×0.50×0.80 = 0.12;每一步模型给出的都是整个词表上的概率分布

阶段二:Bigram——一张计数表就是模型 ​

最朴素的语言模型不看公式,只数次数。假设训练语料只有三个词:ab、ab、ac。

统计"每个字符后面跟了什么":

当前字符下一个字符次数
ab2
ac1

于是 P(b∣a)=23≈0.67,P(c∣a)=13≈0.33。这就是第3章 mle_bernoulli 的推广:条件概率 = 该搭配出现的次数 ÷ 当前字符出现的总次数,它就是最大似然估计(MLE)。

生成时,从当前字符出发,按这一行的概率随机抽下一个字符,循环往复——这就是"采样",第3章的 categorical_sample(probs, rng) 就是干这个的:先归一化,再按累积分布抽样。

Karpathy 的顿悟时刻:计数表 vs 神经网络的等价性 ​

计数表的表达能力受限于其记录过的精确搭配:未见过的上下文组合要么概率为 0,要么依赖平滑项硬凑,无法泛化到新序列。

看 Karpathy 在 makemore 1 中揭示的深刻洞察:

  1. 统计路线:开辟一个 (V,V) 的计数矩阵 N,统计每对字符共现次数,按行归一化得到概率表 P。
  2. 神经路线:开辟一个 (V,V) 的可学习权重矩阵 W。用字符索引 i 直接取权重第 i 行(W[i],等价于 one-hot 查表)作为 Logits(未归一化的实值分数,softmax 将其映射为概率分布),经过 Softmax 得到预测概率,计算交叉熵损失,用梯度下降更新 W。

当你把神经 Bigram 训练收敛后,经过 Softmax 后的权重矩阵 softmax(W) 会逼近计数表的条件概率;但计数表默认使用 Laplace 平滑(alpha=1.0,见 python/llm_core/bigram.py:28),神经版本没有自带这项正则化,因此两者只有在神经版本也加入匹配的平滑项时才会完全重合。

text
[纯统计] 数据集词频统计 N ─────────> 归一化概率矩阵 P
                                            ▲
                                            │ 完全等价!
                                            ▼
[神经网络] One-Hot @ W ──> Softmax ──> 梯度下降收敛后的 Softmax(W)

在充分收敛、正则化方向一致且数据非稀疏的前提下,神经网络的梯度下降本质上是在逼近真实数据的最优统计规律。

但 Bigram 的短板很明显:它只看一个字符的上下文,a 后面该跟什么,和它出现在词首还是词中毫无关系。如果想看前 3 个字符,计数表的大小会膨胀到 V3;想看前 1000 个字符,全世界的硬盘都装不下一张表!要看得更远、且不让参数量爆炸,就必须把"离散查表"升级成"在连续向量空间里泛化"的神经网络(Bengio 2003 MLP 与 Transformer)。

阶段三:交叉熵——给模型的预测打分 ​

训练需要一个分数来衡量"模型猜得多好"。答案来自第3章的自信息:用 −log⁡p 给预测打分——模型给正确字符的概率越小,惩罚越重。

手算一遍:正确字符是 b。

  • 模型给出 P(b)=0.8:loss =−ln⁡0.8≈0.223(猜得不错,罚得轻)
  • 模型给出 P(b)=0.1:loss =−ln⁡0.1≈2.303(猜得很差,罚得重)

对一批 token 取平均,就是 NLL(负对数似然):

L=−1N∑i=1Nlog⁡pi,yi

其中 pi,yi 是模型给第 i 个位置正确 token 的概率。

和第3章信息论的关系:交叉熵 H(P,Q)=H(P)+DKL(P∥Q)。训练标签是 one-hot(正确词概率为 1),此时 H(P)=0,所以最小化交叉熵 = 最小化 KL 散度 = 让模型输出逼近标签。one-hot 标签下 CE 就退化成 −log⁡(正确词概率)——也就是上面手算的那一行。

底数提醒:手算用 log2 是为了对齐"比特"直觉(−log2⁡0.8≈0.322),代码实现用自然对数 np.log。两者只差常数 ln⁡2,数字对不上时先查底数再查 bug。

前端类比:cross_entropy([1,0], [0.8,0.2]) ≈ 0.322 就像组件的期望行为是 [1,0](100% 选 A),但实际输出 [0.8, 0.2]——CE 衡量"期望 vs 实际"的信息差距。

初始损失基线定理(Initial Loss Sanity Check) ​

这是每一个大模型工程师在写完模型架构后的第一道必查本能:

当网络刚刚初始化完毕、参数还是完全随机值时,模型对下一个词的预测应当是毫无偏见的均匀猜测。假设词表大小为 V(比如字符集大小为 27),模型猜测每个词的概率都是 1V。

那么,模型在第 0 步的理论交叉熵损失必须精确等于:

Linit=−ln⁡(1V)=ln⁡(V)
  • 字符级小实验(V=27):Linit=ln⁡(27)≈3.295;
  • 第9章 TinyGPT 诊断配置(硬编码 V=65):Linit=ln⁡(65)≈4.174,对应该章打印的初始 Loss 4.1742;
  • GPT-2 工业级 BPE 词表(V=50,257):Linit=ln⁡(50257)≈10.825。

工程实战第一反射: 如果你初始化了一个词表为 65 的模型,运行第 0 步计算出来的 loss 是 16.5 或 0.2,请立即按 Ctrl+C 中断!

  • 若初始 loss 远大于 ln⁡(V):说明权重初始化方差过大(某些 logit 初始就高达十几,softmax 极度极化,猜错被重罚);
  • 若初始 loss 远小于 ln⁡(V):说明 target 与 input 没有严格 shift 错位(模型直接抄到了当前输入),发生了数据泄漏!

阶段四:MLP——把计数表升级成可学习的参数 ​

神经语言模型把"查计数表"换成"查参数表 + 小网络":

对象shape说明
token ids(B, T)整数,范围 [0, V)
embedding table(V, D)只按 token id 查行
hidden/context(B, T, D) 或 (B, D)context 长度必须固定或显式 padding
logits(B, T, V)最后一维是词表
targets(B, T)与 logits 的时间位置对齐
loss()reduction 规则要写入 evidence

它比计数表强在哪:embedding 让"相似的字符"自动获得相似的向量,于是模型对没见过的上下文也能给出合理猜测——这就是泛化,是神经网络取代计数表的根本原因。

经典论文连线:Bengio 2003 与“连续词嵌入”革命 ​

在 2003 年图灵奖得主 Yoshua Bengio 发表《A Neural Probabilistic Language Model》之前,整个 NLP 界都被**维度灾难(Curse of Dimensionality)**卡得动弹不得:

  • 如果词表有 V=10,000 个词,想要看前 3 个词预测第 4 个词(4-gram),离散计数表需要 10,0003=1012(一万亿!)个单元格。训练集再大,绝大多数组合的频次也都是 0。
  • 更致命的是缺乏语义泛化:计数表认为“猫”和“狗”是两个完全不相干的离散符号。哪怕模型在语料里见过一万次“这只狗在草地上跑”,当下一次遇到“这只猫在草地上跑”时,离散表依然两眼一抹黑。

Bengio 提出了一个改变 AI 历史的想法:给每个词分配一个低维连续实数向量(Embedding 嵌入,比如 64 维)。其核心张量计算流水线如下:

  1. 查表拼接(Embedding Lookup & Concat):给定前 n−1 个上下文词索引 wt−n+1,…,wt−1,通过共享嵌入矩阵 C∈RV×D 查出对应向量并拼接:x=[C(wt−n+1);C(wt−n+2);…;C(wt−1)]∈R(n−1)D
  2. 非线性隐藏层(Hidden Projection):h=tanh⁡(W1x+b1),W1∈RH×(n−1)D,b1∈RH
  3. 词表 Logits 输出(Output Projection):z=W2h+b2,W2∈RV×H,b2∈RV
  4. Softmax 概率分布与交叉熵:P(wt∣wt−n+1:t−1)=softmax(z)wt

参数量级对比:

  • 离散 N-gram 计数表:参数量为 O(Vn)。当 V=10,000、n=4 时需要 1012 空间,组合爆炸;
  • Bengio MLP:参数量为 O(V⋅D+(n−1)D⋅H+H⋅V)。参数规模随词表大小 V 和上下文长度 n 是线性增长的,彻底打破了维度诅咒。

工程与代码直觉: 在 TypeScript / Python 中,Embedding 矩阵 C 表面上看仅仅是一个二维数组查表 C[id]。但因为 C 接入了第4章手写的计算图,反向传播会计算梯度 ∂L∂C[id]。当“猫”和“狗”频繁出现在“在草地上跑”、“喝牛奶”等相似上下文中时,损失函数会驱动两者的向量在连续空间里自然靠拢(余弦相似度极高)。模型学会“狗在草地上跑”后,几何上的平滑性让它能举一反三泛化到“猫在草地上跑”。

这个小小的结构,就是今天从 Word2Vec、FastText 到 GPT、LLaMA、DeepSeek 的统一地基。

训练闭环就是第3章的五行循环:前向算 logits → 交叉熵算 loss → 第4章的 backward() 求梯度 → 梯度下降更新参数 → 清零梯度再来一轮。

时间切分的必要性:语言模型的数据是时间序列,随机打乱会把未来 token 泄露到验证集,使 val loss 失去意义——模型在训练时"见过未来",验证损失会虚低。python/llm_core/mlp_lm.py 的 train_mlp_lm_with_validation 按文本顺序截断,保证训练窗口和验证窗口不重叠。

文本生成与采样三剑客:Temperature、Top-K 与 Top-p ​

大模型生成文本时不是机械地挑最高分,而是从概率分布中随机抽样。业界通用的采样三剑客正是控制“胡言乱语”与“死板复读”的调音旋钮:

  1. Temperature(温度):

    pi=softmax(z/T)i

    T 是温度。以 logits [2,1,0] 为例:

    T概率分布(约)效果
    0.5[0.87, 0.12, 0.02]低温:分布尖锐,几乎总选最强候选,适合代码与算术
    1.0[0.67, 0.24, 0.09]常温:模型天然分布
    2.0[0.51, 0.31, 0.19]高温:分布平缓,更富创造力,但过高容易胡言乱语
  2. Top-K 采样: 只保留概率最高的 K 个候选词,其余所有词的 Logits 强行置为 −∞(概率清零),再在剩余的 K 个词中重新归一化采样。

    • 例如 K=50:彻底防止模型由于极低概率的噪音词(比如长尾罕见错别字)导致语句崩溃。
  3. Top-p(Nucleus / 核采样): 按概率从大到小排列,累加概率直到刚好超过阈值 p(例如 p=0.9),只在这前 90% 概率质量的头部候选集中采样。

    • 相比于固定的 K,Top-p 是自适应的:当模型很笃定(前两项概率已达 95%)时只在 2 个词里选;当上下文很模糊时候选集自动扩大,比 Top-K 更加自然。

交叉熵的和式只累加标签 token 的 −log⁡p。偏好、事实性、安全性和榜单分都不在这个和式里。上面三个旋钮也不在里面,它们改的是三个不同的集合:temperature 缩放 softmax 之前的整条 logits,不按名次删 token;top-k 留下概率最高的固定 k 个;top-p 留下累计概率刚达到 p 的最短那一段,候选个数随分布变尖或变平。三者都不改已经训好的权重。开放式长文本若仍按最大似然来解,每步只取概率最高的 token,Holtzman 等(2019)观察到文本平淡,并出现异常重复。那是解码目标和训练目标不同,不是再训一个模型。

注意 T 是除 logits 不是乘——这是本章故障表里的经典错误。top_k 是另一个旋钮:只在概率最高的 k 个候选里采样。

交互:Softmax + Temperature

把 logits 看成「未归一化分数」。Temperature 越高分布越平,越低越尖。

tok0
8.5%
tok1
23.2%
tok2
5.2%
tok3
63.1%

固定一组 logits,分别观察低温、常温和高温下分布形状的变化。

Greedy ≠ MAP:每步取最大,不等于整句最优 ​

把"每个位置挑条件概率最高的 token"(greedy 解码,温度趋近 0 的极限)和"挑整句联合概率最高的完整序列"(MAP 解码)当成同一件事,是解码环节的常见误解。一个只生成 3 个 token 的续写就能把两者拆开(示例构造化用自 Ernest Ryu, RL of LLMs (Spring 2025) Chapter 2 的 greedy≠MAP 双路树图,数值为本教程自编)。

设前缀是"深夜,她听到楼上传来",模型下一步给出两个头部候选,各自往后走两步:

路径第 1 步第 2 步第 3 步整句联合概率
A:"了 一声 叹息""了" 0.45"一声" 0.30"叹息" 0.200.45×0.30×0.20=0.027
B:"脚步声 , 很轻""脚步声" 0.40"," 0.85"很轻" 0.900.40×0.85×0.90=0.306

表中每格是"给定前缀,下一个 token 是它"的条件概率;路径 A 的第 2、3 步也都取的是该前缀下概率最高的候选。

greedy 在第 1 步比较 0.45>0.40,选"了",从此只能走在路径 A 上——后面两步它每次都取了当前前缀下的最大值,最后拿到联合概率 0.027 的"传来了一声叹息"。而它在第 1 步以 0.05 之差放走的"脚步声",后续每步条件概率都很高,联合概率 0.306,才是这条前缀下模型真正最看好的完整续写。逐步最优的选择在第 1 步就锁死了更优的整条路径;反过来,想直接找联合概率最大的序列,要在"词表大小的序列长度次幂"条路径里穷举,代价随长度指数增长,实践中没有人真去解精确 MAP。

这正是"解码是逐 token 的、而质量是整句的"的结构性错位。回到上面三个旋钮:temperature、top-k、top-p 全都只作用在当前这一步的条件分布上,没有任何一个能回头修改已经生成的 token(三旋钮速查表见参考页)。整句层面的纠偏要么靠保留多条部分路径的搜索,要么等到第 11 章在序列级打分的后训练。

阶段五:动手实验 ​

目标:让两条路线都真实跑通——Bigram 计数矩阵能采样出像样的"人名",MLP 的交叉熵 loss 在受控数据上真实下降。

环境准备 ​

bash
cd <仓库根>
export PYTHONPATH="$PWD/python"

步骤 ​

  1. 在小语料上统计 Bigram 计数和条件概率,并明确"没见过的搭配怎么办"的策略。

  2. 实现神经 Bigram/MLP:embedding → hidden → logits → CE;先过拟合一个极小 batch,再观察 held-out loss。

  3. 比较 T=0.2/1.0/2.0 和固定 top_k 的样本变化;seed 只能约束本地采样,不能把随机输出当事实。

  4. 跑判分与总览:

    bash
    python -m pytest python/tests/test_bigram_mlp.py -q
    python -m labs.run_all
    text
    bigram anna
    mlp losses 3.2xx -> 2.1xx
    
    # 判定条件:
    # - final loss 明显小于 initial loss(数值示例:3.2xx -> 2.1xx)
    # - bigram 采样输出在训练词表中存在或形态相近
    # - 训练 seed 固定后,两次运行的首末 loss 一致

当前 bigram.py、mlp_lm.py 已提供可复现的 temperature 分布、参数更新、时间切分和 held-out loss fixture;evidence/04-runtime-v1.json 记录本地实测值。

概念图 ​

概念路线 · CONCEPT ROADMAP

第5章核心

forward → backward → 采样
  1. 01

    字符序列(B, T)

    原始语料的 token ids,整数范围 [0, V)

  2. 02

    切分为输入/输出对

    target 右移一位,模型学的是"预测下一个字符"

  3. 03

    Embedding 查找表(V, D)

    只按 token id 查行

  4. 04

    隐藏层激活MLP tanh

    把 embedding 组合成上下文向量

  5. 05

    输出层logits (B, T, V)

    最后一维是词表

  6. 06

    log_softmax

    数值稳定的 log 概率

  7. 07

    交叉熵 lossNLL

    per-token 平均,惩罚低概率目标

  8. 08

    反向传播,更新权重

    沿计算图回传梯度

  9. 09

    采样生成temperature

    先除 T 再 softmax,控制随机性

自上而下即学习顺序:字符 → embedding → MLP 隐藏层 → logits → CE loss → backward → 采样生成。第6章用 BPE 把字符升级为子词,第7章引入 SGD/AdamW 优化器,第8章把 MLP 隐藏层替换为 Transformer FFN + Attention。

故障注入与预期信号 ​

故障预期失败信号修复后证据
把 softmax 作用在 batch 维每个 token 的概率和不为 1最后一维逐行和为 1
target 未右移模型学成复制当前 tokenshifted target 检查和受控 loss 下降
temperature 乘而非除 logits低温反而更随机,概率尖锐度反转固定 logits 的单元测试通过
训练集和验证集用同一份数据val loss 看起来在降,其实只是复读训练集严格切分 train/val,val loss 曲线独立可解释
采样温度设为 0永远输出同一个字符,看起来像"训练失败"温度大于 0,且与训练温度一致时质量更稳
采样温度非常大输出完全乱码把温度夹在 [0.5, 1.5] 区间,超出则告警
对 logits 直接 softmax 而没有 log-softmaxloss 数值变成负数或 NaN用 log_softmax,或 softmax 后显式 log 并加 nan_to_num
训练没有固定 seed同一脚本两次跑出的 loss 不同入口处同时固定 random.seed 与 numpy.random.seed

本章验收 ​

  1. 自查清单全部能答"是":
  1. 不看资料,完成这五道闭卷解释题:
  • 从自回归联合概率分解推到平均 NLL,说明为什么训练中把它写成 cross entropy。
  • 用 cross_entropy([1,0], [0.8,0.2]) ≈ 0.322 解释为什么 one-hot 标签下 CE 等于 −log⁡(正确词概率)。这个和式优化的是标签 token 的 NLL。temperature 缩放 softmax 前的 logits,top-k 截固定个数,top-p 截累计概率质量;三者都不进入训练损失,也不改权重。
  • greedy 解码为什么通常拿不到联合概率最高的序列?构造一个两步反例说明"逐步条件概率最大"不等于"联合概率乘积最大",并解释 temperature/top-k/top-p 为什么都改不了这个错位。
  • 口算 Bigram 的 MLE:训练集中 "th" 出现 50 次、"t" 出现 200 次,P(h∣t) 是多少?这和第3章 mle_bernoulli 有什么异同?
  • 比较 Bigram 与上下文 MLP:各自能表达什么、会在哪种 held-out 现象上失败。
  1. 通过条件复核:参数确实更新、held-out loss 下降可复现、采样参数已记录。缺少 held-out 指标、seed 或参数更新证据时,本章保持 gate。

论文与延伸 ​

实验与参考 ​

前端/Agent 迁移 ​

把 logits 看作候选动作的未归一化评分,采样是策略层——模型输出的是条件分布,不是事实数据库。到第15章做 Agent 时,要把"模型建议"与"系统允许的动作"分离,后者仍需 schema、状态和结果校验。

资源 / 成本 / 隐私 ​

本地 CPU/NumPy 即可,固定小语料不需要云资源;预计 gross cost 为 0。语料使用合成或已确认公开许可内容,输出不得包含个人经历原文。

Evidence ​

仓库当前机器证据(只读快照) ​

evidence/module-manifest-v1.json 中 04.evidence 指向当前文件:evidence/04-runtime-v1.json。这是当前 checkout 的脱敏机器运行记录,只覆盖该 JSON 记录的命令、指标、产物和已知失败;它不是学习者提交,也不能推出学习者已完成本章。

学习者提交模板(待填写,不是当前机器证据) ​

复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actual 和 artifacts 尤其不能被当作已运行或已通过。artifacts 必须替换为本次提交中真实存在的仓库相对路径。

yaml
schema: learn-llm.evidence.v1
module: 04-language-model
commit: <learner-commit-sha>
verified_at: <iso-date>
environment: <sanitized-python-device>
seed: 2
commands:
  - PYTHONPATH=python python -m pytest python/tests/test_bigram_mlp.py -q
  - PYTHONPATH=python python <learner-controlled-language-model-run>
metrics:
  - name: parameter_update_norm
    expected: '>0'
    actual: <recorded-value>
  - name: held_out_loss_delta
    expected: <versioned-threshold>
    actual: <recorded-value>
artifacts:
  - <learner-repo-relative-artifact-path>
cost:
  gross_usd: 0
  credit_usd: 0
licenses:
  - source: <source>
    version: <version>
    license: <license>
    attribution: <attribution>
    redistribution: <redistribution>
known_failures:
  - <sanitized-failure-or-none>

下一步 ​

进入 第6章 · UTF-8、byte BPE 与数据管线:把「字符」升级成可逆的子词 token。

私有学习站 · 原理从零构建 · 勿提交个人隐私或密钥