交叉熵:语言模型的损失函数,为什么只能是它Cross-Entropy: The Loss Function of Language Models, and Why It Must Be This One

3Blue1Brown「压缩即智能」Part 2 学习笔记——从一个玩具编码方案讲交叉熵,为什么 p 和 q 不能对调,又为什么交叉熵公式是「平均损失仅在 q=p 时取最小值」的唯一解Notes on 3Blue1Brown's 'Compression is Intelligence' Part 2 — from a toy coding scheme to cross-entropy, why p and q can't be swapped, and why the cross-entropy formula is the unique answer to 'average loss minimized exactly when q = p'

Posted by Ryan on October 5, 2026

这篇是 3Blue1Brown 系列 Compression is Intelligence 的第二篇。Part 1 把熵重新讲了一遍,这里直接接上:熵之后,语言模型的损失函数到底是什么,以及为什么是它。原文链接:But what is cross-entropy? — Compression is Intelligence Part 2(B站官方双语版)。这个视频的讲解让我对交叉熵有了完全不一样的认识,妙不可言啊。


一、从一个20年前的实验说起

故事要从一篇 2002 年的论文《Language Trees and Zipping》开始。它做的事情相当离谱:把一堆不同语言写的文本扔进通用压缩器(gzip),看压缩之后的文件大小,就能把语言分好类、甚至恢复出语言的谱系树。

没有训练,没有词典,没有统计任何词频,甚至连语言学先验都不需要。只靠压缩。

这个想法很值得琢磨:压缩器为什么能看出语言关系?因为它本质上在估计数据的统计规律——一份文本能被压得多狠,取决于它有多”可预测”。两种语言越像,拿 A 的上下文去压 B,压缩比就会越接近单独压 A。

换句话说,“压缩距离”天然就是一个分布之间的差异度量。这个度量后面会变成一个名字:交叉熵。

不过先说好,视频里自己也提了:压缩距离不是严格的交叉熵,gzip 也远没达到 Shannon 极限,这只是一个经验估计。但方向是对的,而这一步足够把我们从”压缩”带到”语言模型训练”门口。

二、编码一个玩具消息

自然语言的分布复杂到没法描述,所以视频从一个玩具例子起步:给一个远程机器人发指令,只有四种符号——上、下、左、右。

第一版分布(也就是老规矩):

符号 概率
上 1/2
下 1/4
左 1/8
右 1/8

Part 1 已经推过结论:最优编码里,每个符号分配的比特数就是 −log₂(概率)。

符号 概率 比特数
上 1/2 1
下 1/4 2
左 1/8 3
右 1/8 3

−log₂ 这个形式第一次看到确实别扭。Shannon 把它叫做事件的信息量。Grant 甚至开玩笑说,希望历史上把它叫作 log base 1/2(即以 1/2 为底的对数),因为它其实就是在问”把东西对折几次,才能折到这么多”。

玩具编码:旧分布决定码本,每个符号按 −log₂(概率) 定价

然后计划改了。从此刻起,新的分布是:

符号 概率
上 1/8
下 1/8
左 1/4
右 1/2

注意这句:编码和解码系统都写死了,用的是旧分布的最优方案。

于是每次发消息要花多少比特?加权求和:

1/8 · 1 + 1/8 · 2 + 1/4 · 3 + 1/2 · 3 = 2.625 bits / 消息

2.625 这个数,就是旧分布相对于新分布的交叉熵。

它回答的问题很具体:一个为某种上下文优化的压缩方案,换个上下文还能用吗?能用,但要多花多少?

三、交叉熵公式

把符号一般化:真实分布记作 p(实际发生的是什么),编码所依据的分布记作 q(我们按什么方案编码)。

H(p || q) = -Σ pᵢ log₂ qᵢ = Σ pᵢ · (-log₂ qᵢ)

每一项 −log₂ qᵢ 是”按 q 给符号 i 定的价”,乘上 pᵢ 是”这个符号实际出现的频率”,加起来就是平均要花的比特数。

写代码的话:

import numpy as np

p = np.array([0.125, 0.125, 0.25, 0.5])   # 真实分布(新)
q = np.array([0.5, 0.25, 0.125, 0.125])   # 编码所用分布(旧码本)

cross_entropy = -(p * np.log2(q)).sum()     # 2.625
entropy_p     = -(p * np.log2(p)).sum()     # 1.75
kl            = cross_entropy - entropy_p    # 0.875

三个数,后面全用得上。

四、p 和 q 不能对调

只留两个事件的分布,交叉熵的性质看得更清楚。

情形 A:q 是均匀分布(50/50),p 是偏斜的(90/10)。

0.9 × (-log₂ 0.5) + 0.1 × (-log₂ 0.5) = 0.9·1 + 0.1·1 = 1.00 bits

情形 B:反过来,q 偏斜(90/10),p 均匀(50/50)。

0.5 × (-log₂ 0.9) + 0.5 × (-log₂ 0.1) = 0.5·0.15 + 0.5·3.32 ≈ 1.74 bits

同样是 90/10 和 50/50 这两个分布,只是换了个角色,1 bit 直接变成 1.74 bits。

交换 p 和 q 的角色,交叉熵从 1 bit 变成 1.74 bits

原因在 −log₂ q 这个”单价”上。q 偏斜意味着有个符号被定价成 3.32 bits;当 p 也均匀地往那个符号上摊 50% 的质量时,3.32 就被拉进了平均值。反过来 q 均匀时,所有符号单价都是 1,不管 p 长什么样,加权平均都等于 1。

所以 H(p||q) ≠ H(q||p)。写代码的时候,p 传错就出 bug。在语言模型里:p 是真实数据(下一个 token 到底是什么),q 是模型给的预测分布。顺序反了,你算的根本不是同一个东西。

五、最低点就是熵

把 p 钉死,让 q 动起来看交叉熵怎么变。

import numpy as np

p = np.array([0.125, 0.125, 0.25, 0.5])       # 固定真实分布
q_old = np.array([0.5, 0.25, 0.125, 0.125])   # 旧码本

ts = np.linspace(0, 1, 200)                   # q 从 p 走向 q_old
ce = np.array([-(p * np.log2((1-t)*p + t*q_old)).sum() for t in ts])

print(f"q=p 时: {ce[0]:.4f}   (= p 的熵)")     # 1.75
print(f"q=旧码本: {ce[-1]:.4f}")              # 2.625
print(f"差值(KL): {ce[-1] - ce[0]:.4f}")      # 0.875
q=p 时: 1.7500   (= p 的熵)
q=旧码本: 2.6250
差值(KL): 0.8750

曲线从下往上爬,最低点严格出现在 q = p 处,而那个最低值就是 p 的熵 H(p) = 1.75。

交叉熵的最低点恰好等于 p 的熵,多出的部分是 KL 散度

这条曲线的意义很大:熵不只是一个抽象数字,它是任何编码方案的比特数下界。q 偏离 p 一点,交叉熵就往上抬一点,抬了多少就是亏了多少。

顺带把 KL 散度定下来:

D_KL(p || q) = H(p||q) − H(p)

交叉熵减去熵,就是”用了次优编码方案,每个符号浪费的比特数”。上面的例子:2.625 − 1.75 = 0.875。机器学习中它常当分布之间的距离用——相等时为 0,差异越大越大,但不对称,不是真距离(不满足对称性)。

六、为什么公式非得是 −log

前面所有的例子都在展示交叉熵”是什么样”,这一节回答的是为什么只能是它。

语言模型的损失函数是”平均损失”。它应该满足什么性质?最自然的诉求是:只有当模型给出的分布 q 恰好等于真实分布 p 时,平均损失取最小值;其他任何 q 都更差。

那什么样的损失函数 F(q) 能做到这点?

模型对一组可能的下一个 token 给出分布 q,我们要最小化的是这个加权平均损失:

L(q) = Σ pᵢ F(qᵢ)     约束: Σ qᵢ = 1

这里有个细节容易忽略:L 是 q 的函数,但 p 是真实世界的固定事实——求导只对 q 求,pᵢ 是常数权重。这一点如果绕不过去,后面的推导就会卡住。

引入拉格朗日乘子 λ:

∂L/∂qᵢ = pᵢ F'(qᵢ) + λ = 0

对所有 i 成立,于是:

F'(qᵢ) = -λ / qᵢ     (所有 i 一致,说明 F' 就是 1/q 的形式)

积分:

F(q) = -λ log q + C

再取一个边界条件把常数定下来:当只有一个是真(q = p = 1)时,预测全对,损失该是 0。代入 F(1) = -λ·0 + C = 0,所以 C = 0。

于是 F(q) = −λ log q,取 λ = 1(这只是单位选择,等价于换底数),我们得到:

F(q) = -log q

平均损失就是交叉熵,而且只有它是这个形状。

也就是说,交叉熵不是被”选出来”的,是被”逼出来”的。只要承认”平均损失应该恰好在 q=p 时最小”,−log 是唯一答案。

这里有个我读到这里才真正体会到的东西:前几节里,交叉熵是从压缩那条路自然长出来的(信息量、比特数、编码);到这一节,它又是在机器学习那条路上,用约束优化从别处被推出来的。两条完全不相关的推理链,落在同一个公式上。

七、大语言模型的损失函数

现在把前面全部拼起来。

大语言模型做的事很简单:把文本切成 token,喂进模型,让模型输出下一个 token 的概率分布 q,然后跟真实数据里的下一个 token(即 p)比。损失就是模型给真实 token 打分的对数取负,再对所有 token 求平均:

L = - (1/N) Σ log q(true token)

写成交叉熵的形式,这就是”模型分布 q 相对于真实分布 p 的交叉熵”。第 6 节证明过,这个损失恰好在模型学会数据的统计规律(q 贴近 p)时最小。

几个细节:

  • 代码里几乎都用 log 而不是 log₂。差一个常数因子 1/ln 2,会被学习率吸收,而自然对数求导干净得多。
  • 预训练的终极目标,是让损失逼近语言的熵。语言的熵是下界(第 5 节那条曲线的最低点),永远到不了,但可以一直逼近。
  • 形式上极其简洁,工程上极其复杂:批处理、优化器、数值稳定(softmax + log 容易出 NaN,得用 logsumexp),这些和数学无关,但都躲不开。

八、压缩即智能

回到标题。把第 7 节那句”模型视角下的平均信息量”换个说法:

模型的损失,就是它认为每个 token 值多少比特。把整个文本用这个”模型视角的比特数”加起来,就是一次压缩。

所以训练语言模型,数学上等价于训练一个最优文本压缩器。 这不是类比,是同一个目标函数的两种读法。

这条命题我现在的理解是:它在数学上严格成立——交叉熵既是压缩的期望比特数,又是预测的损失,证明路径互不相干却互相印证。但”智能”那半个词仍然是个教学框架:压缩得好不代表”懂”。Grant 真正在做的事,是把这两边放在同一个公式下面,让你自己决定这个等号要打多深。

知识蒸馏是另一个直接应用:让小模型在同一个位置,去逼近大模型给出的完整分布,损失就是小模型分布相对于大模型分布的交叉熵。好处是每个样本都能拿到整个分布的监督,而不只是一个 one-hot 标签——单个训练样本就能提供比标签多得多的信息。

九、参考资料与复现

正文里所有数字都可以用第三、六节的两段代码复现,纯 numpy:

  • 交叉熵 2.625 bits/消息(旧码本 vs 新分布)
  • H(p) = 1.75 bits(新分布的熵,交叉熵下界)
  • D_KL = 0.875 bits(交叉熵 − 熵)
  • 非对称性对照:H(p=90/10, q=50/50) = 1.00 vs H(p=50/50, q=90/10) ≈ 1.74

如果这篇笔记对你有帮助,欢迎交流。