语言模型中熵与交叉熵的差异解析:字符级Unigram模型实践示例
核心概念梳理
熵(Entropy)
熵是衡量真实语言分布P不确定性的指标,对于字符级语言来说,它表示预测下一个字符的平均不确定性。计算时需要先得到真实语言的概率分布(比如n-gram的出现概率),公式为:
$$H(P) = -\sum_{x} P(x) \log_2 P(x)$$
对于序列语言,我们通常计算熵率(每个字符的平均熵),即n-gram熵除以n,用来近似无限长语言序列的真实熵。
交叉熵(Cross-Entropy)
交叉熵衡量模型预测分布Q与真实分布P之间的差异,用来评估模型对真实语言的拟合程度。公式为:
$$H(P,Q) = -\sum_{x} P(x) \log_2 Q(x)$$
在语言模型训练中,P常以独热编码形式出现(因为每个位置的真实字符是确定的,概率为1),交叉熵损失就是基于这个逻辑计算的。
关联
- 语言熵是交叉熵的下界:当模型Q完全拟合真实分布P时,交叉熵等于熵;模型越差,交叉熵越高。
- 语言熵反映语言本身的复杂度,交叉熵反映模型对语言的建模能力。
基于给定名字语料的计算方法
我们以提供的20个名字组成的语料为例,分别计算语言熵率和Unigram模型的交叉熵率:
步骤1:预处理语料
把所有名字拼接成一个字符串,统一转为小写(忽略大小写差异):
prenoms = [ "Alice", "Alfred", "Alina", "Aline", "Alexandre", "Alicia", "Alison", "Alma", "Alva", "Elise", "Elisa", "Eliane", "Alain", "Amélie", "Arline", "Olivier", "Oline", "Alva", "Eliott", "Julien" ] corpus = ''.join(prenoms).lower()
步骤2:计算语言熵率
用n-gram(这里选n=4)近似真实语言分布:
- 统计所有4-gram的出现次数,计算每个4-gram的真实概率P(ngram)
- 计算4-gram的熵,再除以4得到每个字符的熵率
步骤3:计算Unigram模型的交叉熵率
Unigram模型假设字符之间相互独立,因此一个n-gram的概率等于每个字符概率的乘积:
- 统计每个字符的出现概率(Unigram分布Q(c))
- 对每个真实的4-gram,计算模型预测的概率Q(ngram) = Q(c1)*Q(c2)*Q(c3)*Q(c4)
- 代入交叉熵公式计算,再除以4得到每个字符的交叉熵率
代码解释与优化
你提供的代码逻辑是正确的,以下是优化后的版本(修正模块导入位置、补充注释):
import math from collections import Counter def get_ngram_distribution(text, n=4): """生成n-gram的概率分布""" ngrams = [text[i:i+n] for i in range(len(text)-n+1)] total_ngrams = len(ngrams) count_counter = Counter(ngrams) return {ngram: count/total_ngrams for ngram, count in count_counter.items()} def calculate_language_entropy_rate(text, n=4): """计算语言的熵率(每个字符的平均熵)""" ngram_dist = get_ngram_distribution(text, n) # 计算n-gram的熵 ngram_entropy = -sum(p * math.log2(p) for _, p in ngram_dist.items()) # 归一化到每个字符的熵率 return ngram_entropy / n def calculate_unigram_cross_entropy_rate(text, n=4): """计算Unigram模型的交叉熵率""" # 真实n-gram分布(P) true_ngram_dist = get_ngram_distribution(text, n) # Unigram模型分布(Q) unigram_dist = get_ngram_distribution(text, 1) cross_entropy = 0.0 for ngram, p_true in true_ngram_dist.items(): # Unigram模型对该n-gram的预测概率:字符独立,乘积计算 p_pred = 1.0 for char in ngram: p_pred *= unigram_dist[char] # 累加交叉熵项 cross_entropy -= p_true * math.log2(p_pred) # 归一化到每个字符的交叉熵率 return cross_entropy / n if __name__ == "__main__": prenoms = [ "Alice", "Alfred", "Alina", "Aline", "Alexandre", "Alicia", "Alison", "Alma", "Alva", "Elise", "Elisa", "Eliane", "Alain", "Amélie", "Arline", "Olivier", "Oline", "Alva", "Eliott", "Julien" ] corpus = ''.join(prenoms).lower() entropy_rate = calculate_language_entropy_rate(corpus) cross_entropy_rate = calculate_unigram_cross_entropy_rate(corpus) print(f"语言熵率(每个字符):{entropy_rate:.4f} bits") print(f"Unigram模型交叉熵率(每个字符):{cross_entropy_rate:.4f} bits")
代码输出说明
运行后会得到两个数值:
- 语言熵率:反映这个名字语料本身的字符不确定性,数值越小说明语料的字符模式越固定。
- Unigram模型交叉熵率:反映Unigram模型对这个语料的拟合程度,它一定大于等于语言熵率,差值越大说明模型的建模能力越差(因为Unigram忽略了字符间的依赖关系)。
更简化的示例
假设我们有一个极小的语料:["aaab", "aaac"],拼接后是aaabaaac:
语言熵率(n=2):
2-gram有:aa,aa,ab,ba,aa,aa,ac
分布:aa:4/7,ab:1/7,ba:1/7,ac:1/7
2-gram熵:$-(4/7 \log2(4/7) + 3*(1/7 \log2(1/7))) ≈ 1.79$
熵率:$1.79/2 ≈ 0.895$ bits/字符Unigram交叉熵率:
Unigram分布:a:6/8,b:1/8,c:1/8
每个2-gram的模型预测概率:aa: (6/8)^2=36/64,ab: (6/8)(1/8)=6/64,ba: (1/8)(6/8)=6/64,ac: (6/8)(1/8)=6/64
交叉熵:$-(4/7 \log2(36/64) + 1/7 \log2(6/64) + 1/7 \log2(6/64) + 1/7 \log2(6/64)) ≈ 2.02$
交叉熵率:$2.02/2 ≈ 1.01$ bits/字符
可以看到交叉熵率大于熵率,因为Unigram模型没捕捉到"aa"之后更可能接"a"、"b"或"c"的依赖关系。
内容的提问来源于stack exchange,提问作者curious

