PPL是自然语言处理(NLP)和大模型(LLM)中最经典、最核心的评估指标。
一、直觉理解——什么是困惑度
想象你在做一个英语填空题:
"The sun rises in the__." (太阳从__升起。)
情况 A(毫无困惑):你非常有把握,后面肯定是 "East"(东方)。此时,你对下一个词的预测概率接近 100%。你的困惑度很低(接近 1)。
情况 B(非常困惑):题目变成了 "The machine is."(这台机器是。)可能是 "broken"?可能是 "running"?可能是 "red"?你有很多种猜测,拿不准。此时你对下一个词的预测概率很分散。你的困惑度很高。
基于上述现象,先给出结论:
PPL 越低越好:代表模型越“聪明”,对生成的句子越有把握。
PPL 越高越差:代表模型越“懵圈”,它觉得下面接什么词都可能,或者接了错误的词。
二、数学原理——从概率出发
要计算困惑度,我们必须先理解语言模型(Language Model)在做什么。
语言模型本质上是一个概率计算器。
1. 句子的概率
假设一个句子 S 由 N 个单词组成:W = (w_1, w_2, ..., w_N)。
语言模型的目标是计算这个句子出现的概率 P(W)。根据链式法则(Chain Rule),这个概率是每个词在前面所有词作为条件下发生的概率的乘积:
2. 困惑度的原始定义
困惑度不仅仅是概率的倒数,它是概率倒数的几何平均(也就是开 N 次方根)。
公式定义如下:
为什么要开 N 次方根?
因为句子长度不一样。长句子因为乘的项多,总概率 P(W) 肯定比短句子小。为了公平比较不同长度的句子,我们需要把概率“平均”到每一个单词上。
三、核心计算——为什么要取Log?
在计算机里直接算上面的公式有个大问题:数值下溢(Underflow)。
如果一个词的概率是 0.0001,连乘 100 次,结果会变成一个无限接近于 0 的数,计算机存不下了。
所以,我们会把上面的公式转换到对数域(Log Domain)来计算。
1. 推导过程
我们对 PPL 公式取自然对数 ln(或者 log_e):
利用对数的性质:
把 P(W) 展开为连乘:
利用对数性质,乘法变成了加法:
2. 最终计算公式
最后,为了还原回 PPL,我们在两边取指数 exp:
重点来了!
你看括号里那个公式:。
这正是深度学习中常用的 交叉熵损失函数(Cross Entropy Loss)!
基于上述推到,我们得出一个很实用且很重要的结论
(注:如果你的 Loss 是以 e 为底的 log 算的,就用 e 的指数;如果是以 2 为底,就用 2 的指数。PyTorch 等框架默认通常是 e)。
四、手把手带你学会计算PPL
场景:模型要评估句子 "I love AI"
句子长度 N=3(假设简化处理,不考虑结束符)。
模型预测的概率情况:
第一个词是 "I" 的概率:P(w_1) = 0.5
在 "I" 后面出现 "love" 的概率:P(w_2|w_1) = 0.5
在 "I love" 后面出现 "AI" 的概率:P(w_3|w_1, w_2) = 0.4
步骤 1:计算总概率
步骤 2:计算几何平均的倒数(即 PPL)
物理含义:这个 PPL = 2.154 意味着什么?
意味着在生成这句话的每一个位置,模型平均需要在 2.154 个候选词 中进行纠结。这说明模型对这句话还算比较确定的(毕竟如果完全瞎猜,字典里有几万个词,PPL 会是几万)。
五、理论小结
1. 为什么 PPL 越低越好?
因为:PPL 低 -> Cross Entropy Loss 低 -> 模型预测真实单词的概率高 -> 模型很“懂”这句话。
2. PPL 的局限性
虽然 PPL 是训练时的核心指标,但它并不完全等同于生成质量。
重复问题:如果模型一直重复 "The the the the...",因为 "the" 这种词概率通常很高,PPL 可能会很低,但句子完全不通顺。
事实错误:如果模型很有信心地胡说八道(比如“秦始皇用 Python 写代码”),只要模型觉得概率高,PPL 也会低。
3. 表格总结
| 概念 | 解释 | 数学关系 |
|---|---|---|
| Probability | 猜对真实单词的概率 | P |
| Log Probability | 概率取对数 (变成负数) | |
| Loss (Cross Entropy) | 负对数的平均值 (正数) | |
| Perplexity (PPL) | Loss 的指数形式 |
六、代码实战
1.基础版(按照PPL原始公式计算)
这段代码完全对应我们刚才推导的公式:
import torch import torch.nn.functional as F import math # 1. 假设词表大小为 5 (字典里只有 A, B, C, D, E) vocab_size = 5 # 2. 假设模型对 "I love AI" 中每个位置的预测输出 (Logits) # 形状: [序列长度, 词表大小] -> [3, 5] # 这里是模拟数据,真实模型会输出具体的数值 logits = torch.tensor([ [2.0, 4.5, 1.0, 0.5, 0.1], # 预测第一个词,索引1(B)的分数最高 [0.5, 0.2, 3.8, 0.1, 0.1], # 预测第二个词,索引2(C)的分数最高 [0.1, 0.1, 0.1, 5.0, 0.1] # 预测第三个词,索引3(D)的分数最高 ]) # 3. 真实的标签 (假设真实句子对应的单词索引是 1, 2, 3) targets = torch.tensor([1, 2, 3]) print("--- 手动步骤计算 ---") # 步骤 A: 将 Logits 转换为概率 (Softmax) probs = F.softmax(logits, dim=-1) print(f"1. 预测概率矩阵:\n{probs}") # 步骤 B: 提取真实目标单词对应的概率 # gather 是 pytorch 中用来按索引取值的函数 target_probs = probs.gather(1, targets.view(-1, 1)).squeeze() print(f"2. 真实单词对应的概率: {target_probs.tolist()}") # 结果类似: [0.9..., 0.8..., 0.9...] 说明模型预测得挺准 # 步骤 C: 取对数 (ln) log_probs = torch.log(target_probs) print(f"3. 对数概率 (ln P): {log_probs.tolist()}") # 步骤 D: 求平均负对数似然 (NLL / Cross Entropy) # 公式: - (1/N) * sum(ln P) mean_nll = -log_probs.mean() print(f"4. 交叉熵 Loss: {mean_nll.item():.4f}") # 步骤 E: 计算 PPL (取指数) # 公式: exp(Loss) ppl = torch.exp(mean_nll) print(f"5. 最终困惑度 PPL: {ppl.item():.4f}")2.进阶版(用CrossEntropyLoss计算)
在实际的大模型开发(如 GPT, Llama)中,我们不会手写上面那么多步,而是直接用torch.nn.CrossEntropyLoss。
这个函数内部自动帮我们完成了Softmax + Log + NLL的所有操作,数值稳定性更好。
import torch import torch.nn as nn # --- 设置数据 (同上) --- logits = torch.tensor([ [2.0, 4.5, 1.0, 0.5, 0.1], [0.5, 0.2, 3.8, 0.1, 0.1], [0.1, 0.1, 0.1, 5.0, 0.1] ]) targets = torch.tensor([1, 2, 3]) print("\n--- 工业界标准写法 ---") # 1. 定义损失函数 # CrossEntropyLoss 接收 logits 和 targets criterion = nn.CrossEntropyLoss() # 2. 计算 Loss loss = criterion(logits, targets) print(f"计算出的 Loss: {loss.item():.4f}") # 3. 计算 PPL ppl = torch.exp(loss) print(f"计算出的 PPL: {ppl.item():.4f}")验证:你可以对比上下两部分代码的结果,应该是一模一样的!
3.代码中的关键点解析
Logits vs Probabilities:我们在计算
CrossEntropyLoss时,传入的是Logits(未经过 Softmax 的原始分数),而不是概率。这是因为 PyTorch 内部为了防止数值下溢(Underflow),会在内部合并计算LogSoftmax,这样更精确。Base (底数):
torch.exp是以自然常数 e 为底。如果你看一些很老的论文,PPL 偶尔会用 2 为底。但在现代深度学习(GPT 系列等)中,默认都是 e。
4.调用 GPT2 模型来计算PPL
在工业界,我们几乎从不手写CrossEntropyLoss,而是直接使用Hugging Face Transformers库。它把模型加载、分词、计算 Loss 全部封装好了。
请确保你安装了transformers库:pip install transformers
记得先打开魔法梯子,然后运行下面代码会加载GPT-2模型,并让它给两个句子打分:一句是正常的人话,一句是乱码。你会直观地看到 PPL 的巨大差异!
import torch from transformers import GPT2LMHeadModel, GPT2Tokenizer # 1. 加载“大脑”(模型) 和 “字典”(分词器) # 我们使用最小版的 'gpt2',因为它下载快,只有几百兆 model_id = 'gpt2' print(f"正在加载 {model_id} 模型,请稍候...") tokenizer = GPT2Tokenizer.from_pretrained(model_id) model = GPT2LMHeadModel.from_pretrained(model_id) model.eval() # 设置为评估模式(不进行训练更新) def calculate_ppl(text): # 2. 预处理:把文本变成数字 ID (Token IDs) # return_tensors='pt' 表示返回 PyTorch 的 Tensor 格式 encodings = tokenizer(text, return_tensors='pt') # 3. 喂给模型 # 关键点:我们将 input_ids 同时也传给 labels # GPT-2 内部会自动把 labels 向左移一位,计算预测下一个词的 Loss with torch.no_grad(): # 不计算梯度,节省内存 outputs = model( input_ids=encodings.input_ids, labels=encodings.input_ids ) # 4. 获取 Loss 并计算 PPL # outputs.loss 就是我们之前讲的 CrossEntropyLoss (平均负对数似然) loss = outputs.loss ppl = torch.exp(loss) return ppl.item() # --- 测试时刻 --- # 句子 A: 正常的英语 sentence_good = "The sun rises in the east." ppl_good = calculate_ppl(sentence_good) # 句子 B: 语序混乱的英语 sentence_bad = "Sun the east in rises the." # 句子 C: 完全胡言乱语 sentence_gibberish = "Dog car fly table sky blue red." ppl_bad = calculate_ppl(sentence_bad) ppl_gibberish = calculate_ppl(sentence_gibberish) print("\n--- 结果对比 ---") print(f"句子: '{sentence_good}'") print(f"PPL: {ppl_good:.2f} (越低越好,说明模型觉得这句话很通顺)") print(f"\n句子: '{sentence_bad}'") print(f"PPL: {ppl_bad:.2f} (变高了,模型开始困惑)") print(f"\n句子: '{sentence_gibberish}'") print(f"PPL: {ppl_gibberish:.2f} (非常高!模型完全懵了)")对上面代码进行详细解读
1. 为什么把input_ids传给labels?
这是 Hugging Face 的一个极其方便的特性。
输入 (
input_ids):[The, sun, rises, in]标签 (
labels):[The, sun, rises, in]模型内部自动移位: 模型会自动把标签往左移一位,变成预测目标。
输入
The-> 预测sun输入
sun-> 预测rises...
然后自动帮你算好
CrossEntropyLoss,存在outputs.loss里。不需要我们手写 LogSoftmax 了。
2. PPL 数值大小的概念
运行上述代码,你通常会看到:
< 20: 非常流畅、常见的句子。
20 - 100: 正常的句子,或者稍微有点生僻词。
> 100: 句子语法不通,或者包含模型从未见过的概念。
> 1000: 基本上是乱码。
六、全文总结
现在,你已经从直觉原理,到数学公式,再到原生 PyTorch 实现,最后掌握了Hugging Face 实战。相信你已经完全搞懂困惑度(PPL)了!