Skip to content

第 2 章 概率与统计

本章目标:建立"大模型 = 概率机器"的核心认知,掌握从概率分布到交叉熵、从采样策略到困惑度的完整知识链,并用 NumPy 亲手实现一个微型语言模型的推理全流程。

预计用时:1 周(每天 1~2 小时)

学完你将能够

  • 用条件概率和贝叶斯公式分析日常问题
  • 说清"模型输出本质是一个类别分布"这句话的含义
  • 从最大似然估计推导出"训练语言模型 = 最大化语料似然"
  • 手写交叉熵、KL 散度、softmax、top-p 采样的 NumPy 实现
  • 理解 temperature、top_k、top_p 等解码参数背后的概率学
  • 计算并解释困惑度(Perplexity)这个核心评估指标

2.0 为什么大模型工程师要懂概率

第 0 章你已经跑通了第一次推理。当时我们说"模型本质上是一个巨大的函数,输入一串数字,输出下一个数字最可能是什么的概率分布"。这句话里藏着整本书的钥匙:

  • 训练时,我们调整模型参数,让真实语料出现的概率尽量大(最大似然);
  • 推理时,我们从模型输出的分布中挑选下一个词(采样策略);
  • 评估时,我们问模型对一段新文本"惊讶"程度有多高(困惑度);
  • 对齐时,我们用 KL 散度约束模型不要偏离太远(RLHF)。

可以说,大模型的每一个核心概念,脱掉工程外衣后都是概率论。本章不追求数学严谨性,而是建立直觉——你只需要高中数学基础,加上一点耐心。

💡 经验法则:学这一章时,每看到一个公式,都问自己一句"它在大模型里对应什么"。本章每个知识点都会用 标注这个关联。


2.1 概率基础:从掷骰子到下一个词

2.1.1 随机变量:给不确定性起个名字

**随机变量(Random Variable)**是一个"取值看运气"的变量,通常用大写字母表示,如 $X$。

  • 掷一颗骰子,$X$ 表示点数,$X$ 可能取 $1, 2, 3, 4, 5, 6$;
  • 让语言模型预测"今天天气很___"的下一个词,$X$ 表示下一个词,$X$ 可能取"好""糟""冷"……词表中任意一个词。

随机变量的每个取值都有一个概率,记作 $P(X = \text{好}) = 0.4$,读作"X 取值为'好'的概率是 0.4"。所有取值的概率加起来必须等于 1——这是概率世界的"能量守恒定律"。

离散 vs 连续

  • 离散随机变量:取值可以一一列举(骰子点数、词表中的词)。
  • 连续随机变量:取值是实数轴上的一段(身高、温度),此时我们不说"取某个值的概率"(那几乎是 0),而是说概率密度,用曲线下的面积表示概率。

→ 这个在大模型里用在哪:语言模型的输出层面对的是一个离散随机变量——词表大小通常 3 万~15 万,模型每次预测就是给这十几万个候选词各分配一个概率。本章后续的 softmax、采样策略,全都在和这个离散分布打交道。

2.1.2 联合概率、边缘概率、条件概率

用一个生活例子一次讲清三个概念。假设统计了 100 名学生的"是否熬夜"和"是否挂科":

挂科没挂科合计
熬夜203050
不熬夜54550
合计2575100

设 $A$ = 熬夜,$B$ = 挂科。

  • 联合概率 $P(A, B)$:两件事同时发生的概率。 $P(\text{熬夜}, \text{挂科}) = 20/100 = 0.2$(表中间那个格子除以总数)。
  • 边缘概率 $P(A)$:只看一个变量,把另一个变量"加总消掉"(边缘这个名字就来自表格的边)。 $P(\text{熬夜}) = 50/100 = 0.5$,$P(\text{挂科}) = 25/100 = 0.25$。
  • 条件概率 $P(B \mid A)$:在已知 $A$ 发生的前提下,$B$ 发生的概率。竖线读作"给定"。 $P(\text{挂科} \mid \text{熬夜}) = 20/50 = 0.4$——只看"熬夜"那一行,挂科的比例。

三者之间有一个极其重要的关系,叫乘法规则

$$P(A, B) = P(A) \cdot P(B \mid A)$$

翻译成人话:"两件事一起发生的概率" = "第一件发生的概率" × "在第一件发生的前提下第二件发生的概率"。

→ 这个在大模型里用在哪:语言模型做的事就是条件概率。模型读到的上文是 $A$("今天天气很"),要预测下一个词 $B$ 的概率 $P(B \mid A)$。而一整句话的概率,就是乘法规则的反复套用: $$P(\text{我 爱 北 京}) = P(\text{我}) \cdot P(\text{爱} \mid \text{我}) \cdot P(\text{北} \mid \text{我 爱}) \cdot P(\text{京} \mid \text{我 爱 北})$$ 这个"逐个词分解"的公式叫链式法则(chain rule),是自回归语言模型(GPT 系列)的理论根基。记住它,第 4 章讲 Transformer 时会重逢。

2.1.3 贝叶斯公式:根据证据更新信念

继续上面的例子。现在换一个问法:已知一个学生挂科了,他熬夜的概率是多少?

我们要的是 $P(\text{熬夜} \mid \text{挂科})$,但表格方便读出的是 $P(\text{挂科} \mid \text{熬夜})$。贝叶斯公式做的就是这个"条件调换":

$$P(A \mid B) = \frac{P(B \mid A) \cdot P(A)}{P(B)}$$

代入数字(在 25 个挂科学生里,20 个熬夜):

$$P(\text{熬夜} \mid \text{挂科}) = \frac{0.4 \times 0.5}{0.25} = 0.8$$

直觉解读:贝叶斯公式是一种"用新证据更新旧认知"的思维方式。我先有一个先入为主的判断(先验 $P(A) = 0.5$),看到证据 $B$(挂科)后,把判断更新为 后验 $P(A \mid B) = 0.8$。证据越"反常"($P(B)$ 越小),更新幅度越大。

📝 练习 2.1:某疾病患病率为 0.1%,检测的准确率为 99%(患病者 99% 检出阳性,健康者 99% 检出阴性)。小明检测结果为阳性,用贝叶斯公式算他真的患病的概率。结果会远低于你的直觉——想想为什么。(提示:分母 $P(\text{阳性})$ 要算上"健康但误报"的部分。)

→ 这个在大模型里用在哪:贝叶斯思维贯穿机器学习——模型参数可以被看作"假设",训练数据是"证据",训练就是不断根据证据更新对参数的信念。更直接的应用:检索增强生成(RAG,第 6 章)中,检索到的文档就是"证据",模型基于证据更新对答案的判断。


2.2 期望、方差与三种必知分布

2.2.1 期望:平均意义上的结果

**期望(Expectation)**是随机变量的"加权平均值",权重就是概率:

$$E[X] = \sum_{i} x_i \cdot P(X = x_i)$$

符号说明:$E[X]$ 读作"X 的期望",$\sum$ 是求和符号,$x_i$ 是第 $i$ 个取值,$P(X=x_i)$ 是它的概率。

例子:一场抽奖,1% 概率中 100 元,99% 概率中 0 元,期望收益 $= 100 \times 0.01 + 0 \times 0.99 = 1$ 元。期望不一定是可能取到的值——它回答的是"玩一万次,平均每次得多少"。

2.2.2 方差:结果的波动有多大

**方差(Variance)**衡量随机变量偏离期望的程度:

$$\text{Var}(X) = E\left[(X - E[X])^2\right]$$

即"每个取值与期望之差的平方,再求期望"。方差大 = 结果飘忽不定;方差小 = 结果稳定。它的平方根 $\sigma = \sqrt{\text{Var}(X)}$ 叫标准差,和原始数据同一个量纲,更直观。

→ 这个在大模型里用在哪:方差的概念会反复出现——评估模型输出稳定性、理解 LayerNorm 为什么按方差归一化(第 4 章)、分析 RLHF 训练波动,都离不开它。

2.2.3 伯努利分布:一次是非题

**伯努利分布(Bernoulli)**描述只有两种结果的试验:成功(概率 $p$)或失败(概率 $1-p$)。抛一枚硬币、一个用户点不点广告,都是伯努利试验。期望为 $p$,方差为 $p(1-p)$。

2.2.4 类别分布(Categorical):伯努利的"多选项版"

如果有 $K$ 个可能结果,各自概率为 $p_1, p_2, \dots, p_K$(加起来等于 1),就是类别分布。掷骰子是 $K=6$ 的类别分布,语言模型预测下一个词是 $K=\text{词表大小}$ 的类别分布。

python
import numpy as np  # 本书所有代码都默认 import numpy as np

# 一个 K=4 的类别分布:模型认为下一个词的概率
vocab = ["好", "糟", "冷", "热"]
probs = np.array([0.5, 0.1, 0.3, 0.1])  # 必须加起来等于 1

assert np.isclose(probs.sum(), 1.0)  # 校验概率和为 1,不通过会报错

# 从这个分布中采样 10 次(模拟模型生成 10 次)
np.random.seed(42)  # 固定随机种子,保证结果可复现
samples = np.random.choice(vocab, size=10, p=probs)
print(samples)  # 大概率以"好"和"冷"居多

→ 这个在大模型里用在哪这是本章最重要的一句话——语言模型每预测一个 token,输出的就是一个类别分布。 所谓"生成文本",就是从一连串类别分布里一次次抽样。2.6 节的 softmax 就是"如何把模型的原始分数变成合法的类别分布",2.7 节就是"怎么从这个分布里挑词"。

2.2.5 高斯分布:连续世界的王者

**高斯分布(Gaussian / 正态分布)**是最常见的连续分布,形状是钟形曲线,由两个参数完全决定:均值 $\mu$(中心在哪)和标准差 $\sigma$(胖瘦)。概率密度函数:

$$p(x) = \frac{1}{\sqrt{2\pi},\sigma} \exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)$$

不用背这个公式,记住三个直觉:关于 $\mu$ 对称;约 68% 的概率落在 $\mu \pm \sigma$ 内;$\sigma$ 越小曲线越尖。

python
# 从标准高斯分布(mu=0, sigma=1)采样 10000 个点
x = np.random.normal(loc=0.0, scale=1.0, size=10000)
print(f"均值: {x.mean():.3f}, 标准差: {x.std():.3f}")  # 应接近 0 和 1

→ 这个在大模型里用在哪:① 神经网络的权重初始化通常从高斯分布采样;② 变分自编码器(VAE)等生成模型直接用高斯分布建模隐变量;③ 理解"为什么初始化太大会导致梯度爆炸",要靠高斯的方差性质(第 3 章)。


2.3 最大似然估计(MLE):让数据自己"投票"出参数

2.3.1 猜硬币的例子

你拿到一枚来路不明的硬币,不知道正面概率 $p$ 是多少。你抛了 10 次,结果是:7 次正面,3 次反面。问:$p$ 最有可能是多少?

直觉答案脱口而出:$p = 0.7$。这个直觉背后的正式方法,就是最大似然估计(Maximum Likelihood Estimation)

似然(Likelihood):在某个假设 $p$ 下,观察到这组数据的概率。10 次抛掷(7 正 3 反)的似然是:

$$L(p) = p^7 \cdot (1-p)^3$$

我们的问题变成:哪个 $p$ 让 $L(p)$ 最大?这就是"最大似然"的字面意思——让已经发生的事情,概率最大化

用 NumPy 暴力验证一下直觉:

python
# 在 0~1 之间密集地尝试各种 p,看哪个让似然最大
candidates = np.linspace(0.01, 0.99, 99)     # 99 个候选 p 值
likelihoods = candidates**7 * (1 - candidates)**3  # 每个 p 对应的似然

best_p = candidates[np.argmax(likelihoods)]  # 找似然最大的那个 p
print(f"最大似然估计 p = {best_p:.2f}")       # 输出 0.70,正是直觉答案

2.3.2 对数似然:把乘法变加法

实际计算中没人直接优化 $p^7 (1-p)^3$——成千上万个小数连乘会下溢成 0。标准技巧是取对数(对数是单调函数,不改变最大值的位置):

$$\log L(p) = 7\log p + 3\log(1-p)$$

乘法变加法,数值稳定,求导方便。优化时通常再加个负号变成负对数似然(NLL),把"最大化"转成机器学习习惯的"最小化损失":

$$\text{NLL}(p) = -\big(7\log p + 3\log(1-p)\big)$$

→ 这个在大模型里用在哪训练语言模型,本质就是对模型参数做最大似然估计。 语料里每出现一句话,链式法则就把它拆成一连串条件概率的乘积;训练目标就是让真实语料在这套概率下总似然最大。具体地,对每个位置,我们最大化真实下一个词的 $\log P(\text{真实词} \mid \text{上文})$——这正等于最小化下一节要讲的交叉熵损失。你此刻学到的硬币例子,和 GPT 的训练目标是同一个数学原理,只是参数从 1 个变成了几千亿个。

📝 练习 2.2:把上面的代码改成"抛 100 次、75 次正面",重新估计 $p$。再想想:如果只抛了 2 次、2 次都是正面,MLE 会给出 $p=1$——这个估计靠谱吗?这说明 MLE 在什么情况下会"过度自信"?


2.4 交叉熵:量化"惊讶程度"的损失函数

2.4.1 信息论直觉:越意外,信息量越大

信息论中,一个概率为 $p$ 的事件发生时,它携带的信息量定义为:

$$I = -\log p$$

为什么是 $-\log p$?看它如何刻画"惊讶程度":

事件概率 $p$$-\log_2 p$(比特)
太阳从东边升起≈ 1≈ 0(毫不惊讶,没有信息量)
抛硬币出正面0.51
掷骰子掷出 61/6≈ 2.58
中彩票头奖$10^{-7}$≈ 23.3(极其惊讶,信息量巨大)

对数底数取 2 时单位是比特(bit),取 $e$(自然对数 $\ln$)时单位是纳特(nat)。深度学习中习惯用自然对数,本书后续代码都用 np.log

2.4.2 从信息量到交叉熵

一个分布的**熵(Entropy)**是平均信息量:$H(p) = -\sum_i p_i \log p_i$。它衡量这个分布本身有多"不确定"——均匀分布熵最大,一边倒的分布熵接近 0。

**交叉熵(Cross Entropy)**回答另一个问题:如果真实分布是 $p$,但我用分布 $q$ 去"猜",平均惊讶程度是多少?

$$H(p, q) = -\sum_i p_i \log q_i$$

训练语言模型时,情况会大大简化:真实的下一个词只有一个(比如"京"),所以真实分布 $p$ 是 one-hot 分布——"京"的位置是 1,其余全是 0。代入上式,求和里只有一项存活:

$$\text{loss} = -\log q(\text{真实词})$$

交叉熵损失 = 真实词概率的负对数。就这么简单:模型给真实词的概率越高,损失越小;给的概率越低,惩罚越狠(因为对数在接近 0 时趋向负无穷)。

2.4.3 NumPy 手写交叉熵

python
def cross_entropy(probs: np.ndarray, target: int) -> float:
    """计算单个样本的交叉熵损失。

    参数:
        probs:  模型输出的概率分布(已归一化,和为 1)
        target: 真实词的索引
    返回:
        标量损失 = -log(真实词的概率)
    """
    # 加 1e-12 防止 probs[target] 恰好为 0 时 log(0) 爆炸成 -inf
    return -np.log(probs[target] + 1e-12)


# 场景:模型预测"我爱北"的下一个词,词表 = ["京", "沪", "海"]
# 模型 A:胸有成竹,给了"京" 0.9 的概率
probs_A = np.array([0.9, 0.05, 0.05])
# 模型 B:一脸茫然,几乎均匀瞎猜
probs_B = np.array([0.3, 0.4, 0.3])

print(f"模型 A 的损失: {cross_entropy(probs_A, target=0):.4f}")  # ≈ 0.105
print(f"模型 B 的损失: {cross_entropy(probs_B, target=0):.4f}")  # ≈ 1.204

模型 B 的损失是 A 的 10 倍多——交叉熵用数字精确惩罚了"不自信且错误的预测"。训练就是不断调整参数,让语料上所有位置的平均交叉熵越降越低。

→ 这个在大模型里用在哪:交叉熵是语言模型训练的默认损失函数,没有之一。你在训练日志里看到的 loss: 2.31 -> 1.85 -> ... 就是它。记住这个对应关系:loss 降 = 模型给真实下一个词的平均概率在涨

📝 练习 2.3:如果模型给真实词的概率是 1(完美预测),损失是多少?如果概率是 0.01 呢?把这两个值算出来,感受交叉熵的"惩罚曲线"形状。


2.5 KL 散度:两个分布之间的"距离"

2.5.1 定义与直觉

**KL 散度(Kullback–Leibler Divergence)**衡量"用分布 $q$ 近似真实分布 $p$ 时,损失了多少信息":

$$D_{KL}(p ,|, q) = \sum_i p_i \log \frac{p_i}{q_i} = H(p, q) - H(p)$$

读法:"$p$ 对 $q$ 的 KL 散度"。直觉:它等于"交叉熵 − 自身熵",即"用 $q$ 猜 $p$ 的惊讶程度"减去"$p$ 自身的固有不确定性",剩下的就是因为用错了分布而多付的代价

两个关键性质:

  1. 非负:$D_{KL} \geq 0$,且 $D_{KL} = 0$ 当且仅当 $p = q$(完全没有近似损失)。
  2. 不对称:$D_{KL}(p | q) \neq D_{KL}(q | p)$。它不是真正的"距离"——更像"从 $p$ 的世界搬到 $q$ 的世界需要多少适应成本",两个方向的搬家成本不一样。

2.5.2 NumPy 验证不对称性

python
def kl_divergence(p: np.ndarray, q: np.ndarray) -> float:
    """计算 D_KL(p || q),要求 p、q 都是合法概率分布。"""
    # 只在 p_i > 0 的位置累加(约定 0 * log(0/q) = 0)
    mask = p > 0
    return np.sum(p[mask] * np.log(p[mask] / q[mask]))


p = np.array([0.7, 0.2, 0.1])  # 真实分布:偏重第一个选项
q = np.array([0.3, 0.4, 0.3])  # 近似分布:接近均匀

print(f"D_KL(p || q) = {kl_divergence(p, q):.4f}")  # ≈ 0.345
print(f"D_KL(q || p) = {kl_divergence(q, p):.4f}")  # ≈ 0.353,不相等!
print(f"D_KL(p || p) = {kl_divergence(p, p):.4f}")  # = 0,自己对自己没有损失

2.5.3 大模型中的两个重要应用

应用一:知识蒸馏(Knowledge Distillation)。让一个"小模型(学生)"去模仿"大模型(教师)"的输出分布。损失函数就是 $D_{KL}(q_{\text{教师}} ,|, q_{\text{学生}})$——不只学标准答案(硬标签),还学教师对每个候选词的概率分配(软标签),后者信息量丰富得多。DeepSeek-R1 蒸馏出的一系列小模型就用了这个思想(第 6 章详解)。

应用二:RLHF 的 KL 惩罚。用强化学习微调模型时,如果只追求"奖励模型打分高",模型会走火入魔——生成迎合奖励模型但语无伦次的文本。解决办法是在奖励里加一项 KL 惩罚:

$$\text{目标} = \text{奖励} - \beta \cdot D_{KL}(\pi_{\text{新}} ,|, \pi_{\text{原始}})$$

其中 $\pi_{\text{原始}}$ 是微调前的模型,$\beta$ 是惩罚力度。含义很直白:你可以为了高分改变行为,但不许离原来的自己太远(第 7 章详解)。

📝 练习 2.4:设 $p = [0.5, 0.5]$,$q_1 = [0.5, 0.5]$,$q_2 = [0.9, 0.1]$,$q_3 = [0.1, 0.9]$。用上面的函数计算 $D_{KL}(p | q_2)$ 和 $D_{KL}(p | q_3)$,验证对称位置的两对分布 KL 值相等,但 $D_{KL}(q_2 | p) \neq D_{KL}(p | q_2)$。


2.6 Softmax 与温度:把分数变成概率

2.6.1 Softmax 公式

模型的输出层并不直接吐概率,而是吐一串任意实数,叫 logits(可以负、可以大于 1、加起来不为 1)。把 logits 变成合法概率分布的标准操作是 softmax

$$\text{softmax}(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}}$$

含义:对每个分数取指数(全变正数、放大差距),再除以总和(归一化到和为 1)。指数函数让"强者更强"——logit 最高的词会得到远高于其他人的概率,但第二名也保留了机会。

2.6.2 温度 T:分布的"锐度旋钮"

带温度的 softmax 只是加了一个参数 $T$:

$$\text{softmax}(z_i / T) = \frac{e^{z_i / T}}{\sum_j e^{z_j / T}}$$

  • $T < 1$:logits 被放大,分布变尖锐——高分词独大,输出更确定、更保守;
  • $T = 1$:原始 softmax;
  • $T > 1$:logits 被压缩,分布变平坦——低分词也有机会,输出更随机、更"有创造力";
  • $T \to 0$ 的极限:退化成 argmax(永远选最高分,即贪心解码)。
python
def softmax(logits: np.ndarray, temperature: float = 1.0) -> np.ndarray:
    """带温度的 softmax,含数值稳定处理。"""
    z = logits / temperature
    z = z - np.max(z)      # 关键技巧:减去最大值防止 e^x 溢出
    exp_z = np.exp(z)      # 不改变结果(分子分母同除 e^max)
    return exp_z / exp_z.sum()


# 模拟模型对 4 个候选词的 logits 输出
logits = np.array([3.0, 1.5, 0.5, -1.0])
words = ["好", "糟", "冷", "热"]

for T in [0.5, 1.0, 2.0]:
    probs = softmax(logits, temperature=T)
    pretty = ", ".join(f"{w}: {p:.3f}" for w, p in zip(words, probs))
    print(f"T={T}:  {pretty}")

预期输出(数值可能因版本略有差异):

T=0.5:  好: 0.946, 糟: 0.047, 冷: 0.006, 热: 0.000   ← 尖锐,"好"独大
T=1.0:  好: 0.756, 糟: 0.169, 冷: 0.062, 热: 0.014   ← 原始分布
T=2.0:  好: 0.528, 糟: 0.249, 冷: 0.151, 热: 0.071   ← 平坦,大家都有机会

→ 这个在大模型里用在哪:你调用任何大模型 API 时看到的 temperature 参数,就是这个 $T$。经验法则:写代码、问答等要求准确的场景用低温度(0~0.3);创意写作、头脑风暴用高温度(0.8~1.5)。temperature=0 在大多数框架里等价于贪心解码。

📝 练习 2.5:把 T 调成 10.0 和 0.1,观察分布的极限行为。T 极大时分布趋向什么?(答案:均匀分布。)想想为什么。


2.7 采样策略的概率学:从分布里挑词的艺术

模型给出概率分布后,还有最后一道决策:选哪个词? 四种主流策略:

2.7.1 Greedy(贪心):永远选概率最高的

每一步都选 argmax。优点:稳定、可复现;缺点:容易陷入重复循环("我觉得我觉得我觉得……"),且全局不一定最优。

2.7.2 随机采样:完全按概率抽签

python
vocab_size = 10000                      # 假设词表有 10000 个词
probs = np.full(vocab_size, 1 / vocab_size)  # 一个均匀分布作示例
next_word_id = np.random.choice(vocab_size, p=probs)  # 按概率抽签

忠实于分布,多样性最好,但长尾里的低质量词也可能被抽中——分布尾部那些概率 0.1% 的词累加起来可能占 5%,采多了就会"胡言乱语"。

2.7.3 Top-k:只在概率前 k 名里抽

把排名 $k$ 之后的词概率全部清零,剩余重新归一化再采样。简单粗暴地砍掉了长尾,但 $k$ 是固定值:分布尖锐时 $k=50$ 太宽(放进了垃圾),分布平坦时 $k=50$ 又太窄(扼杀了多样性)。

2.7.4 Top-p(Nucleus Sampling):按累积概率动态截取

核心思想:从概率最高的词开始往下累加,累积概率刚超过阈值 $p$(如 0.9)就停,只在这个"核(nucleus)"里采样。候选集合的大小随分布形状自动伸缩——分布尖锐时核很小,平坦时核很大。

python
def top_p_sampling(probs: np.ndarray, p: float = 0.9) -> int:
    """Top-p(核采样):返回采样到的词索引。

    步骤:排序 -> 找累积概率超过 p 的截断点 -> 截断 -> 归一化 -> 采样
    """
    # 1. 按概率从大到小排序,得到索引
    sorted_idx = np.argsort(probs)[::-1]
    sorted_probs = probs[sorted_idx]

    # 2. 计算累积概率,找到刚超过 p 的位置
    cum_probs = np.cumsum(sorted_probs)          # 例如 [0.64, 0.78, 0.83, ...]
    cutoff = np.searchsorted(cum_probs, p) + 1   # +1 保证核至少覆盖到 p

    # 3. 截断:只保留前 cutoff 个词("核")
    nucleus_idx = sorted_idx[:cutoff]
    nucleus_probs = probs[nucleus_idx]

    # 4. 核内重新归一化(让概率和回到 1)
    nucleus_probs = nucleus_probs / nucleus_probs.sum()

    # 5. 在核内按概率采样
    return np.random.choice(nucleus_idx, p=nucleus_probs)


# 演示:同一个分布在不同 p 下的"核"大小
probs = np.array([0.50, 0.20, 0.12, 0.08, 0.05, 0.03, 0.02])
cum = np.cumsum(np.sort(probs)[::-1])
for p in [0.7, 0.9, 0.99]:
    k = np.searchsorted(cum, p) + 1
    print(f"p={p}: 核内包含 {k} 个词")

输出显示:$p=0.7$ 时核只有 2 个词,$p=0.9$ 时核扩到 5 个,$p=0.99$ 时全部 7 个词都进了核——阈值 p 直接控制了"允许多大程度的冒险"

→ 这个在大模型里用在哪generate() 和 OpenAI API 的解码参数与这里一一对应:do_sample=False = greedy;top_ktop_p 如上;temperature 在采样前先调整分布锐度。实战中的经典组合是 temperature=0.7, top_p=0.9。第 5 章会系统讲推理参数调优。

📝 练习 2.6:修改上面的演示,分别用一个"很尖锐"的分布(如 [0.95, 0.04, 0.01])和一个"很平"的分布(如 10 个词各 0.1),固定 p=0.9,观察两种情况下核的大小差异。这就是 Top-p 比 Top-k 更聪明的原因。


2.8 困惑度(Perplexity):语言模型的"考试成绩"

2.8.1 定义:平均惊讶程度的指数化

一个语言模型在测试集上的困惑度定义为:

$$\text{PPL} = \exp\left(-\frac{1}{N}\sum_{t=1}^{N} \log P(w_t \mid w_{<t})\right) = \exp(\text{平均交叉熵})$$

其中 $N$ 是测试集的 token 总数。它就是平均交叉熵取指数——单调变换,不改变大小比较,但让数值有了漂亮的直觉解释:

困惑度 ≈ 模型在每个位置"平均纠结于多少个候选词"。

PPL = 1:完美预测,毫不纠结(理论下限);PPL = 10:平均每步像在 10 个候选词中犹豫;PPL = 词表大小:完全瞎猜(均匀分布)。越低越好

2.8.2 手算一个小例子

测试句子:"我 爱 北 京"(4 个 token)。模型给出的条件概率如下:

位置预测模型给的概率$-\ln P$
1我(句首)0.102.303
2爱 | 我0.301.204
3北 | 我 爱0.500.693
4京 | 我 爱 北0.800.223

平均交叉熵 $= (2.303 + 1.204 + 0.693 + 0.223) / 4 = 1.106$

$$\text{PPL} = e^{1.106} \approx 3.02$$

模型在这句话上平均"在 3 个词之间犹豫"。用 NumPy 验证:

python
probs = np.array([0.10, 0.30, 0.50, 0.80])  # 每个位置真实词的概率
avg_ce = -np.log(probs).mean()              # 平均交叉熵
ppl = np.exp(avg_ce)                        # 困惑度
print(f"平均交叉熵: {avg_ce:.3f}, 困惑度: {ppl:.2f}")  # 1.106, 3.02

→ 这个在大模型里用在哪:PPL 是语言模型最经典的评估指标。开源模型技术报告里常写"在 WikiText-2 上 PPL = X.X";训练时验证集 PPL 不再下降是 early stopping 的信号。但要注意:PPL 低 ≠ 下游任务强,现代评估还要配合 MMLU、HumanEval 等基准(第 8 章详解)。

📝 练习 2.7:如果模型对所有位置都给出均匀分布(词表大小 $V$,每个词概率 $1/V$),推导并验证 PPL 恰好等于 $V$。这就是"PPL = 平均候选数"直觉的最直白印证。


2.9 综合实战:3 词词表的微型语言模型

把本章所有知识点串成一条流水线。场景:一个词表只有 3 个词 ["猫", "狗", "鱼"] 的"语言模型",输入上句"我养了一只",输出 logits,我们完成从 softmax 到困惑度的完整流程。

python
"""第 2 章综合实战:微型语言模型的完整推理流程"""
import numpy as np

np.random.seed(42)  # 固定种子,结果可复现

# ========== 0. 场景设定 ==========
vocab = ["猫", "狗", "鱼"]          # 3 词词表
logits = np.array([2.5, 1.0, 0.2])  # 模型对下一个词的原始打分(logits)
target = 0                          # 语料中真实的下一个词是"猫"(索引 0)

# ========== 1. Softmax:logits -> 概率分布 ==========
def softmax(logits, temperature=1.0):
    z = logits / temperature
    z = z - np.max(z)               # 数值稳定:防 exp 溢出
    e = np.exp(z)
    return e / e.sum()

probs = softmax(logits)
print("1. 原始分布:", dict(zip(vocab, probs.round(4))))
#    猫 ≈ 0.76, 狗 ≈ 0.17, 鱼 ≈ 0.08 —— 模型相当确定是"猫"

# ========== 2. 温度调整:T=1.5 让分布更平 ==========
probs_t = softmax(logits, temperature=1.5)
print("2. T=1.5 后:", dict(zip(vocab, probs_t.round(4))))
#    分布被"熨平",采样时低概率词机会变多

# ========== 3. Top-p 采样:p=0.8 ==========
def top_p_sampling(probs, p):
    idx = np.argsort(probs)[::-1]            # 概率从大到小排序
    cum = np.cumsum(probs[idx])              # 累积概率
    k = np.searchsorted(cum, p) + 1          # 核的大小
    nucleus = idx[:k]                        # 核内词索引
    sub = probs[nucleus] / probs[nucleus].sum()  # 核内归一化
    return np.random.choice(nucleus, p=sub), nucleus

chosen, nucleus = top_p_sampling(probs_t, p=0.8)
print(f"3. Top-p=0.8 核 = {[vocab[i] for i in nucleus]}, 采样结果: {vocab[chosen]}")
#    注意:原始分布下"猫"独占 0.76,p=0.8 时核里只有"猫""狗"

# ========== 4. 交叉熵:评估模型对真实词"猫"的预测质量 ==========
ce = -np.log(probs[target] + 1e-12)          # 用原始分布(T=1)算训练损失
print(f"4. 交叉熵损失: {ce:.4f}")             # ≈ 0.28,模型预测不错

# ========== 5. 困惑度:假设这是一段只有 1 个 token 的测试集 ==========
ppl = np.exp(ce)                             # 单 token 时 PPL = exp(CE)
print(f"5. 困惑度: {ppl:.2f}")                # ≈ 1.32,接近"几乎不犹豫"

运行它,逐行对照输出:你会亲眼看到 logits 如何变成概率、温度如何熨平分布、Top-p 如何圈定候选核、交叉熵和困惑度如何给这次预测打分。这条流水线放大 1000 亿倍,就是 GPT 的全部推理过程。

📝 练习 2.8(本章大作业):扩展上面的代码,模拟一个两步生成:第一步采样出"猫"后,手工给第二步编造一组新的 logits(模拟"我养了一只猫,它喜欢吃___"),再跑一遍完整流程。并计算这两个 token 组成的小语料上的整体困惑度(提示:把两个位置的 $-\log P$ 平均后取指数)。


本章小结

知识点一句话总结
条件概率与链式法则语言模型就是 $P(\text{下一个词} \mid \text{上文})$,整句概率 = 链式分解
贝叶斯公式用证据更新信念:后验 ∝ 似然 × 先验
期望与方差平均结果与波动程度,贯穿初始化、归一化、训练稳定性
类别分布模型输出的本质——每次预测都是词表上的一个类别分布
最大似然估计让真实数据概率最大化;训练 LLM = 对语料做 MLE
交叉熵$-\log P(\text{真实词})$,越不自信惩罚越狠,是训练默认损失
KL 散度分布间的非对称"距离",用于知识蒸馏与 RLHF 的 KL 惩罚
Softmax 与温度logits → 概率;T 小则尖锐保守,T 大则平坦多样
Top-k / Top-p截断长尾再采样;Top-p 的候选核随分布形状自适应
困惑度$\exp(\text{平均交叉熵})$,直觉是"平均在多少个词间犹豫"

本章实战验收

  • [ ] 能用链式法则手推一句话的概率分解,并解释它与自回归生成的关系
  • [ ] 完成贝叶斯疾病检测练习(练习 2.1)并能向人解释反直觉的结果
  • [ ] 手写 cross_entropykl_divergencesoftmaxtop_p_sampling 四个函数且全部跑通
  • [ ] 解释 temperature=0.1temperature=2.0 下模型行为会有什么不同
  • [ ] 完成 2.9 综合实战与练习 2.8,算出一个两 token 语料的困惑度
  • [ ] 能说出"训练 LLM = MLE = 最小化交叉熵"这条等价链

拓展阅读

  • 3Blue1Brown 的贝叶斯与信息论系列视频 —— 本章直觉的最佳视觉化补充
  • Christopher Bishop《Pattern Recognition and Machine Learning》第 1~2 章 —— 想补数学严谨性的话
  • Hugging Face 博客 How to generate —— 各种解码策略的图解与代码
  • Shannon 1948 年论文 A Mathematical Theory of Communication —— 信息论的源头,前两章即可读懂