Transformers documentation
固定长度模型的困惑度
固定长度模型的困惑度
困惑度(PPL)是评估语言模型最常用的指标之一。在深入探讨之前,我们应当指出,该指标专门适用于经典语言模型(有时也称为自回归或因果语言模型),而对于 BERT 这类掩码语言模型,其定义并不明确。
困惑度(Perplexity)定义为序列的平均负对数似然的指数。如果有一个分词后的序列 $X = (x_0, x_1, \dots, x_t)$,那么 的困惑度为
其中 $\log p\theta (x_i|x{<i})$ 表示根据我们的模型,第 i 个 token 在前序 token $x_{<i}$ 条件下的对数似然。直观地说,它可以被理解为对模型在语料库中指定的 token 集合上进行均匀预测能力的评估。重要的是,这意味着分词过程会直接影响模型的困惑度,因此在比较不同模型时,这一点始终需要考虑在内。
这也等价于数据与模型预测之间交叉熵的指数运算。如需进一步了解困惑度及其与每字符比特数(BPC)和数据压缩之间的关系,请参阅这篇关于 The Gradient 的精彩博客文章。
使用固定长度模型计算 PPL
如果不受模型上下文大小的限制,我们会通过自回归方式对序列进行分解,并在每一步中以整个前缀子序列作为条件,来评估模型的困惑度,如下图所示。
然而,在使用近似模型时,我们通常对模型可处理的 token 数量存在限制。例如, GPT-2, 的最大版本具有固定的 1024 个 token 的长度,因此当\theta(x_t|x{<t})$ 时,我们无法直接计算 $p 大于 1024。
通常,序列会被拆分为长度等于模型最大输入大小的子序列。如果模型的最大输入大小为 $k$,那么我们仅基于其前面的 个 token 来近似 token 的对数似然,而不是基于整个上下文。在评估模型对某个序列的困惑度时,一种诱人但次优的做法是将序列拆分为互不重叠的块,并独立累加每个片段分解后的对数似然。
由于每个片段的困惑度只需一次前向传播即可计算,因此该方法计算速度很快,但它对完全分解困惑度的近似效果较差,并且通常会得出更高(更差)的 PPL,因为在大多数预测步骤中,模型可用的上下文较少。
相反,固定长度模型的 PPL 应采用滑动窗口策略进行评估。该策略通过反复滑动上下文窗口,使模型在进行每次预测时都能拥有更多的上下文信息。
这是序列概率真实分解的更精确近似,通常能带来更优的分数。其缺点在于,需要对语料库中的每个 token 执行一次独立的前向传播。一个良好的实用折中方案是采用带步长的滑动窗口,以较大的步长移动上下文,而非每次仅滑动 1 个 token。这样既能大幅提升计算速度,又能让模型在每一步都拥有较大的上下文来进行预测。
示例:在 🤗 Transformers 中使用 GPT-2 计算困惑度
让我们以 GPT-2 为例来演示这一过程。
from transformers import GPT2LMHeadModel, GPT2TokenizerFast
from accelerate import Accelerator
device = Accelerator().device
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("Salesforce/wikitext", "wikitext-2-raw-v1", split="test")
encodings = tokenizer("\n\n".join(test["text"]), return_tensors="pt")使用 🤗 Transformers 时,我们只需将 input_ids 作为 labels 传入模型,每个 token 的平均负对数似然即作为损失返回。然而,采用滑动窗口方法时,每次迭代传给模型的 token 之间存在重叠。我们不希望仅作为上下文的 token 的对数似然被计入损失,因此可以将这些目标值设为 -100 以使其被忽略。以下是一个使用 512 步长的示例。这意味着在计算任意一个 token 的条件似然时,模型至少拥有 512 个 token 作为上下文(前提是存在 512 个可供条件化的前置 token)。
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 # may be different from stride on last loop
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)
# loss is calculated using CrossEntropyLoss which averages over valid labels
# N.B. the model only calculates loss over trg_len - 1 labels, because it internally shifts the labels
# to the left by 1.
neg_log_likelihood = outputs.loss
# Accumulate the total negative log-likelihood and the total number of tokens
num_valid_tokens = (target_ids != -100).sum().item() # number of valid tokens in target_ids
batch_size = target_ids.size(0)
num_loss_tokens = num_valid_tokens - batch_size # subtract batch_size due to internal label shift
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 # average negative log-likelihood per token
ppl = torch.exp(avg_nll)如果将步长设置为等于最大输入长度,则相当于我们上面讨论过的次优的非滑动窗口策略。步长越小,模型在进行每次预测时拥有的上下文就越多,通常报告的困惑度也会越好。
当我们将上述代码以 stride = 1024(即无重叠)运行时,得到的 PPL 为 19.44,与 GPT-2 论文中报告的 19.93 大致相当。通过使用 stride = 512 并采用我们的滑动窗口策略,该值降至 16.44。这不仅是一个更优的分数,而且其计算方式更接近序列似然的真实自回归分解。