系列 《算法工程师的数学:读公式不卡壳的最小集》 第 5 / 9 篇
训练日志里跳动的那个数字——loss 从 11 降到 2 点几——是什么?它从哪来?为什么是那个公式?这一篇给出完整的答案:从上一篇”语言模型是条件分布”的定义出发,用一个自然的原则(让训练数据出现的概率最大),三步推出它。这三步是后面所有 loss 的模板:SFT、奖励模型、DPO 都是同一个式子换一个概率。然后讲模型是怎么把一堆实数变成概率的(softmax),以及温度、top-k、top-p 在这个分布上做了什么。
这一篇是全系列最重要的一篇之一:loss 是训练的全部驱动力——模型的每一次参数更新,都是朝着”让这个数变小”的方向走的。不理解它,就看不懂训练曲线、看不懂 SFT 为什么要 mask、看不懂奖励模型和 DPO 的公式为什么长那样。所以本文不吝篇幅:每一步都有一个能手算的例子,文中所有数字与图都由配套脚本 math-for-ai/05_mle_to_cross_entropy.py 跑出(输出在 expected/)。
全篇的核心问题是:
一、总览
1. 本文的路线
| 步 | 问题 | 回答 | 章 |
|---|---|---|---|
| 起点 | 模型有一堆参数 \(\theta\),训练数据摆在面前,\(\theta\) 该取什么? | 最大似然:取让训练数据出现概率最大的那个 | 二 |
| 对象 | 语言模型的”训练数据出现的概率”是什么 | 逐 token 条件概率的乘积(上一篇的链式法则) | 三 |
| 推导 | 从”概率最大”到训练日志里那个 loss | 取对数 → 取负 → 除以 token 数,得到每 token 负对数似然 \(-\frac{1}{T}\sum_t \log p_\theta(x_t \mid x_{<t})\),别名交叉熵 | 四 |
| 推广 | SFT、奖励模型、DPO 的 loss 从哪来 | 同一个 \(-\log\),换里面的概率 | 五 |
| 检查 | 训练第一步 loss 应该是多少 | \(\ln V\);偏离说明有 bug | 六 |
| 机制 | 模型输出的是实数,概率从哪来 | softmax;减最大值防溢出 | 七 |
| 使用 | 推理时温度、top-k、top-p 做了什么 | 在 softmax 分布上变形与截断 | 八 |
| 案例 | 拿到一条真实训练日志怎么读 | PPL、bits/char、train–val 差距 | 九 |
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 先从一个硬币说起 |
|
| 三 | 语言模型是一个会看上下文的骰子 |
|
| 四 | 三步推出 loss |
|
| 五 | 同一个模板 | SFT 的 loss mask(画出来)、奖励模型(曲线)、DPO:换一个概率 |
| 六 | 一个立刻能用的数字 | 初始 loss ≈ \(\ln V\):nanoGPT 上的实证;三种 bug 各长什么样 |
| 七 | softmax |
|
| 八 | 温度与采样 |
|
| 九 | 案例:读懂一条训练日志 |
|
| 十 | 本文小结 | |
| 十一 | 自测 | 七道题 |
二、先从一个硬币说起
1. 手里只有数据,不知道参数
一枚硬币,不知道它正面朝上的概率 \(p\)——可能是公平的 0.5,也可能被做过手脚。你抛了 10 次,得到 7 次正面、3 次反面。问:\(p\) 是多少?
直觉答案是 0.7。但”为什么是 0.7”值得想清楚,因为训练一个语言模型就是在做同一件事:模型有一堆参数(Transformer 的全部权重),训练数据摆在面前,参数该取什么值?硬币只有一个参数 \(p\),Llama-3 有 4050 亿个,原则是一样的。
2. 似然:换一个角度看概率
把问题反过来问:如果 \(p\) 是某个值,”7 正 3 反”这个结果出现的概率是多少? 每次抛硬币独立,正面概率 \(p\)、反面 \(1 - p\),所以
\[L(p) = p^7 (1 - p)^3\]这个函数叫似然函数(likelihood)。它的输入是参数,输出是”在这个参数下,手里这份数据出现的概率”。代几个值:
| 猜 \(p\) = | 0.3 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 |
|---|---|---|---|---|---|---|
| \(L(p) = p^7(1-p)^3\) | 0.000075 | 0.000977 | 0.001792 | 0.002223 | 0.001678 | 0.000478 |
\(p = 0.7\) 让这份数据”最可能出现”。把 \(p\) 从 0 到 1 扫一遍画出来:
最大似然估计(Maximum Likelihood Estimation,MLE)就是这条原则:在所有可能的参数里,选让手头数据出现概率最大的那个。 它的直觉是:数据是真实世界给你的样本;一个好的参数应该觉得”这些样本很正常、很可能出现”,而不是”这些样本几乎不可能”。硬币的例子里,\(p = 0.7\) 的答案与直觉一致,但现在它有了一个可以推广到 4050 亿参数的理由。
两个词的关系:似然与概率是同一个数,看待角度不同——固定参数看数据,叫概率(”\(p = 0.7\) 时 7 正 3 反的概率”);固定数据看参数,叫似然(”7 正 3 反时,\(p = 0.7\) 的似然”)。训练时数据是固定的,我们在参数上做选择,所以说的是似然。
3. 为什么取对数
上图右边是 \(\log L(p)\)。曲线形状变了,但最高点还在 0.7——因为对数是单调递增的:\(a > b\) 则 \(\log a > \log b\),谁最大不会变。既然不影响答案,为什么要多做一步?两个原因。
第一,乘积变求和。 \(\log(p^7(1-p)^3) = 7\log p + 3\log(1-p)\)——一堆乘法变成一堆加法,求导、求和、逐项分析都容易得多。
第二,数值。 训练集不是 10 次抛硬币,而是几万亿个 token,每个的概率都小于 1,乘起来是一个小到计算机存不下的数。用 Python 试一下 1000 个 0.01 相乘(配套脚本 underflow 段):
直接相乘(float64):0.0 ← 已经是 0,信息全丢
log 求和:1000 × log(0.01) = −4605.2 ← 正常的数
float64 能表示的最小正数约 \(2.2 \times 10^{-308}\),而 \(0.01^{1000} = 10^{-2000}\) 远小于它,直接算就”下溢”成 0——所有候选参数的似然都是 0,比不出高低。取了对数,\(-4605\) 是一个普通的数。所以实践中总是处理对数似然(log-likelihood),从来不碰似然本身。
三、语言模型是一个会看上下文的骰子
1. 三个 token 的玩具模型
硬币只有正反两面、每次抛不看历史。语言模型是它的加强版:词表有 \(V\) 面(Llama-3 是 128,256 面),而且每一步的概率取决于前面已经出现的 token。上一篇把它写成 \(p_\theta(x_t \mid x_{<t})\):给定前文,下一个 token 的分布。
用一个能手算的规模看清楚:词表只有三个 token a b c,模型只看上一个 token(这叫二元模型,bigram),于是”参数 \(\theta\)“就是一张 3 × 3 的表——行是上一个 token,列是下一个 token 的概率。两个候选参数:
| \(\theta_1\):上一个 → 下一个 | a | b | c |
|---|---|---|---|
| a 后面 | 0.1 | 0.8 | 0.1 |
| b 后面 | 0.7 | 0.1 | 0.2 |
| c 后面 | 0.6 | 0.2 | 0.2 |
| \(\theta_2\):上一个 → 下一个 | a | b | c |
|---|---|---|---|
| a 后面 | 0.4 | 0.3 | 0.3 |
| b 后面 | 0.3 | 0.4 | 0.3 |
| c 后面 | 0.3 | 0.3 | 0.4 |
2. 一句话的概率
训练数据是一句话:a b a c a b。它在某个 \(\theta\) 下”出现的概率”是多少?上一篇的链式法则:整句的概率 = 第一个 token 的概率 × 第二个在第一个之后的概率 × 第三个在前两个之后的概率 × ……(第一个 token 没有前文,这里略去不算)。每个位置查一次表,把查到的数乘起来:
| \(p(b \mid a)\) | \(p(a \mid b)\) | \(p(c \mid a)\) | \(p(a \mid c)\) | \(p(b \mid a)\) | 乘积(似然) | log 之和 | 每 token 平均 \(-\log\) | |
|---|---|---|---|---|---|---|---|---|
| \(\theta_1\) | 0.8 | 0.7 | 0.1 | 0.6 | 0.8 | 0.02688 | −3.62 | 0.72 |
| \(\theta_2\) | 0.3 | 0.3 | 0.3 | 0.3 | 0.3 | 0.00243 | −6.02 | 1.20 |
\(\theta_1\) 让这句话出现的概率是 \(\theta_2\) 的 11 倍。按最大似然的原则,选 \(\theta_1\)。注意最后一列:每个 token 平均的 \(-\log p\),\(\theta_1\) 是 0.72、\(\theta_2\) 是 1.20——这个数就是训练日志里的 loss,下一章推出来。
从 3 面到 128K 面、从”只看上一个 token”到”看前面几千个 token”、从一张表到 4050 亿个权重——变的只是规模,”每个位置查一个概率、乘起来、选让乘积最大的参数”这件事完全不变。Transformer 的全部结构(《Transformer 与 LLM》系列)只是一个把”前文”变成”下一个 token 的概率表”的复杂函数。
四、三步推出 loss
1. 推导
现在正式写。模型参数 \(\theta\),训练集是 \(N\) 条序列 \(x^{(1)}, \dots, x^{(N)}\)。最大似然说:
\[\begin{aligned} \hat\theta &= \arg\max_\theta \prod_{i} p_\theta\big(x^{(i)}\big) && \text{(最大似然:让训练集出现的概率最大)} \\ &= \arg\max_\theta \sum_{i} \log p_\theta\big(x^{(i)}\big) && \text{(第一步:取对数,乘积变求和,最大值位置不变)} \\ &= \arg\max_\theta \sum_{i} \sum_{t} \log p_\theta\big(x^{(i)}_t \mid x^{(i)}_{<t}\big) && \text{(链式法则:一句话的 log 概率是逐 token 之和)} \\ &= \arg\min_\theta \left[ -\sum_{i} \sum_{t} \log p_\theta\big(x^{(i)}_t \mid x^{(i)}_{<t}\big) \right] && \text{(第二步:取负,最大化变最小化)} \end{aligned}\]取负是习惯——优化器都是往”小”的方向走的,所以把要最大化的东西加个负号变成要最小化的。括号里的量再除以总 token 数 \(T_{\text{total}}\)(第三步,让它不随数据量变化——否则数据翻倍 loss 翻倍,不同实验没法比),就是训练日志里的 loss:
\[\mathcal{L}(\theta) = -\frac{1}{T_{\text{total}}} \sum_{i, t} \log p_\theta\big(x^{(i)}_t \mid x^{(i)}_{<t}\big)\]——每个 token 的负对数似然(negative log-likelihood,NLL)的平均。上一章表格的最后一列算的正是它:\(\theta_1\) 是 \(3.62 / 5 = 0.72\)。
2. 逐字读它:\(-\log p\) 的形状
对每个位置,模型给真实的下一个 token 分配了概率 \(p\),这个位置的 loss 是 \(-\log p\):
读这条曲线:
- \(p = 1\)(完全猜对、把全部概率押在正确答案上)时 loss 是 0——没有惩罚;
- \(p = 0.5\) 时 0.69,\(p = 0.1\) 时 2.3——给真实答案的概率越低惩罚越大;
- \(p \to 0\) 时 loss 冲向无穷——”认为真实答案不可能出现”是最重的罪。
可以把它理解成”模型被真实答案惊到了多少“:给了 90% 把握的事发生了,不怎么惊讶(0.11);只给了 1% 的事发生了,非常惊讶(4.6)。训练 loss 是所有位置这个惊讶程度的平均。这条曲线的形状还决定了梯度的行为——第七篇会推出:loss 对 logits 的梯度是 \(p - y\),猜得越准更新越小。
3. 单位:nat 与 bit
对数取自然对数 \(\ln\) 时单位是 nat,取 \(\log_2\) 时是 bit,1 nat \(= 1/\ln 2 \approx 1.44\) bit。bit 有一个直观的读法——猜数字游戏:从 8 个等可能的选项里猜一个,每问一个”是不是在这一半”的问题排除一半,要问 3 次,\(\log_2 8 = 3\) bit。所以”这个位置的 loss 是 3 bit”等于说”模型在这个位置像是在 8 个等可能的选项里猜”。
训练日志里的 loss 是 nat;论文里的 “bits per byte” 是 bit 再除以字节数(跨 tokenizer 比较时用,预训练系列第二篇)。换算:loss 1.8 nat = 2.6 bit/token。
4. 为什么叫交叉熵
同一个量还有另一个名字:交叉熵(cross-entropy)。下一篇会给出信息论的定义:用分布 \(q\) 去编码来自分布 \(p\) 的数据,平均要花的码长是
\[H(p, q) = -\sum_x p(x)\log q(x)\]训练时,”真实分布” \(p\) 是 one-hot——真实的下一个 token 概率为 1、其余全为 0;\(q\) 是模型的输出分布。于是求和里只剩真实 token 那一项:\(H(p, q) = -1 \cdot \log q(x_t) = -\log q(x_t)\)——正是上面的负对数似然。所以”最小化交叉熵”与”最大似然”是同一件事的两个名字,一个来自统计(让数据最可能),一个来自信息论(让编码最短)。PyTorch 里的 F.cross_entropy 算的就是它;第七章会手算一遍与它对拍。
五、同一个模板
第四章那三行是后面所有 loss 的模板,区别只在”把哪个概率放进 \(-\log\)“:
| 任务 | 放进 \(-\log\) 的概率 | 说明 |
|---|---|---|
| 预训练 | \(p_\theta(x_t \mid x_{<t})\),对全部 token 求和 | 上面的推导 |
| SFT(监督微调) | 同上,但只对回答部分求和 | prompt 部分的 token 不算 loss——用一个 0/1 的 loss mask 乘上去。模型只学”怎么回答”,不学”怎么提问” |
| 奖励模型 | \(P(y_w \succ y_l) = \sigma(r(y_w) - r(y_l))\) | 把 \(p_\theta\) 换成”回答 A 比回答 B 好”的概率(下一篇 Bradley-Terry);loss 是 \(-\log\sigma(r_w - r_l)\) |
| DPO | 奖励模型的概率里 \(r\) 再换成 \(\beta\log\frac{\pi_\theta}{\pi_{\text{ref}}}\) | 下一篇推出 |
| 分类(L2) | \(p_\theta(\text{真实类别} \mid \text{输入})\) | 逻辑回归、任何分类头 |
看到一个新的 loss,先问”它把什么概率放进了 \(-\log\)“,大多数时候就读懂了。两个值得画出来看的:
SFT 的 loss mask。 一条对话 user: 1+1=? / assistant: 2 经 Qwen2.5 的 chat 模板变成 26 个 token(含系统提示与角色标记),其中只有回答的 3 个 token 算 loss:
loss 是 mask 为 1 的 3 个位置的 \(-\log p\) 之和除以 3——不是除以 26。分母除错了,loss 会凭空变小(第六章的 bug 之一就是这类),多条长短不一的对话之间也不可比。后训练系列第一篇展开 mask 的各种细节(多轮对话、只训最后一轮、工具调用的返回要不要算)。
奖励模型的 loss。 奖励模型给一对回答各打一个分 \(r_w\)(人标的好回答)、\(r_l\)(差回答),”好的确实比差的好”的概率是 \(\sigma(r_w - r_l)\),loss 是它的 \(-\log\):
分不出好坏(差值 0)时 loss 是 \(\ln 2 = 0.69\)——与”两个选项里瞎猜”一致;好的分越高 loss 越接近 0;把差的排在前面则 loss 近似线性增长、被重罚。它就是第四章的 \(-\log p\) 曲线,只是横轴换成了分差。
六、一个立刻能用的数字
1. 初始 loss ≈ ln V
训练刚开始,权重是随机的,模型对下一个 token 一无所知——它的输出分布接近均匀:词表 \(V\) 个 token 每个概率 \(1/V\)。此时每个位置的 loss 是 \(-\log(1/V) = \ln V\):
| 模型 | 词表大小 \(V\) | \(\ln V\) |
|---|---|---|
| 本系列的字符级莎士比亚小模型(nanoGPT) | 65 | 4.17 |
| GPT-2 | 50257 | 10.8 |
| Llama-2 | 32000 | 10.4 |
| Llama-3 | 128256 | 11.8 |
| Qwen2.5 | 151936 | 11.9 |
所以 Llama-3 的训练曲线应该从 11.8 附近开始往下走。这不是理论上的说法:《Transformer 与 LLM》第四篇在 MacBook 上训过一个字符级的莎士比亚模型,词表 65 个字符,\(\ln 65 = 4.174\)——它的日志第一行是 step 0: train loss 4.1676, val loss 4.1649:
2. 偏离说明什么:三种 bug 各长什么样
上图右边是配套脚本用一个 0.1M 参数的小模型、在同一份数据上各训 150 步的结果(lnv 段):
| 情况 | step 0 | step 50 | step 149 | 读法 |
|---|---|---|---|---|
| 正常 | 4.19 | 2.83 | 2.49 | 从 \(\ln V\) 开始,平滑下降 |
| 输出层权重 ×20 | 25.2 | 3.39 | 2.94 | 首步远大于 \(\ln V\):模型一开始就非常”自信”地猜错——logits 太大、分布太尖。原因几乎总是初始化:最后一层权重太大。修法是把输出层初始化得更小。150 步后仍落后于正常的 |
| 泄漏:输入 = 目标 | 3.46 | 0.62 | 0.06 | 忘了把目标错一位,模型”看着答案预测答案”,loss 迅速趋向 0——从头训还能低到这个程度,一定是数据泄漏(输入里已经包含了目标 token) |
| 一半目标是 pad 且算进 loss | 4.22 | 1.77 | 1.39 | pad 位置的目标永远是同一个 token,模型几秒钟就学会了;loss 被这一半”白送的分”拉低了近一半。这是 loss 算错(忘了 ignore_index)——数字好看,模型没有变好 |
两条规则:第一步 loss 远大于 \(\ln V\),查初始化;从随机初始化出发、loss 很快远低于同类模型应有的水平,查数据泄漏与 loss 的算法(mask、分母、padding)。但前提是从头训——在预训练好的模型上继续训 / SFT,第一步 loss 本来就低(2–4 是正常的),这条检查不适用。
一个细节:泄漏那条曲线 step 0 就是 3.46,已经低于 \(\ln V\)。这是因为 nanoGPT 的输出层与 embedding 共享权重(tied):输入 token 的 embedding 经残差连接一路传到输出,再与同一张表做内积,正确 token 那一位天然多了一个”自己和自己的内积”——所以哪怕权重是随机的,答案已经泄漏进了 logits。看到首步 loss 就异常低,别只怪初始化,先查数据。
这是训练前最便宜的 sanity check:跑一步,看 loss 是不是 \(\ln V\) 上下;再跑几十步,看下降速度是不是合理。L3 系列会把它扩展成一组检查。
七、softmax
1. 从 logits 到概率
模型最后一层输出的是 \(V\) 个实数(可正可负、不归一),叫 logits,记作 \(z = (z_1, \dots, z_V)\)。上一篇说分布要非负、和为 1,logits 都不满足。softmax 把它们变成分布:
\[p_j = \frac{e^{z_j}}{\sum_{k=1}^{V} e^{z_k}}\]为什么是指数?看 \(e^z\) 的曲线:
指数做了两件事:把任何实数变成正数(负的 logit 也能得到一个正的概率),保持大小顺序并放大差距(logit 大 1,\(e^z\) 大 2.7 倍)。然后除以总和让它们加起来为 1。用三个 token 的例子手算,再与 PyTorch 对拍(softmax 段):
| token 1 | token 2 | token 3 | 和 | |
|---|---|---|---|---|
| logits \(z\) | 2.0 | 1.0 | 0.0 | |
| \(e^z\) | 7.389 | 2.718 | 1.000 | 11.107 |
| softmax \(p = e^z / 11.107\) | 0.665 | 0.245 | 0.090 | 1 |
torch.softmax(z) |
0.665 | 0.245 | 0.090 | 1 |
若真实 token 是第 2 个,这个位置的 loss 是 \(-\ln 0.245 = 1.4076\);F.cross_entropy(z, y) 给出 1.4076——它内部就是”softmax 再取 \(-\log\)“,只是合在一起算更稳定。
2. 三条性质
每条都能用上面的数字验证:
- 保序:logit 大的概率大。\(z\) 的排序是 (1, 2, 3),\(p\) 的排序也是。
- 差值决定比值:\(p_j / p_k = e^{z_j - z_k}\)。\(p_1 / p_2 = 0.665 / 0.245 = 2.718 = e^{2 - 1}\);\(p_1 / p_3 = 7.389 = e^2\)。logits 相差 1 概率比是 2.7 倍,相差 5 是 148 倍——所以 logits 的差比绝对值重要。
- 平移不变:所有 logits 加同一个常数 \(c\),概率不变。
softmax(z + 100)仍是 (0.665, 0.245, 0.090),因为分子分母同乘了 \(e^{100}\)。
3. 减最大值的数值技巧
\(e^{z_j}\) 在 \(z_j\) 大于 88 左右就超出 float32 的范围(溢出成 inf)。而模型输出的 logits 是没有上限的——训练后期、或者数值不稳时,几百甚至上千都可能出现,直接算 \(e^{z_j}\) 会得到 inf,再算 inf / inf 就是 NaN,整个训练崩掉。
解法用的正是第三条性质平移不变:所有 logits 同时加(或减)一个常数,概率不变。那就减去最大的那个 logit,再取指数:
\[p_j = \frac{e^{z_j - m}}{\sum_k e^{z_k - m}}, \qquad m = \max_k z_k\]结果完全相同,但指数里最大是 \(e^0 = 1\),永远不溢出。用三个 logits \((1000, 1001, 999)\) 实际跑一遍:
| 直接算 | 先减最大值 \(m = 1001\) | |
|---|---|---|
| 指数 | \(e^{1000}, e^{1001}, e^{999}\) ——float32 里全是 inf | \(e^{-1}, e^{0}, e^{-2} = 0.368, 1, 0.135\) |
| 分母 | inf | \(1.503\) |
| 概率 | inf / inf = NaN, NaN, NaN | \(0.245, 0.665, 0.090\) |
为什么减完还是”同一个答案”:\(\dfrac{e^{z_j - m}}{\sum_k e^{z_k - m}} = \dfrac{e^{z_j} \cdot e^{-m}}{\sum_k e^{z_k} \cdot e^{-m}} = \dfrac{e^{z_j}}{\sum_k e^{z_k}}\)——分子分母同乘了 \(e^{-m}\),约掉了。所有框架的 softmax 都这么做,torch.softmax、F.cross_entropy 里都不用你操心;但自己手写 softmax(比如写 kernel、写评测脚本)时忘了这一步,就是 NaN 最常见的来源之一。
这个技巧在 FlashAttention 里变成了核心机制。attention 要对每一行 \(T\) 个 score 做 softmax,但 FlashAttention 为了省显存分块计算,一次只看一块 score,看不到整行的最大值。做法是在线 softmax:每块维护自己的最大值 \(m_{\text{块}}\) 和局部的分母,合并两块时用 \(e^{m_1 - m_{\text{new}}}\)、\(e^{m_2 - m_{\text{new}}}\) 把两边的结果修正到同一个基准。数学上只用了”平移不变”这一条性质。Infra 地图的 GPU kernel 系列会写它的实现;这里知道它的数学根源即可。
4. logits 的差与”自信”
回到第六章:随机初始化的模型 logits 差不多大,softmax 接近均匀,loss ≈ \(\ln V\)。训练让正确 token 的 logit 比其他的高出几个单位,概率就压到它上面。用缓存在本机的 GPT-2(1.24 亿参数)看两个真实位置(sampling 段):
| 前文 | 第一名 token | 它的概率 | 前十名合计 | 累计到 90% 需要多少个 token |
|---|---|---|---|---|
The United States of |
␣America |
0.966 | ≈ 1 | 1 |
The capital of France is |
␣the |
0.085 | 0.359 | 1503 |
第一个位置下一个词几乎只有一个可能,GPT-2 把 96.6% 押在 ␣America 上;第二个位置可以接很多种说法(␣the capital of the French Republic、␣a gritty bastion of French、␣Paris……),概率摊到了上千个 token 上,第一名只有 8.5%。困惑度(下一篇)度量的就是这种平均的分散程度:确定的位置贡献接近 0 的 loss,开放的位置贡献几个 nat。
八、温度与采样
1. 温度
推理时通常不直接用 softmax 的分布,而是先把 logits 除以一个温度 \(\tau > 0\):
\[p_j \propto e^{z_j / \tau}\]用第七章的三个 logits \((2, 1, 0)\):
| \(\tau\) | logits / \(\tau\) | 概率 | 读法 |
|---|---|---|---|
| 1 | (2, 1, 0) | (0.665, 0.245, 0.090) | 模型原始分布 |
| 0.5 | (4, 2, 0) | (0.867, 0.117, 0.016) | 更尖:logits 差被放大两倍 |
| 2 | (1, 0.5, 0) | (0.506, 0.307, 0.186) | 更平:logits 差被压缩一半 |
| → 0 | 差被无限放大 | (1, 0, 0) | 只剩最大的:greedy |
| → ∞ | 差被压成 0 | (1/3, 1/3, 1/3) | 均匀:随机乱说 |
温度不改变顺序,只改变”多确定”(第七章右图三组柱子的顺序一样,只是尖或平)。在 GPT-2 的真实分布上:
\(\tau = 0.5\) 时前十名合计 91%、第一名 ␣the 独占 40%;\(\tau = 2\) 时前十名合计只剩 3%,剩下 97% 的概率散在其余五万个 token 上——采样几乎等于乱抽。三次实际续写:
| \(\tau\) | 三次续写 |
|---|---|
| 0(greedy) | ␣the capital of the French Republic × 3(每次一样) |
| 0.7 | ␣a gritty bastion of French / ␣Rome, which is about 10 / ␣the birthplace of the French philosopher |
| 1.5 | ␣Carrequisite pre-publicad / ␣still `promised land82 / ␣renowned ideologically for sailware wine |
\(\tau < 1\) 保守、重复;\(\tau > 1\) 多样、易出错(1.5 时已经不成句)。代码生成常用 0.0–0.2,创意写作常用 0.7–1.0。
2. 截断:top-k 与 top-p
温度之外的另一类做法是截断:先把概率很小的 token 直接去掉,再在剩下的里面按比例抽。
- top-k:只保留概率最大的 \(k\) 个(比如 50),其余置零,重新归一化;
- top-p(nucleus sampling):按概率从大到小累加,累加到 \(p\)(比如 0.9)为止,保留这些,其余置零。
上图右边是这两种截断在两个真实位置上的差别:top-k 5 在两个位置都保留 5 个 token;top-p 0.9 在开放位置保留 1503 个、在确定位置只保留 1 个。top-p 是自适应的——分布尖时只留几个,分布平时留几百上千个;top-k 则不管分布形状一律留 \(k\) 个,在开放位置会砍掉大量合理的候选(这里的 5 个合计只有 24% 的概率),在确定位置又会留下 4 个几乎不可能的。这是 top-p 更常用的原因。
greedy(每步取概率最大的)是 \(\tau \to 0\) 或 \(k = 1\) 的特例。
3. 为什么评测依赖采样设置
同一个模型,\(\tau = 0\) 与 \(\tau = 0.7\) 输出的是两个不同的分布——前者确定、后者随机。评测得分是从这个分布里抽样得到的:greedy 在数学题上通常更高(不会抽到低概率的错误步骤),但在需要多样性的任务上更差;带温度的采样每次结果不同,要多跑几次取平均并报方差(第八篇)。所以比较两个模型时必须固定采样设置,论文里 “greedy” 与 “temperature 0.6, top-p 0.95” 的分数不能直接比。这也是 L5 评测系列反复强调的一件事——它的数学根源就是”温度改变了分布”。
九、案例:读懂一条训练日志
把本文的工具用在一条真实日志上。下面是《Transformer 与 LLM》第四篇那次训练的原始输出(nanoGPT,字符级莎士比亚,\(V = 65\),0.8M 参数):
step 0: train loss 4.1676, val loss 4.1649
step 250: train loss 2.8491, val loss 2.8662
step 500: train loss 2.3961, val loss 2.4026
...
step 1500: train loss 1.5555, val loss 1.7179
step 1750: train loss 1.4989, val loss 1.6616
逐行读:
| step | val loss | 困惑度 \(e^{\text{loss}}\) | bits/char | 含义 |
|---|---|---|---|---|
| 0 | 4.165 | 64.4 | 6.01 | \(\approx \ln 65 = 4.17\):均匀乱猜,”65 选 1”。初始化正常、loss 没算错 |
| 750 | 2.166 | 8.7 | 3.12 | 像是在 8.7 个等可能的字符里猜;每个字符约 3 bit |
| 1750 | 1.662 | 5.3 | 2.40 | 像是在 5.3 个字符里猜;train 1.499 < val 1.662,差 0.16——开始轻微过拟合 |
四个读法:
- 困惑度(perplexity,PPL)\(= e^{\text{loss}}\),读作”模型在每个位置像是在 PPL 个等可能的选项里猜”。从 64 猜到 5,是这次训练做的全部事情。下一篇给它信息论的定义。
- bits/char \(= \text{loss} / \ln 2\)。2.4 bit/字符——作为对比,英文文本用 gzip 压缩约 2–2.5 bit/字符,好的大模型能到 1 bit 以下。语言模型就是压缩器,loss 就是压缩率。
- train 与 val 的差距:前 500 步两条线重合(模型还没能力记住训练集),之后 train 持续低于 val 且差距拉大——模型开始记住训练集特有的东西。这个规模的数据(1M 字符)配 0.8M 参数,继续训会过拟合;第一篇讲过的划分与泛化在这里看得见。
- 下降速度:前 250 步从 4.17 掉到 2.85(每步 0.005),后 250 步从 1.72 掉到 1.66(每步 0.0002)——loss 曲线几乎总是这个形状:先快后慢,双对数坐标上近似直线(第八篇的幂律)。
拿到任何一条训练日志,先问这四个问题:首步是不是 \(\ln V\) 上下?降到了几个”等可能选项”?train 与 val 差多少?下降是不是在放缓?——四个问题都能回答,这条日志就读懂了。
十、本文小结
- 最大似然:手里只有数据不知道参数时,选让数据出现概率最大的参数。硬币抛 10 次 7 正,\(L(p) = p^7(1-p)^3\) 在 0.7 处最高。似然与概率是同一个数、看待角度不同。取对数把乘积变求和、避免下溢(1000 个 0.01 相乘在 float64 里是 0)、且不改变最大值的位置。
- 语言模型是会看上下文的骰子:一句话的概率 = 逐位置”真实下一个 token 的概率”相乘(链式法则)。3 个 token 的玩具模型上,\(\theta_1\) 让训练句出现的概率是 \(\theta_2\) 的 11 倍,就选 \(\theta_1\)。
- 三步推出 loss:取对数 → 取负 → 除以 token 数,得到每 token 负对数似然 \(-\frac{1}{T}\sum_t \log p_\theta(x_t \mid x_{<t})\);它就是交叉熵(真实分布 one-hot 时交叉熵只剩这一项);\(-\log p\) 是”被真实答案惊到多少”;单位 nat,1 nat = 1.44 bit,bit 是”猜数字要问几个问题”。
- 同一个模板:SFT 只对回答部分求和(26 个 token 里只有 3 个算 loss,分母是 3);奖励模型把概率换成 \(\sigma(r_w - r_l)\),分不出好坏时 loss 是 \(\ln 2\);DPO 再换一次;分类换成真实类别的概率。读新 loss 先问”什么概率进了 \(-\log\)“。
- 从头训的初始 loss ≈ \(\ln V\)(莎士比亚 65 字符是 4.17,nanoGPT 实测 4.1676;Llama-3 是 11.8)。远大于它是初始化太大(×20 后首步 25);很快远低于应有水平是数据泄漏(输入 = 目标,loss → 0.06)或 loss 算错(pad 算进 loss,被拉低一半)。微调不适用这条检查。
- softmax \(p_j = e^{z_j} / \sum_k e^{z_k}\):指数让一切为正并放大差距;保序、差值决定比值、平移不变;实现上减最大值防溢出(\((1000, 1001, 999)\) 直接算是 NaN);FlashAttention 的在线 softmax 靠的就是平移不变。GPT-2 在确定位置把 96.6% 押在一个 token 上,在开放位置第一名只有 8.5%。
- 温度缩放 logits 的差:\(\tau \to 0\) 是 greedy,\(\tau = 1.5\) 时 GPT-2 已不成句;top-p 自适应(开放位置留 1503 个、确定位置留 1 个),top-k 不看分布形状。同一个模型不同采样设置是不同的分布——评测必须固定它们。
- 读日志:PPL \(= e^{\text{loss}}\) 是”在几个等可能选项里猜”;bits/char 是压缩率;train–val 差距是过拟合的信号;下降先快后慢。
十一、自测
-
一个只有三个 token 的语言模型,某位置真实 token 的概率是 0.25,这个位置的 loss(nat)是多少?换成 bit 呢?
答案
\(-\ln 0.25 = 1.39\) nat \(= 2\) bit——相当于在 4 个等可能的选项里猜。
-
硬币抛 20 次得到 12 次正面。写出似然函数,说出最大似然估计是多少,并说明为什么”取对数再求最大值”得到同一个答案。
答案
\(L(p) = p^{12}(1-p)^8\),最大似然估计 \(p = 12/20 = 0.6\)。对数单调递增,\(\log L\) 与 \(L\) 在同一个 \(p\) 处取最大值;对 \(\log L = 12\ln p + 8\ln(1-p)\) 求导令为零:\(12/p = 8/(1-p)\),解得 \(p = 0.6\)。
-
写出 SFT 的 loss:prompt 有 \(P\) 个 token、回答有 \(R\) 个 token,哪些位置进入求和?除以多少?
答案
只有回答的 \(R\) 个位置(每个位置预测回答的下一个 token);通常除以 \(R\)(mask 为 1 的 token 数),不是 \(P + R\)。第五章的例子里是 3 而不是 26。
-
词表 50257 的模型第一步 loss 是 3.1,最可能的原因是什么?如果第一步是 24 呢?
答案
\(\ln 50257 = 10.8\)。3.1 远小于它——若是从随机初始化开始训,是数据泄漏(输入含目标)或 loss 算错(mask / 分母 / padding);若是从预训练模型出发(微调),3.1 是正常水平,这条检查不适用。24 远大于它——输出层初始化尺度太大,logits 太尖。
-
logits \((5, 3, 3)\):softmax 后三个概率是多少?温度 2 时呢?
答案
\(e^5 = 148.4\)、\(e^3 = 20.1\),和 188.6,\(p = (0.787, 0.107, 0.107)\);温度 2 时 logits 变 \((2.5, 1.5, 1.5)\),\(p = (0.576, 0.212, 0.212)\)。顺序不变,差距变小。
-
为什么 softmax 减去最大值不改变结果?用一句话说明 FlashAttention 分块合并时靠的是这条性质的哪一部分。
答案
分子分母同乘 \(e^{-m}\) 约掉;分块时各块用自己的最大值,合并时再统一乘一个修正因子 \(e^{m_{\text{块}} - m_{\text{new}}}\)——靠的就是”乘一个公共因子不改变结果”。
-
一条日志:词表 32000,
step 0: loss 10.37,step 2000: train 2.91, val 2.93,step 6000: train 2.10, val 2.62。逐行读。答案
\(\ln 32000 = 10.37\),首步正常。step 2000 困惑度 \(e^{2.93} \approx 19\),train 与 val 几乎相同,没有过拟合。step 6000 val 困惑度 \(e^{2.62} \approx 14\),但 train 2.10 与 val 2.62 差了 0.5——明显过拟合,模型在记训练集;再训下去 val 大概率不再下降甚至回升,该加数据、加正则或停下。
下一篇进入信息论:熵、交叉熵、KL 三个量的定义与关系,KL 的方向为什么决定”对齐会降低多样性”,以及从 KL 约束的最优策略一路推出 DPO。
配套代码:math-for-ai/05_mle_to_cross_entropy.py——硬币似然曲线、玩具语言模型、下溢演示、softmax 对拍与溢出、nanoGPT 首步 loss 与三种 bug、GPT-2 上的温度 / top-k / top-p、Qwen2.5 chat 模板的 loss mask、奖励模型曲线、读日志;本文全部图与数字由它生成,输出在 expected/。
-
能。最大似然要让训练集出现的概率 \(\prod_t p_\theta(x_t \mid x_{<t})\) 最大;取对数变乘为加;取负、除以 token 数,得到每 token 负对数似然 \(-\frac{1}{T}\sum_t \log p_\theta(x_t \mid x_{<t})\)——真实分布是 one-hot 时它就是交叉熵。详见第二章到第四章。 ↩
-
\(\approx \ln V\):随机初始化的模型对词表近似均匀,每个 token 的概率约 \(1/V\),负对数就是 \(\ln V\)——Llama-3 的 128 K 词表是 11.8,本系列的 65 字符莎士比亚小模型是 4.17(nanoGPT 实测首步 4.1676)。远大于它是初始化尺度太大,远小于它是数据泄漏或 loss 算错。详见第六章。 ↩
系列 《算法工程师的数学:读公式不卡壳的最小集》 第 5 / 9 篇
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/from-maximum-likelihood-to-cross-entropy.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。