困惑度(Perplexity)在固定长度模型中的应用¶
什么是困惑度?¶
困惑度(PPL)是评估语言模型性能最常用的指标之一。需要注意的是,这个指标主要用于传统的自回归(或因果)语言模型,而对于像 BERT 这样的掩码语言模型则不太适用(详见模型总结)。
困惑度的定义是对序列的平均负对数似然取指数。如果有分词后的序列 $X = (x_0, x_1, \dots, x_t)$,那么该序列的困惑度 PPL(X) 通过以下公式计算: $$ \text{PPL}(X) = \exp \left\{ -\frac{1}{t}\sum_{i=1}^t \log p_\theta (x_i|x_{<i}) \right\} $$ 其中,$\log p_\theta (x_i|x_{<i})$ 是根据模型计算的第 $i$ 个词在其前面的词条件下的对数似然。直观上,困惑度可以评估模型在给定语料库中对指定词汇的预测均匀性。需要注意的是,分词方式会直接影响模型的困惑度,因此在比较不同模型时应始终考虑这一点。
困惑度也等价于数据与模型预测之间的交叉熵的指数。有关困惑度及其与每字符比特数(BPC)和数据压缩关系的更深入理解,可以参考这篇博客文章。
使用固定长度模型计算困惑度¶
如果我们不受模型上下文大小的限制,可以对整个序列进行自回归分解,每次预测时都以前面所有词为条件。

但在实际应用中,我们通常会受到模型处理的令牌数量的限制。例如,GPT-2 的最大上下文长度为 1024 个令牌,因此当 $t>1024$ 时,我们无法直接计算 $p_\theta(x_t|x_{<t})$。
为了应对这种情况,通常将序列分割成与模型最大输入长度相等的子序列。如果模型的最大输入长度为 $k$,那么我们只需考虑每个令牌前面的 $k−1$ 个令牌,而不是整个上下文。计算模型困惑度时,一个简单但次优的方法是将序列分解为不重叠的片段,并独立计算每个片段的对数似然。

这种方法的优点是计算速度快,但作为完全分解困惑度的近似效果较差,通常会得到更高的(更差的)困惑度,因为模型在大多数预测步骤中会有更少的上下文。
更好的方法是使用滑动窗口策略。通过不断滑动上下文窗口,使模型在每次预测时都能获得更多的上下文。

这种方法更接近真实序列概率的分解,通常会产生更优的结果。缺点是需要为语料库中的每个令牌进行一次前向计算。一种实用的折中方法是采用步长较大的滑动窗口,而不是每次只滑动一个令牌。这可以在保持较大上下文的同时提高计算速度。
示例:使用 🤗 Transformers 和 GPT-2 计算困惑度¶
让我们用 GPT-2 来演示这个过程。
# 导入必要的库和模型
from transformers import GPT2LMHeadModel, GPT2TokenizerFast
from accelerate.test_utils.testing import get_backend
# 自动检测设备类型(CUDA、CPU、XPU、MPS 等)
device, _, _ = get_backend()
model_id = "openai-community/gpt2-large"
model = GPT2LMHeadModel.from_pretrained(model_id).to(device)
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
我们将加载 WikiText-2 数据集,并使用不同的滑动窗口策略评估困惑度。由于该数据集较小,我们可以一次性将整个数据集加载到内存中。
from datasets import load_dataset
# 加载和编码数据集
test = load_dataset("wikitext", "wikitext-2-raw-v1", split="test")
encodings = tokenizer("\n\n".join(test["text"]), return_tensors="pt")
在 🤗 Transformers 中,我们只需将 input_ids 作为 labels 传递给模型,它会返回每个令牌的平均负对数似然作为损失。使用滑动窗口方法时,传递给模型的令牌之间会存在重叠,我们不希望将作为上下文的令牌的对数似然包含在损失中,因此可以将这些目标设置为 -100,让它们被忽略。以下是一个使用步长为 512 的示例。
import torch
from tqdm import tqdm
# 设置最大长度和步长
max_length = model.config.n_positions
stride = 512
seq_len = encodings.input_ids.size(1)
# 初始化变量
nll_sum = 0.0
n_tokens = 0
prev_end_loc = 0
# 迭代滑动窗口
for begin_loc in tqdm(range(0, seq_len, stride)):
end_loc = min(begin_loc + max_length, seq_len)
trg_len = end_loc - prev_end_loc # 在最后一轮可能与步长不同
input_ids = encodings.input_ids[:, begin_loc:end_loc].to(device)
target_ids = input_ids.clone()
target_ids[:, :-trg_len] = -100 # 忽略作为上下文的令牌
with torch.no_grad():
# 前向传播并计算损失
outputs = model(input_ids, labels=target_ids)
neg_log_likelihood = outputs.loss
# 累积总负对数似然和有效令牌数量
num_valid_tokens = (target_ids != -100).sum().item() # 有效令牌数量
batch_size = target_ids.size(0)
num_loss_tokens = num_valid_tokens - batch_size # 考虑内部标签移位
nll_sum += neg_log_likelihood * num_loss_tokens
n_tokens += num_loss_tokens
prev_end_loc = end_loc
if end_loc == seq_len:
break
# 计算平均负对数似然和困惑度
avg_nll = nll_sum / n_tokens
ppl = torch.exp(avg_nll)
当步长等于最大输入长度(1024)时,相当于我们之前讨论的次优的非滑动窗口策略。步长越小,模型在每次预测时获得的上下文越多,报告的困惑度通常会越好。例如,使用 stride = 1024(无重叠)时,困惑度为 19.44,接近 GPT-2 论文中报告的 19.93。而使用 stride = 512(滑动窗口)时,困惑度降低到 16.44。这种方法不仅能得到更优的分数,还更接近真实的自回归分解。