【转载】什么是困惑度perplexity

原文

正文

定长模型的困惑度(Perplexity)

困惑度(PPL)是评估语言模型最常用的指标之一。在深入介绍之前,我们应注意该指标专门适用于经典语言模型(有时称为自回归或因果语言模型),而对于像BERT这样的掩码语言模型,其定义并不明确(参见模型概要)。

困惑度定义为序列的平均负对数似然的指数化。如果我们有一个分词后的序列 X = (x_0, x_1, \dots, x_t),那么序列 X 的困惑度为,

\text{PPL}(X) = \exp\left\{ -\frac{1}{t}\sum_i^t \log p_\theta (x_i|x_{ ```<i}) \right\}

其中 \\log p_\\theta (x_i|x_{\\langle i}) 是根据我们的模型,条件于前置标记 x_{\\langle i} 的第 i 个标记的对数似然。直观地讲,它可以被看作是对模型在语料库中指定标记集合上进行均匀预测能力的评估。重要的是,这意味着分词过程对模型的困惑度 具有直接影响,这在比较不同模型时应始终被考虑。

这也等价于数据和模型预测之间交叉熵的指数形式。关于困惑度及其与每字符比特数(BPC)和数据压缩的关系的更多直观理解,请参阅这篇非常棒的 The Gradient 博客文章

采用固定长度模型计算困惑度

如果不受模型上下文长度的限制,我们会通过自回归地分解序列,并在每一步条件于整个前置子序列,来评估模型的困惑度,如下所示。

然而,在使用近似模型时,通常会限制模型可处理的标记数量。例如,GPT-2 的最大版本具有固定长度 1024 标记,所以当 t 大于 1024 时,我们不能直接计算 p_\\theta(x_t|x_{\\langle t})

相反,序列通常被分割成等于模型最大输入大小的子序列。如果模型最大输入大小是 k,那么我们通过仅条件于前面 k-1 个标记而非整个上下文,来近似计算标记 x_t 的似然。在评估序列的困惑度时,一个诱人的但次优方法是将序列分成不重叠的块,并独立地累加每段分解的对数似然。

这种方法计算速度快,因为每段的困惑度可通过一次前向传播计算,但这对完全分解的困惑度是较差的近似,通常会导致较高(较差)的 PPL,因为模型在大多数预测步骤中可利用的上下文较少。

因此,固定长度模型的困惑度应采用滑动窗口策略进行评估。该策略通过反复滑动上下文窗口,使模型在每次预测时拥有更多的上下文。

这更接近序列概率的真实分解,通常会得到更优的分数。缺点是每个标记都需要单独进行一次前向传播。一个实用的折中方案是采用带步幅的滑动窗口,每次移动的上下文更大,而非一步一标记滑动,这样可以大幅提升计算速度,同时仍给予模型较大的上下文用于预测。

例子:用 :hugs: 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("wikitext", "wikitext-2-raw-v1", split="test")
encodings = tokenizer("\n\n".join(test["text"]), return_tensors="pt")

:hugs: Transformers 中,我们可以简单地将 input_ids 作为 labels 传入模型,模型会返回每个标记的平均负对数似然作为 loss。然而使用滑动窗口时,每次传给模型的标记有重叠,我们不希望将仅用作上下文的标记的对数似然计入损失,因此可以将这些目标标记置为 -100,使其被忽略。下面展示了如何使用步幅为 512 实现该方法。这意味着在计算任一标记的条件似然时,模型至少有 512 个标记的上下文(前提是有 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  # 最后一轮可能与 stride 不同
    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 采用 CrossEntropyLoss 计算,平均只针对有效标签
        # 注意,模型只计算 trg_len - 1 个标签的 loss,因为标签内部向左移动了 1 位。
        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  # 减掉 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)

当步幅长度等于最大输入长度时,相当于上述非滑动窗口的次优策略。步幅越小,模型做出每个预测时拥有的上下文越多,得到的困惑度通常越优。

在上述代码中使用 stride = 1024(无重叠)时,得到的 PPL 为 19.44,与 GPT-2 论文中报告的 19.93 大致相当。采用 stride = 512 也就是步进滑动窗口策略后,PPL 降至 16.44。这不仅是更优的分数,而且计算方式更接近序列似然的真实自回归分解。