大学4年没讲明白的信息论,被一段 PyTorch 代码讲透了

2026-07-01 · Steve Chan

熵、交叉熵、KL 散度、互信息、香农第一定理……当年在《通信原理》或《信息论》课上背得死去活来, 考完试只记得一个"熵越大越混乱"。 因为老师把它讲成了"电报和信道的编码课",没告诉你一个真相:

你现在训练的每一个大模型,损失函数就叫"交叉熵"(cross entropy)。信息论不是通信课,是深度学习的记分牌。

这篇文章不背一个公式、不证一个定理,全部用能跑起来的 PyTorch 代码, 把信息论最核心的四个量——自信息、熵、交叉熵、KL 散度——一段一段"跑"给你看。 并且顺手回答四个卡住无数人的灵魂拷问:

  • 大模型的损失函数为什么偏偏叫"交叉熵"?它和香农那个"熵"是一回事吗?
  • KL 散度到底"散"的是什么?为什么它不是一种距离?
  • 评价语言模型的"困惑度(perplexity)",凭什么能说明模型好坏?
  • 信息的单位是"比特",那一个字、一次预测,到底值几个比特?

0. 一句话主线

如果只能留一句话,那就是这句:

信息 = 意外。越让你意外的事,信息量越大。而"熵"就是平均意外程度。

大模型训练的全部目标,用这句话翻译过来就是:逼自己对真实世界越来越"不意外"。 它每看一句话,就赌下一个字是什么;赌错了(很意外)就狠狠罚一下,赌对了(不意外)就放过。 这个"意外的罚分",数学上就是交叉熵。

import torch

# 一件事发生的概率 p,它的"意外程度"(信息量)= -log2(p)
p_sunrise = torch.tensor(0.999)   # 太阳明天升起:几乎必然
p_coin    = torch.tensor(0.5)     # 抛硬币正面:五五开
p_lottery = torch.tensor(1e-6)    # 中头奖:极其罕见

print(-torch.log2(p_sunrise))     # ≈ 0.0014 比特 —— 毫不意外,几乎没信息
print(-torch.log2(p_coin))        # = 1.0 比特    —— 一次标准的"意外"
print(-torch.log2(p_lottery))     # ≈ 19.9 比特   —— 巨大的意外,信息量爆表

记住这个画面:概率越低 = 越意外 = 信息量越大。 下面所有概念都挂在它上面。


1. 自信息:一件事的信息量 = 它有多让你意外

课本管它叫"自信息量 \(I(x) = -\log p(x)\)",一上来就是 log,把人劝退。 其实它只回答一个问题:这件事发生,让你有多惊讶?

  • 必然发生的事(p=1):一点不惊讶,信息量 = 0。
  • 罕见的事(p 很小):非常惊讶,信息量很大。

为什么要取 -log 而不是别的?因为信息量得满足一条硬性质:两件独立的事一起发生, 总信息量应该能"相加"。 而概率是相乘的(p(A且B)=p(A)·p(B)), log 正好能把"相乘"变成"相加":

import torch

p_rain = torch.tensor(0.25)       # 今天下雨
p_late = torch.tensor(0.20)       # 你迟到(与下雨独立)

# 两件事一起发生的信息量 = 各自信息量之和
i_both  = -torch.log2(p_rain * p_late)
i_sum   = -torch.log2(p_rain) + -torch.log2(p_late)
print(torch.allclose(i_both, i_sum))   # True —— log 把"相乘的概率"变成"相加的信息"

这就是 log 的全部理由:它让"意外"变得可以累加。 一个字、一句话、一整篇文章的信息量, 才能一个字一个字地加起来。


2. 熵:一个分布的"平均意外" = 它有多不确定

单个事件的意外是自信息;把一个分布里所有可能事件的意外,按概率加权平均,就是

\[H(p) = -\sum_x p(x)\log p(x)\]

别被公式吓到,它就是"意外的期望值"。一段代码看它的脾气——越均匀越不确定,熵越大;越确定,熵越小

import torch

def entropy(p, base=2):                       # 熵 = 平均意外
    p = p[p > 0]                              # 约定 0·log0 = 0,直接跳过
    return -(p * torch.log(p) / torch.log(torch.tensor(float(base)))).sum()

uniform  = torch.tensor([0.25, 0.25, 0.25, 0.25])   # 四选一,完全说不准
skewed   = torch.tensor([0.97, 0.01, 0.01, 0.01])   # 几乎肯定是第一个
certain  = torch.tensor([1.0,  0.0,  0.0,  0.0])    # 板上钉钉

print(entropy(uniform))    # 2.0 比特  —— 最乱,需要 2 个比特才能说清是哪个
print(entropy(skewed))     # ≈ 0.24    —— 基本确定,几乎不需要信息
print(entropy(certain))    # 0.0       —— 毫无悬念,零信息

熵是"不确定性"的精确度量。 四选一且完全均匀时,熵恰好是 2 比特—— 这正对应"用 2 个 0/1 比特(00/01/10/11)就能编码 4 个选项"。 香农当年就是用这个,算出了"一条消息最少需要几个比特才能传完"。

记住这个直觉:熵 = 这个分布让你平均要"惊讶"多少。 均匀分布最惊讶,one-hot 分布毫不惊讶。 下一节的交叉熵,就是在这个基础上问:"如果我猜错了这个分布,要多付多少代价?"


3. 交叉熵:用"你以为的分布"去描述"真实的分布",要多付多少代价

这是全篇的主角,因为它就是你训练大模型时天天在最小化的那个 loss。

场景是这样的:真实世界按分布 p 出牌,但你的模型以为世界是分布 q。 你拿错误的 q 去编码真实的 p,会比用正确的 p 多花一些"意外代价"。这个总代价就是交叉熵:

\[H(p, q) = -\sum_x p(x)\log q(x)\]

注意:权重用真实的 p,但意外程度 -\log q 用的是你以为的 q 一段代码看它:

import torch

p = torch.tensor([1.0, 0.0, 0.0])            # 真实:答案 100% 是第 0 类
q_good = torch.tensor([0.7, 0.2, 0.1])       # 模型:70% 押对了
q_bad  = torch.tensor([0.1, 0.2, 0.7])       # 模型:只给了正确答案 10% 的信心

def cross_entropy(p, q):
    return -(p * torch.log(q)).sum()         # 用 q 的意外,按 p 的权重加权

print(cross_entropy(p, q_good))   # ≈ 0.357  —— 猜得准,代价小
print(cross_entropy(p, q_bad))    # ≈ 2.303  —— 猜得离谱,代价大

看清楚了吗?当真实答案是第 0 类(p 是 one-hot),交叉熵就退化成一句话:

交叉熵 = −log(模型给正确答案的概率)。 模型对正确答案越自信(q 越接近 1),loss 越接近 0;越心虚(q 越接近 0),loss 越爆炸。

这就是训练的记分牌。 模型每预测一次,就按"它给正确答案打了几分信心"来罚分。 罚分越少,说明它对世界越"不意外"——回到第 0 节那句主线。


4. KL 散度:交叉熵里,"纯属你认知错误"的那部分额外代价

交叉熵 H(p,q) 里其实混了两笔账:

  1. 世界本身就有的不确定性(熵 H(p),这部分谁也躲不掉);
  2. 你因为认知错误(q≠p)而额外多付的代价

第二部分单独拎出来,就是 KL 散度

\[D_{KL}(p\,\|\,q) = H(p,q) - H(p) = \sum_x p(x)\log\frac{p(x)}{q(x)}\]

一行代码验证这个"减法关系",并看它的两条铁律:永远 ≥ 0,且完全相等时才 = 0

import torch

def entropy(p):       p=p[p>0]; return -(p*torch.log(p)).sum()
def cross_entropy(p,q): return -(p*torch.log(q)).sum()
def kl(p,q):          m=p>0;    return (p[m]*torch.log(p[m]/q[m])).sum()

p = torch.tensor([0.5, 0.3, 0.2])
q = torch.tensor([0.3, 0.3, 0.4])

print(cross_entropy(p, q) - entropy(p))   # 交叉熵 − 熵
print(kl(p, q))                           # 完全相等 —— KL 就是这块"额外代价"
print(kl(p, p))                           # 0.0 —— 认知完全正确,不多付一分钱

KL 有一个反直觉但极其重要的性质:它不是距离,因为它不对称—— D(p‖q) ≠ D(q‖p)。"把 p 错认成 q 的代价"和"把 q 错认成 p 的代价"根本不是一回事:

import torch
def kl(p,q): m=p>0; return (p[m]*torch.log(p[m]/q[m])).sum()
p = torch.tensor([0.9, 0.1])
q = torch.tensor([0.5, 0.5])
print(kl(p, q))   # ≈ 0.368
print(kl(q, p))   # ≈ 0.511 —— 方向反过来,值不一样!所以它不是"距离"

一句话记住三者的关系: 交叉熵 = 熵(世界固有的不确定性)+ KL 散度(你认知偏差的罚款)。 训练时熵是常数(数据分布固定),所以最小化交叉熵,等价于最小化 KL 散度—— 也就是逼模型的分布 q 去贴近真实分布 p。这就是"学习"这件事的信息论定义。


🤔 疑惑点一:分类问题的 loss,为什么用交叉熵,不用更直观的均方误差(MSE)?

因为在"预测一个概率分布"这件事上,交叉熵的梯度又干净又不饱和,而 MSE 会让模型学得极慢。

直觉上 MSE(预测概率和真实标签的平方差)好像也能用,但它有个致命毛病: 当模型错得很离谱、但 softmax 已经饱和时,MSE 的梯度会趋近于 0——错得越狠,学得越慢,方向完全反了。 交叉熵配 softmax 则相反:错得越狠,梯度越大,改得越猛。 用代码把这个梯度对比出来:

import torch
import torch.nn.functional as F

# 模型极度自信但完全错了:把答案压在第 2 类,真实答案是第 0 类
logits = torch.tensor([[-5.0, 0.0, 5.0]], requires_grad=True)
target = torch.tensor([0])

ce = F.cross_entropy(logits, target)
ce.backward()
print("交叉熵在'自信地错'时的梯度:", logits.grad)   # 数值很大 —— 使劲往回拉
# 输出类似 tensor([[-0.9933, 0.0066, 0.9867]]):对正确类是强负梯度,把它往上推

交叉熵给正确类一个明显的负梯度("给我把这个概率提上去!"),给错误类正梯度("给我压下去!"), 而且错得越自信,这个推力越大。这正是我们想要的老师:学生越是自信地犯错,越要重罚。


5. F.cross_entropy 就是信息论本论(信息论和深度学习的接头)

现在把第 3 节手写的交叉熵,对接到你每天在用的 torch.nn.functional.cross_entropy。 它其实只是把"softmax 变概率 + 取正确类的 −log"这两步打包了:

import torch
import torch.nn.functional as F

logits = torch.tensor([[2.0, 1.0, 0.1]])     # 模型的原始输出(未归一化)
target = torch.tensor([0])                   # 正确答案是第 0 类

# PyTorch 一行搞定
loss_builtin = F.cross_entropy(logits, target)

# 手动还原它内部做的事:softmax -> 取正确类 -> 取 -log
q = torch.softmax(logits, dim=-1)            # 变成概率分布
loss_manual = -torch.log(q[0, target])       # = 第 3 节的 −log(正确类概率)

print(loss_builtin)                          # ≈ 0.417
print(loss_manual)                           # ≈ 0.417 —— 完全一样
print(torch.allclose(loss_builtin, loss_manual))   # True

F.cross_entropy 一点不神秘:它就是第 3 节那个 -Σ p·log q,只不过 p 是 one-hot 的正确标签, q 是 softmax 出来的模型分布。 那么语言模型训练是什么?就是把这件事在每个位置、对整个词表做一遍:

import torch
import torch.nn.functional as F

# 模拟:一句话 5 个位置,词表大小 10000,模型给出每个位置的 logits
vocab = 10000
logits = torch.randn(5, vocab)               # 5 个位置,各自对 1 万个词打分
next_tokens = torch.randint(0, vocab, (5,))  # 每个位置真实的"下一个字"

loss = F.cross_entropy(logits, next_tokens)  # 就是对 5 次预测求平均交叉熵
print("语言模型的训练 loss:", loss.item())      # 一个数:模型对这句话有多"意外"

这就是 GPT 训练的全部信息论内核: 每读一个位置,模型对"下一个字"给出一个概率分布 q, 真实的下一个字是 p(one-hot),loss 就是 -log q[真实字]。 模型对真实文本越"不意外",交叉熵越低——训练,就是把这个平均意外一路压下去。 呼应第 0 节主线,闭环了。


🤔 疑惑点二:评价语言模型的"困惑度 perplexity",到底是什么?

困惑度 = e 的交叉熵次方。它把"平均意外几比特"换算成了一个更好懂的数字:"模型平均在几个词之间纠结。"

交叉熵是"平均每一步付多少意外代价",但这个数(比如 2.3)不够直观。 把它取指数,就得到一个有画面感的量:

import torch
import torch.nn.functional as F

logits = torch.randn(20, 100)                # 20 个位置,词表 100
targets = torch.randint(0, 100, (20,))

ce = F.cross_entropy(logits, targets)        # 平均交叉熵(单位:nat)
perplexity = torch.exp(ce)                   # 困惑度 = e^交叉熵
print(f"交叉熵: {ce.item():.3f}   困惑度: {perplexity.item():.1f}")

困惑度的物理意义:模型在预测下一个词时,平均相当于在"这么多个等可能的词"之间猜。

  • 困惑度 = 1:完美,每一步都笃定唯一答案,毫无困惑。
  • 困惑度 = 100(词表也是 100):等于瞎猜,模型什么都没学到。
  • GPT 级别的模型在真实语料上困惑度能压到个位数——意味着它平均只在几个候选词之间纠结, 这就是"语言能力"的量化。

所以困惑度不是玄学:它就是交叉熵换了个更人话的单位。 交叉熵降,困惑度就降, 两者盯的是同一件事——模型对真实语言有多"不意外"。


🤔 疑惑点三:KL 散度在 RLHF / GRPO 里,到底扮演什么角色?

它是一根"拴狗绳":允许强化学习去优化奖励,但不许新模型偏离原模型太远,防止它为了骗高分而胡说八道。

你在做 GRPO / RLHF 时那一项 KL 正则,用信息论翻译过来就一句话: "想涨奖励可以,但你得和参考模型(reference model)说话方式差不多,别学歪了。" 第 4 节说过 KL 衡量"两个分布差多远",这里正好拿来当约束:

import torch
import torch.nn.functional as F

# 同一句话、同一个位置:参考模型 vs 正在训练的策略模型,各自的下一词分布
ref_logits    = torch.tensor([[2.0, 1.0, 0.5, 0.1]])   # 冻住的参考模型
policy_logits = torch.tensor([[2.2, 0.9, 0.4, 0.2]])   # 正在被 RL 更新的模型

logp_policy = F.log_softmax(policy_logits, dim=-1)
logp_ref    = F.log_softmax(ref_logits,    dim=-1)
p_policy    = logp_policy.exp()

# KL(policy ‖ ref) = Σ p_policy · (logp_policy − logp_ref)
kl = (p_policy * (logp_policy - logp_ref)).sum()
print("策略模型偏离参考模型的 KL:", kl.item())   # 很小 —— 还在"拴狗绳"允许范围内

# RL 的总目标 ≈ 最大化奖励 − β · KL(β 越大,绳越短,越不许乱跑)
reward = torch.tensor(1.3)
beta = 0.1
objective = reward - beta * kl
print("带 KL 正则的优化目标:", objective.item())

没有这根 KL 绳,RL 会为了那点奖励分数把语言模型"训崩"(钻奖励模型的空子,输出乱码却拿高分)。 KL 正则就是信息论在你 MathGPT 训练里的现实身份:用"分布别离太远"这个约束,稳住整个 RL 过程。


🎬 动手:亲眼看着 loss 下降 = 模型对数据"越来越不意外"

说了半天"训练就是压低平均意外",不如把它跑一遍、看着交叉熵和困惑度一起往下掉。 一个最小的分类任务,故意让模型从"瞎猜"开始:

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)
# 造一批 3 类数据:每类围绕一个中心
centers = torch.tensor([[2.0, 2.0], [-2.0, 2.0], [0.0, -2.0]])
X = torch.cat([c + torch.randn(200, 2) * 0.6 for c in centers])
y = torch.cat([torch.full((200,), i) for i in range(3)])

model = nn.Sequential(nn.Linear(2, 32), nn.ReLU(), nn.Linear(32, 3))
opt = torch.optim.Adam(model.parameters(), lr=0.05)

for step in range(0, 201):
    logits = model(X)
    loss = F.cross_entropy(logits, y)        # 平均意外(交叉熵)
    ppl = torch.exp(loss)                    # 困惑度 = 平均在几类间纠结
    if step % 40 == 0:
        print(f"step {step:3d}  交叉熵={loss.item():.3f}  困惑度={ppl.item():.2f}")
    opt.zero_grad(); loss.backward(); opt.step()
# 交叉熵从 ~1.1(≈ln3,等于瞎猜)一路掉到接近 0,困惑度从 ~3(三类瞎猜)掉到 ~1

跑完你会看到一串数字:交叉熵从 ln3 ≈ 1.10(三类完全瞎猜的理论值)一路下滑, 困惑度从约 3.0 掉到接近 1.0。 这两串数字,就是"模型对数据从满头问号到胸有成竹"的全过程。

仓库里的动画脚本把它画成了双面板动图:

python cross_entropy_training_visualization.py
  • 左面板:三类数据点 + 模型学出的决策边界。边界从一片混沌,一帧帧清晰地把三坨点切开。
  • 右面板:交叉熵(蓝)和困惑度(红)随训练步数同步下滑的曲线,一路奔向各自的下界(0 和 1)。

这就是"训练"最朴素的信息论真相:右面板那两条下滑的曲线, 就是模型对世界的"平均意外"在被一步步压低——第 0 节那句"逼自己越来越不意外",肉眼可见。


缝合:把所有画面接起来

回到开头,现在每个概念都有了画面和代码出处:

信息论概念 课本怎么讲(抽象) 这篇文章怎么看(画面) 在深度学习里是什么
自信息 -log p 信息量公式 一件事有多让你意外(第 1 节) 单个 token 的预测代价
H(p) 平均信息量 一个分布有多不确定(第 2 节) 数据本身的"难度下界"
交叉熵 H(p,q) 编码代价 用错分布 q 描述真相 p 的代价(第 3 节) F.cross_entropy,训练 loss 本尊
KL 散度 相对熵 认知偏差的额外罚款,不对称(第 4 节) RLHF/GRPO 的 KL 正则(疑惑点三)
困惑度 平均在几个词间纠结(疑惑点二) 语言模型的评测指标

三句话总结这篇文章:

信息论的灵魂是"意外"——概率越低越意外,信息量越大(第 0、1 节); 熵是平均意外,交叉熵是"用错分布"的平均意外,而它正是你训练大模型的 loss(第 2、3、5 节); 最小化交叉熵 = 最小化 KL 散度 = 逼模型分布贴近真实分布,这就是"学习"的信息论定义(第 4 节)。

当年信息论没讲明白,不是因为它难,是因为没人告诉你: 香农那套"熵",最后全都活在一行 F.cross_entropy 里,是你每天都在最小化的东西。 现在把上面每段代码跑一遍,比期末背十遍公式都管用。