查看全课大纲(第 12 / 69 节)
  1. 1.NLP 的研究领域、应用与学习路径
  2. 2.自然语言的基本术语
  3. 3.信息论基础:信息熵与交叉熵
  4. 4.语言的进化与语义变迁
  5. 5.主观概率、客观概率与条件概率
  6. 6.贝叶斯原理与推理
  7. 7.随机变量:二项式、期望方差与联合概率
  8. 8.常见概率分布
  9. 9.辛普森悖论与贝叶斯解题实例
  10. 10.语言模型:语言概率与词袋模型
  11. 11.n-gram 语言模型(二元 / 三元 / N-Gram)
  12. 12.语言模型评价:交叉熵、困惑度与插值
  13. 13.朴素贝叶斯文本分类:垃圾邮件
  14. 14.拼写检查
  15. 15.中文分词:构词法、最大匹配、最优路径与 Jieba
  16. 16.词表示问题与语义相似度
  17. 17.TF-IDF 权重处理
  18. 18.One-Hot 表达与词表示的局限
  19. 19.Word2Vec 核心思想
  20. 20.Word2Vec 训练过程
  21. 21.Skip-gram 模型详解
  22. 22.词性标注任务与标注方法(规则 + N-Gram)
  23. 23.EM 算法与混合模型
  24. 24.高斯混合模型 GMM
  25. 25.从混合模型到隐马尔可夫模型
  26. 26.隐马尔可夫模型(HMM)推导与维特比解码
  27. 27.线性链条件随机场 CRF
  28. 28.贝叶斯网络与图模型
  29. 29.隐马尔可夫链与 HMM 推导
  30. 30.PLSA 主题模型
  31. 31.LSI 与 SVD 向量表达
  32. 32.LDA 与狄利克雷分布
  33. 33.主题模型实验复现(困惑度 / 分类 / 相似度 / 延展)
  34. 34.上下文无关文法(CFG)与句法分析
  35. 35.概率上下文无关文法 PCFG
  36. 36.统计机器翻译:词对齐与翻译模型
  37. 37.统计机器翻译中的对齐模型与参数估计
  38. 38.从统计机器翻译到神经机器翻译
  39. 39.神经元与反向传播速览(指向深度学习课)
  40. 40.CNN 原理及其在 NLP 中的应用
  41. 41.循环神经网络的基本原理与 Elman / Jordan 网络
  42. 42.LSTM:长短期记忆网络的核心思想与分步实现
  43. 43.Encoder-Decoder 框架
  44. 44.Seq2Seq 与早期注意力机制
  45. 45.注意力机制的背景与动机
  46. 46.注意力模型实现原理与匹配函数
  47. 47.记忆网络:组成与计算
  48. 48.注意力机制的应用与历史延展
  49. 49.Transformer 结构:Encoder-Decoder 栈与 Self-Attention
  50. 50.Multi-Head Attention、FFNN 与位置编码
  51. 51.层归一化、损失函数与 Universal Transformer 延展
  52. 52.预训练范式:从 ELMo 到 BERT
  53. 53.BERT 与掩码语言模型
  54. 54.BERT 下游任务微调与 GLUE 口径
  55. 55.GPT 与自回归语言建模
  56. 56.GPT-1 到 GPT-4 的演进
  57. 57.指令微调与 RLHF
  58. 58.分词器:BPE、WordPiece 与中文词表
  59. 59.上下文窗口与长文本处理
  60. 60.能力边界、成本与适用场景
  61. 61.文本分类实战:从朴素贝叶斯到逻辑回归
  62. 62.命名实体识别实战
  63. 63.文本生成与摘要实战
  64. 64.语义检索与向量库实战
  65. 65.RAG 知识库问答
  66. 66.NLP 评估指标:BLEU / ROUGE / BERTScore 与人工评估
  67. 67.幻觉与事实性问题
  68. 68.文本与知识结合(知识图谱压缩)
  69. 69.跨媒体信息检索与未来展望

语言模型评价:交叉熵、困惑度与插值

小象实战讲义 · 自然语言处理

在上一节中,我们学习了如何构建n-gram语言模型来估计句子出现的概率。然而,面对多种模型(如unigram, bigram, trigram),我们如何客观地评价它们的优劣?哪种模型更能捕捉语言的规律?本节将引入信息论中的核心工具——交叉熵(Cross-Entropy)和困惑度(Perplexity),为语言模型提供一套可量化的评价标准。我们还将学习插值法(Interpolation),一种结合不同阶数n-gram模型优势的平滑技术。学完本节,你将能够计算并比较不同语言模型的性能,并理解其背后的信息论意义。

💡 核心导读

  • 交叉熵:衡量真实语言分布与我们模型预测分布之间的“距离”。交叉熵越低,模型预测下一个词的能力越强。
  • 困惑度:交叉熵的指数形式,直观理解为模型在预测下一个词时的“平均分支因子”或不确定性。困惑度越低,模型越好。
  • 信息论视角:交叉熵和困惑度与数据压缩密切相关。一个好的语言模型能更高效地编码文本,平均每个词所需的比特数更少。
  • 模型插值:通过加权组合不同阶数的n-gram模型(如unigram, bigram, trigram),可以缓解数据稀疏问题,获得更鲁棒的概率估计。

1. 从熵到交叉熵:衡量预测的不确定性

在信息论中,熵(Entropy) H(X)H(X) 衡量的是一个随机变量 XX 的不确定性。对于一个离散概率分布 P(x)P(x),其熵定义为: H(P)=−∑x∈XP(x)log⁡2P(x)H(P) = -\sum_{x \in \mathcal{X}} P(x) \log_2 P(x) 熵的单位是比特(bit)。它表示,如果我们想对事件 XX 进行最优编码(如霍夫曼编码),平均每个事件需要的最少比特数。

交叉熵(Cross-Entropy) H(P,Q)H(P, Q) 则用于衡量当我们使用一个估计的分布 QQ 来近似真实分布 PP 时,编码事件所需的平均比特数。其定义为: H(P,Q)=−∑x∈XP(x)log⁡2Q(x)H(P, Q) = -\sum_{x \in \mathcal{X}} P(x) \log_2 Q(x) 在语言模型的上下文中:

  • PP 是真实但未知的语言分布(一个词序列出现的真实概率)。
  • QQ 是我们的语言模型(如n-gram模型)给出的概率分布 PmodelP_{\text{model}}。

对于一个由 MM 个词组成的测试句子 w1,w2,...,wMw_1, w_2, ..., w_M,其每个词的交叉熵可以近似为: Hper-word≈−1Mlog⁡2Pmodel(w1,w2,...,wM)H_{\text{per-word}} \approx -\frac{1}{M} \log_2 P_{\text{model}}(w_1, w_2, ..., w_M) 这里,Pmodel(w1,w2,...,wM)P_{\text{model}}(w_1, w_2, ..., w_M) 是我们的模型赋予整个句子的概率。这个公式的直观理解是:模型给句子的概率越高(log⁡2\log_2 值越大),负对数就越小,交叉熵也就越低,说明模型预测得越好。

2. 困惑度:更直观的评价指标

困惑度(Perplexity,PPL) 是语言模型评价中最常用的指标,它直接由交叉熵定义而来: Perplexity=2H(P,Q)\text{Perplexity} = 2^{H(P, Q)} 将每个词的交叉熵公式代入,对于一个测试句子,其困惑度近似为: PPL≈2−1Mlog⁡2Pmodel(w1,w2,...,wM)=Pmodel(w1,w2,...,wM)−1M\text{PPL} \approx 2^{-\frac{1}{M} \log_2 P_{\text{model}}(w_1, w_2, ..., w_M)} = P_{\text{model}}(w_1, w_2, ..., w_M)^{-\frac{1}{M}}

困惑度的直观解释:

  1. 平均分支因子:假设模型在预测每个词时,面临一个均匀分布的选择。困惑度的值等于这个均匀分布的“选项”数量。例如,困惑度为64,意味着模型在预测每个词时,其不确定性相当于在64个等概率的词中随机猜测。
  2. 与交叉熵的关系:较低的交叉熵直接对应较低的困惑度。两者都指示更好的模型性能。
  3. 数据压缩视角:困惑度也反映了压缩效率。一个困惑度为 2b2^b 的模型,平均每个词需要 bb 个比特来编码。困惑度越低,压缩率越高。

让我们通过一个具体的例子来理解交叉熵和困惑度的计算,并比较不同模型。

# ========== 1. 定义微型语料库和测试句子 ==========
# 为了演示,我们使用一个极小的语料库和句子
corpus = [
    "i spent three years",
    "before the mast",
    "i spent money"
]
test_sentence = ["i", "spent", "three", "years", "before", "the", "mast"]
M = len(test_sentence)  # 句子长度

# ========== 2. 构建Unigram模型概率 ==========
from collections import Counter
import math

# 将所有句子合并并分词
all_words = []
for sent in corpus:
    all_words.extend(sent.split())
vocab = set(all_words)
V = len(vocab)  # 词汇表大小

# 计算unigram计数和概率(使用加1平滑)
unigram_counts = Counter(all_words)
def p_unigram(word):
    # 加1平滑 (Laplace smoothing)
    return (unigram_counts.get(word, 0) + 1) / (len(all_words) + V)

# ========== 3. 构建Bigram模型概率 ==========
# 创建bigram计数
bigram_counts = {}
context_counts = {}
for sent in corpus:
    words = sent.split()
    for i in range(len(words) - 1):
        bigram = (words[i], words[i+1])
        bigram_counts[bigram] = bigram_counts.get(bigram, 0) + 1
        context_counts[words[i]] = context_counts.get(words[i], 0) + 1

def p_bigram(w1, w2):
    # 使用加1平滑
    count_bigram = bigram_counts.get((w1, w2), 0)
    count_context = context_counts.get(w1, 0)
    # 平滑后概率: (c(w1,w2)+1) / (c(w1)+V)
    return (count_bigram + 1) / (count_context + V)

# ========== 4. 计算句子概率、交叉熵和困惑度 ==========
# 计算unigram模型下的句子概率(假设词独立)
log_prob_uni = 0.0
for word in test_sentence:
    prob = p_unigram(word)
    log_prob_uni += math.log2(prob)

# 计算bigram模型下的句子概率(需要句首标记,这里简化处理)
# 假设句子以"<s>"开始,计算 P(w1|<s>) * P(w2|w1) * ... * P(w7|w6)
log_prob_bi = 0.0
# 第一个词的概率:P(i | <s>),用unigram近似,因为我们的语料没有句首标记
log_prob_bi += math.log2(p_unigram(test_sentence[0]))
for i in range(1, M):
    prob_bi = p_bigram(test_sentence[i-1], test_sentence[i])
    log_prob_bi += math.log2(prob_bi)

# 计算每个词的交叉熵
cross_entropy_uni = - (1 / M) * log_prob_uni
cross_entropy_bi = - (1 / M) * log_prob_bi

# 计算困惑度
perplexity_uni = 2 ** cross_entropy_uni
perplexity_bi = 2 ** cross_entropy_bi

print(f"测试句子: '{' '.join(test_sentence)}'")
print(f"句子长度 M = {M}")
print()
print("=== Unigram 模型 ===")
print(f"  句子对数概率 (log2): {log_prob_uni:.4f}")
print(f"  每个词交叉熵: {cross_entropy_uni:.4f} bits")
print(f"  困惑度: {perplexity_uni:.4f}")
print()
print("=== Bigram 模型 ===")
print(f"  句子对数概率 (log2): {log_prob_bi:.4f}")
print(f"  每个词交叉熵: {cross_entropy_bi:.4f} bits")
print(f"  困惑度: {perplexity_bi:.4f}")
测试句子: 'i spent three years before the mast'
句子长度 M = 7

=== Unigram 模型 ===
  句子对数概率 (log2): -21.0196
  每个词交叉熵: 3.0028 bits
  困惑度: 8.0155

=== Bigram 模型 ===
  句子对数概率 (log2): -16.1536
  每个词交叉熵: 2.3077 bits
  困惑度: 4.9508

结果分析:

  • Bigram 模型的交叉熵是 2.3077 bits,Unigram 模型是 3.0028 bits,前者更低。
  • 相应地,Bigram 的困惑度 4.9508 也低于 Unigram 的 8.0155(8.0155 = 23.00282^{3.0028},4.9508 = 22.30772^{2.3077},正好是交叉熵取 2 为底的指数)。
  • 这意味着,在这个微型语料库和这条测试句上,Bigram 是更好的模型,它预测下一个词的不确定性更小。从数据压缩角度看,用 Bigram 编码每个词平均约 2.3077 个比特,Unigram 约需 3.0028 个比特——每词省下约 0.7 比特。

信息论意义:这个例子印证了课件中的观点:更好的语言模型(如bigram)能更有效地“压缩”语言,平均每个词所需的编码位数更少。课件中提到的Moby Dick例子显示,bigram模型将句子“I spent three years before the mast”的交叉熵降至约6 bits/word,而unigram模型约为11 bits/word,压缩效率差异显著。

3. 模型插值:结合不同阶数的优势

n-gram模型面临一个权衡:高阶模型(如trigram)能捕捉更长的上下文依赖,但数据稀疏问题严重(许多n-gram在训练中从未出现);低阶模型(如unigram)数据估计更可靠,但忽略了上下文信息。

插值法(Interpolation) 提供了一种解决方案:将不同阶数的n-gram模型通过线性加权的方式组合起来。例如,一个插值的三元模型概率可以定义为: Pinterp(w3∣w1,w2)=λ1P(w3)+λ2P(w3∣w2)+λ3P(w3∣w1,w2)P_{\text{interp}}(w_3 | w_1, w_2) = \lambda_1 P(w_3) + \lambda_2 P(w_3 | w_2) + \lambda_3 P(w_3 | w_1, w_2) 其中:

  • P(w3)P(w_3) 是unigram概率。
  • P(w3∣w2)P(w_3 | w_2) 是bigram概率。
  • P(w3∣w1,w2)P(w_3 | w_1, w_2) 是trigram概率。
  • λ1,λ2,λ3\lambda_1, \lambda_2, \lambda_3 是插值参数(或混合权重),满足 λ1+λ2+λ3=1\lambda_1 + \lambda_2 + \lambda_3 = 1 且 λi≥0\lambda_i \ge 0。

插值法的优点:

  1. 缓解稀疏性:即使 trigram 概率 P(w3∣w1,w2)P(w_3 | w_1, w_2) 为零,只要高阶项对应的权重不全为零、且低阶概率不为零,最终插值概率就不为零。注意条件里必须带上“λi>0\lambda_i > 0”:若 λ1=λ2=0\lambda_1 = \lambda_2 = 0、λ3=1\lambda_3 = 1,插值退化成 trigram 本身,零概率照旧。
  2. 灵活利用信息:模型可以自动学习权重 λ\lambda,决定在何种情况下更依赖哪种阶数的信息。
  3. 平滑效果:它本身就是一种有效的平滑技术。

如何确定最优的 λ\lambda 值?通常在一个留存数据集(held-out dataset) 上进行优化,目标是使该数据集上的困惑度最小化。


# —— 重新构建上文用到的微型数据(本节各代码块相互独立,可单独运行)
corpus = [
    "i spent three years",
    "before the mast",
    "i spent money"
]
test_sentence = ["i", "spent", "three", "years", "before", "the", "mast"]
M = len(test_sentence)  # 句子长度
from collections import Counter
import math
all_words = []
for sent in corpus:
    all_words.extend(sent.split())
vocab = set(all_words)
V = len(vocab)  # 词汇表大小
unigram_counts = Counter(all_words)
def p_unigram(word):
    # 加1平滑 (Laplace smoothing)
    return (unigram_counts.get(word, 0) + 1) / (len(all_words) + V)
bigram_counts = {}
context_counts = {}
for sent in corpus:
    words = sent.split()
    for i in range(len(words) - 1):
        bigram = (words[i], words[i+1])
        bigram_counts[bigram] = bigram_counts.get(bigram, 0) + 1
        context_counts[words[i]] = context_counts.get(words[i], 0) + 1
def p_bigram(w1, w2):
    # 使用加1平滑
    count_bigram = bigram_counts.get((w1, w2), 0)
    count_context = context_counts.get(w1, 0)
    # 平滑后概率: (c(w1,w2)+1) / (c(w1)+V)
    return (count_bigram + 1) / (count_context + V)
log_prob_uni = 0.0
for word in test_sentence:
    prob = p_unigram(word)
    log_prob_uni += math.log2(prob)
log_prob_bi = 0.0
log_prob_bi += math.log2(p_unigram(test_sentence[0]))
for i in range(1, M):
    prob_bi = p_bigram(test_sentence[i-1], test_sentence[i])
    log_prob_bi += math.log2(prob_bi)
cross_entropy_uni = - (1 / M) * log_prob_uni
cross_entropy_bi = - (1 / M) * log_prob_bi
perplexity_uni = 2 ** cross_entropy_uni
perplexity_bi = 2 ** cross_entropy_bi

# ========== 5. 插值法计算示例 ==========
# 假设我们要计算 P(three | i, spent) 的插值概率
# 沿用前面的概率函数;trigram 项为演示插值而人为设定一个很小的值

# 定义上下文和目标词
context = ("i", "spent")
target = "three"

# 获取各阶概率估计(使用之前定义的函数)
p_uni = p_unigram(target)  # P(three)
p_bi = p_bigram(context[1], target)  # P(three | spent)
# 为演示插值,人为设定一个很小的 trigram 概率(若要用观测值,需先构建 trigram 计数并按平滑规则计算)
p_tri = 0.001  # 人为设定的 P(three | i, spent)

# 定义一组插值权重(这些权重通常从留存数据学习得到)
lambda1, lambda2, lambda3 = 0.1, 0.3, 0.6  # 总和为1

# 计算插值概率
p_interp = lambda1 * p_uni + lambda2 * p_bi + lambda3 * p_tri

print("=== 插值法计算示例 ===")
print(f"上下文: {context}")
print(f"目标词: '{target}'")
print(f"  Unigram 概率 P({target}) = {p_uni:.6f}")
print(f"  Bigram  概率 P({target} | {context[1]}) = {p_bi:.6f}")
print(f"  Trigram 概率 P({target} | {context[0]}, {context[1]}) = {p_tri:.6f} (虚构)")
print(f"插值权重: λ1={lambda1}, λ2={lambda2}, λ3={lambda3}")
print(f"插值概率 P_interp({target} | {context[0]}, {context[1]}) = {p_interp:.6f}")
=== 插值法计算示例 ===
上下文: ('i', 'spent')
目标词: 'three'
  Unigram 概率 P(three) = 0.111111
  Bigram  概率 P(three | spent) = 0.200000
  Trigram 概率 P(three | i, spent) = 0.001000 (虚构)
插值权重: λ1=0.1, λ2=0.3, λ3=0.6
插值概率 P_interp(three | i, spent) = 0.071711

在这个虚构的例子中,尽管我们赋予trigram模型最高的权重(0.6),但由于其估计概率很低(0.001,注意这里不是 0),插值结果 0.071711 主要由 unigram 的 0.111111 与 bigram 的 0.2 拉起来:0.1*0.111111 + 0.3*0.2 + 0.6*0.001 = 0.011111 + 0.06 + 0.0006 = 0.071711。它演示的是低概率被抬升,而不是”零概率被救回”;要看零概率救回,得把 ptrip_{tri} 设成 0 再看结果。

4. 评价指标的应用与局限

在实际应用中,超参数的选择与最终报告要分在两个数据集上做。阶数 nn、平滑方法、插值权重 λ\lambda 这些超参数,必须用留存集(held-out)或验证集来选——在那上面反复比较、挑出最好的一组;等超参数全部锁定后,才在从未参与过任何选择的独立测试集上算一次最终困惑度,作为对外报告的泛化成绩。

把测试集拿来比较不同阶数或不同平滑方法,实质是在测试集上做模型选择:选出来的模型已经”见过”测试集的信息,报出的困惑度不再是干净的泛化估计,而是偏乐观的。这也是本节前面说”在留存集上优化 λ\lambda“的原因——两处必须口径一致。

几点重要认识:

  • 相对性:困惑度是一个相对指标,用于比较不同的模型。单独一个困惑度数值(如247)没有绝对意义,但通过与基线模型(如unigram)或其他模型对比,我们可以判断改进程度。
  • 与任务的相关性:更低的困惑度通常意味着更好的语言模型,但不一定直接转化为下游任务(如机器翻译、语音识别)的性能提升。最终仍需在具体任务上验证。
  • 历史参考:课件中提到,在著名的布朗语料库(Brown Corpus)上,1992年最好的语言模型达到了约247的困惑度,对应每个词约7.9比特的交叉熵。这为我们提供了一个历史基准。

📝 动手练一练

  1. 概念辨析:假设模型A在测试集上的困惑度为50,模型B的困惑度为100。哪个模型更好?为什么?从“平均分支因子”的角度如何理解这个差异?
👉 点击查看参考答案

模型A更好。因为困惑度越低,代表模型在预测下一个词时的不确定性越小,模型对语言的建模能力越强。

从“平均分支因子”理解:

  • 模型A的困惑度为50,意味着该模型在预测每个词时,其不确定性相当于在一个均匀分布的、包含50个词的词表中进行随机选择。
  • 模型B的困惑度为100,相当于要在100个等概率的词中猜测。
  • 显然,模型A的猜测空间更小,预测更准确,因此是更好的模型。
  1. 手算练习:假设一个极小的语言只有两个词 {A, B}。一个unigram语言模型给出概率:P(A)=0.8P(A)=0.8, P(B)=0.2P(B)=0.2。现有测试序列为 A B。
    • a) 计算该序列在模型下的概率。
    • b) 计算该序列的每个词交叉熵。
    • c) 计算模型的困惑度。
👉 点击查看参考答案

a) 序列概率: P(A B)=P(A)×P(B)=0.8×0.2=0.16P(\text{A B}) = P(A) \times P(B) = 0.8 \times 0.2 = 0.16

b) 每个词交叉熵: 首先计算序列的对数概率(以2为底): log⁡2P(A B)=log⁡2(0.16)≈−2.64386\log_2 P(\text{A B}) = \log_2(0.16) \approx -2.64386

序列长度 M=2M=2。 每个词交叉熵 H=−1Mlog⁡2P(序列)=−12×(−2.64386)=1.32193 bitsH = -\frac{1}{M} \log_2 P(\text{序列}) = -\frac{1}{2} \times (-2.64386) = 1.32193 \text{ bits}

c) 困惑度: PPL=2H=21.32193=2.5\text{PPL} = 2^{H} = 2^{1.32193} = 2.5

也可直接算:PPL=P(A B)−1/M=0.16−1/2=1/0.16=1/0.4=2.5\text{PPL} = P(\text{A B})^{-1/M} = 0.16^{-1/2} = 1/\sqrt{0.16} = 1/0.4 = 2.5,两条路结果相同。

这里顺带排掉一个常见算错:log⁡2(0.16)\log_2(0.16) 不是 −2.3219-2.3219(−2.3219-2.3219 是 log⁡20.2\log_2 0.2),若误用它,交叉熵会算成 1.160951.16095、困惑度算成 2.23612.2361,与 2.5 差开。精确算法是 log⁡2(0.16)=log⁡2(4/25)=2−log⁡225\log_2(0.16)=\log_2(4/25)=2-\log_2 25,于是 H=log⁡225−22=log⁡25−1H=\frac{\log_2 25 - 2}{2}=\log_2 5 - 1,2H=5/2=2.52^H = 5/2 = 2.5。所以最终答案:困惑度 = 2.5,每个词交叉熵 ≈ 1.3219 比特。

本章小结

本节我们为语言模型建立了核心的评价体系。交叉熵和困惑度源于信息论,为我们提供了量化模型预测能力的工具:交叉熵越低(困惑度越低),模型越好。这一评价标准与数据压缩效率紧密相连。此外,我们学习了插值法,通过线性组合不同阶数的n-gram模型来平衡上下文依赖与数据稀疏性,这是一种强大且实用的平滑技术。

现在,我们已经掌握了语言模型从构建(n-gram)到平滑(多种技术)再到评价(困惑度)的完整流程。这些经典方法奠定了统计语言模型的基础,其核心思想——用概率描述语言序列,并通过优化概率评价模型——至今仍在深度学习中延续。

📋 行动清单

  • 理解交叉熵和困惑度的定义、计算方式及其信息论解释(压缩效率)。
  • 能够手动计算给定句子在简单n-gram模型下的困惑度。
  • 掌握模型插值法的动机、公式和应用场景,理解其作为一种平滑技术的作用。

—— 小象教研组