大模型的padding——Llama 2为例

Posted by lili on January 4, 2024

本文是Padding Large Language Models — Examples with Llama 2的翻译。

目录

填充是大型语言模型(LLM)中最缺乏文档的方面之一。为什么呢?简单来说,LLM通常在没有填充的情况下进行预训练。

【译注:Llama 2在pretraining时没有使用Padding,为什么可以这样呢?我们可以把多个句子这样拼接:

s1: a b c d e
s2: h i j k l m n
s3: o p q r s t 

假设最大长度是10,那么可以这样:

<s> a b c d e </s> <s> h i
j k l m n </s> <s> o p q

这样做的好处是所有样本都是一样长,但是模型可能会看到半个句子。 】

尽管如此,在对自定义数据集进行LLM的微调时,填充是必要的。如果未正确填充训练示例,则可能导致各种意外行为:在训练过程中出现空损失或无穷损失,过度生成或在推断过程中出现空输出,这些都是填充不正确的症状。 【译注:为什么微调时不能用前面的方法呢?因为微调的输入通常远远小于最大长度,如果那样的话会把太多样本放到一个“句子”里了。 】

在本文中,我首先解释了什么是填充以及为什么它是必要的。然后,我展示了如何为没有填充的LLM找到正确的填充策略。我提出了两种使用Hugging Face的Transformers为LLM添加填充支持的不同解决方案。

在文章末尾,我还提供了示例,展示了如何为Llama 2填充训练示例。

阅读本文后,您应该能够自己弄清楚如何为LLM填充训练示例,而无需阅读它们的文档或教程。

填充和批次

什么是填充,为什么我们需要填充?

让我们以一个用于对LLM进行微调的示例为例。

example = "You are not a chatbot."

我们必须将此示例转换为标记序列。通常,诸如Transformers之类的库遵循以下步骤进行标记化:

根据给定的词汇将示例分段为子词:

example = ["▁You", "▁are", "▁not", "▁a". "▁chat", "bot", "."]

用词汇表中的索引替换单词,以获取整数序列:

example = [887, 526, 451, 263, 13563, 7451, 29889]

向序列添加特殊标记:BOS标记、EOS标记、UNK标记、PAD标记等。

example = [1, 887, 526, 451, 263, 13563, 7451, 29889]

注意:对于此示例,我使用了Llama 2的Tokenizer。我们将在下文详细介绍如何执行此操作。

在此示例中,仅添加了BOS(序列开始)特殊标记。

对于每个训练示例,还生成了一个注意力掩码。该掩码告诉变压器是否应该关注一个标记(1)或不关注(0)。该示例的注意力掩码很简单,因为所有标记都应该被考虑。

# 我们有与标记数量相同的值。

attention_mask = [1, 1, 1, 1, 1, 1, 1, 1]

接下来的步骤是使用Pytorch将所有内容包装成张量。这种包装对于应用CUDA和GPU进行优化的矩阵操作是必要的。

{'input_ids': tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889]]), 
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1]])}

现在,假设我们不是一个而是两个训练示例。为简单起见,我将只是复制已经有的示例。新的张量有一行多:

{'input_ids': tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889],
                      [1, 887, 526, 451, 263, 13563, 7451, 29889]]), 
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 1, 1, 1]])}

两个示例的长度相同(当然,因为它们是相同的)。两个张量的维度相同,都是2x8(N x M)。

示例被放入张量中以创建批次,以便神经网络在看到N个示例后可以更新其值。批处理对于计算效率和模型性能至关重要。

现在,让我们引入一个长度较短的第三个示例:

example = "You are not."

在标记化后,我们得到:

example = [1, 887, 526, 451, 29889]
attention_mask = [1, 1, 1, 1, 1]

如果尝试将其添加到我们的示例列表并创建张量,将会收到一个错误。但假设我们没有错误,我们将得到:

{'input_ids': tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889],
                      [1, 887, 526, 451, 263, 13563, 7451, 29889],
                      [1, 887, 526, 451, 29889]]), 
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1]])}

你能看到这里的问题以及为什么不可能创建这样的张量吗?

我们有一行长度不同。我们不能对这个应用矩阵操作。

在大多数数据集中,示例的长度不相同。我们必须修改它们以确保同一批次中的示例具有相同的长度。

这就是为什么我们需要“填充”的原因。

填充标记和填充位置

您可以将填充视为通过重复虚拟标记来扩展序列,使其达到指定长度。这个虚拟标记就是“填充标记”。

例如,我们上面的第一个例子有8个标记的长度(包括BOS标记)。假设在我们的批处理中,我们不会有超过8个标记长的序列。所有序列都必须是8个标记长。

我们的第二个例子只包含5个标记。因此,我们必须添加3个填充标记。

例如:”You are not. [PAD] [PAD] [PAD]”

在实践中,我们不手动向序列中添加“[PAD]”标记。大多数分词器会将“[PAD]”拆分为子词。填充标记通常是分词器内部定义的特殊标记,如果需要,将自动与其他特殊标记一起添加到序列中。 如果填充标记在词汇表中的ID为32000,我们会得到:

例子:[1, 887, 526, 451, 29889, 32000, 32000, 32000]

现在,我们有了一个期望长度的序列。但是仍然存在一个问题:我们还需要修改注意力掩码。

记住,填充标记是虚拟标记,我们不希望LLM给予它们任何注意力。我们只是引入这些标记来填充序列并创建正确的张量。

为了告诉模型这一点,我们只需在注意力掩码中放入“0”,以便模型将其忽略。

例如:[1, 1, 1, 1, 1, 0, 0, 0]

最后,我们可以使用填充后的示例创建正确的张量:

{‘input_ids’: tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889], [1, 887, 526, 451, 263, 13563, 7451, 29889], [1, 887, 526, 451, 29889, 32000, 32000, 32000]]), ‘attention_mask’: tensor([[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0]])}

注意:填充是在序列长度太短时执行的。但在某些情况下,序列可能太长。在这种情况下,我们必须截断序列,使其大小与最大长度相匹配。

填充的另一个重要参数是填充位置。在上面的例子中,我是向右填充的。如果模型有一个EOS标记,那么填充标记将在其后添加。

我们也可以选择向左填充。在这种情况下,张量如下:

{‘input_ids’: tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889], [1, 887, 526, 451, 263, 13563, 7451, 29889], [32000, 32000, 32000, 1, 887, 526, 451, 29889]]), ‘attention_mask’: tensor([[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1]])}

填充标记是在BOS标记之前添加的。

选择哪一侧主要取决于您想使用的LLM及其下游任务。这就是在做出任何决定之前研究模型及其分词器的重要性的原因。在下文中,我们将看到如何为Llama 2做出这个决定。

为因果型LLM添加填充支持

正如我们所看到的,对于微调来说,填充(几乎)总是必要的。然而,许多LLM默认不支持填充。这意味着它们的词汇表中没有特殊的填充标记。

在这里,我提供了两种添加填充标记的解决方案。

简单的解决方案

这个解决方案是大多数教程中都会找到的解决方案。

它简单地将一个现有的标记分配给填充标记。例如,您可以声明您的填充标记将是EOS标记。我们将得到类似于以下这样的张量(向右填充,其中“2”是EOS标记的ID):

{'input_ids': tensor([[1, 887, 526, 451, 263, 13563, 7451, 29889],
                      [1, 887, 526, 451, 263, 13563, 7451, 29889],
                      [1, 887, 526, 451, 29889, 2, 2, 2]]), 
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 0, 0, 0]])}

这种解决方案的问题在于LLM现在感到困惑:大多数情况下,EOS标记的注意力掩码将为“0”。这鼓励LLM忽略原始的EOS标记。这并不理想,因为EOS标记向LLM发出停止生成的信号。

此外,使用这种解决方案,我们必须向右填充。如果您向左填充,您将得到以EOS标记开头的序列,从而提前停止生成。

注意:我阅读了几篇微调Llama 2的教程,它们使用EOS标记进行左填充。如果您这样做,将会得到0.0的损失,并且训练会发散。试试看!观察这个过程很有趣。

在我看来,一个更好的选择是将UNK标记用作填充标记。这个标记很少被使用。它仅在序列中的标记不在词汇表中时出现。将其用于其他目的不应该有显著影响。

Meta在其Llama recipes中使用了这个替代方案。使用这种解决方案时,填充是哪一侧并不太重要。

替代解决方案:从头创建填充标记

UNK标记已经在模型中发挥了作用。理想情况下,我们希望有一个仅用于填充的填充标记。

如果词汇表中不存在填充标记,我们必须从头创建一个。这是Hugging Face为Llama 2推荐的解决方案。

使用诸如transformers之类的库,扩展词汇表很容易。

如果您想创建一个填充标记,必须按照以下步骤操作:

  • 将填充标记添加为LLM词汇表中的特殊标记
  • 调整标记嵌入
  • 重新训练标记嵌入(可选)

如果您的预算有限并且使用LoRa进行微调,则可能要跳过最后一步,因为标记嵌入可能包含数亿参数。此外,在我的实验中,重新训练嵌入总是导致更差的结果,表明我的微调数据集不够大,或者很难找到良好的超参数。

案例研究:使用Hugging Face的Transformers填充Llama 2

在本节中,我们将为Llama 2启用填充。要复制每个步骤,您需要访问Hugging Face上的Llama 2。我在这篇文章中解释了如何获取Llama 2。

注意:我在The Kaitchup上分享了一个笔记本,复制所有这些步骤。

首先,安装Transformers库:

pip install transformers

然后,我们导入transformers并加载标记器。确保将您的Hugging Face访问令牌放在这里:

from transformers import AutoTokenizer

# 用您自己的Hugging Face访问令牌替换以下内容。
access_token = "hf_token"

# 我们要量化的模型
pretrained_model_dir = "meta-llama/Llama-2-7b-hf"
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_dir, use_fast=True, use_auth_token=access_token)

我们定义了两个训练示例:

prompt1 = "You are not a chatbot."
prompt2 = "You are not."

如果我们将prompt1两次放入同一批次,一切都正常:

prompts = [prompt1, prompt1]
input = tokenizer(prompts, return_tensors="pt")
print(input)

输出:

{'input_ids': tensor([[    1,   887,   526,   451,   263, 13563,  7451, 29889],
        [    1,   887,   526,   451,   263, 13563,  7451, 29889]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1, 1, 1, 1]])}

但是如果添加prompt2,将如预期地收到错误:

prompts = [prompt1, prompt1, prompt2]
input = tokenizer(prompts, return_tensors="pt")
print(input)

输出:

ValueError: Unable to create tensor, you should probably activate truncation and/or padding with 'padding=True' 'truncation=True' to have batched tensors with the same length. Perhaps your features (`input_ids` in this case) have excessive nesting (inputs type `list` where type `int` is expected).

很明显,标记器没有对示例进行填充。

我们可以通过简单地将UNK标记用作填充标记来解决这个问题,如下所示:

tokenizer.padding_side = "left"
tokenizer.pad_token = tokenizer.unk_token
input = tokenizer(prompts, padding='max_length', max_length=20, return_tensors="pt")
print(input)

在这个例子中,我要求标记器填充到max_length。我将max_length设置为20。如果你的示例包含10个标记,标记器将添加10个填充标记。

{'input_ids': tensor([[    0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
             0,     0,     1,   887,   526,   451,   263, 13563,  7451, 29889],
        [    0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
             0,     0,     1,   887,   526,   451,   263, 13563,  7451, 29889],
        [    0,     0,     0,     0,     0,     0,     0,     0,     0,     0,
             0,     0,     0,     0,     0,     1,   887,   526,   451, 29889]]), 'attention_mask': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
        [0, 0, 0, 0, 0,     0,     0,     0,     0,     0,
             0,     0,     0,     0,     0,     1,   887,   526,   451, 29889]])}

另一种方法是从头开始创建一个填充标记。使用Hugging Face的transformers库,我们可以使用“add_special_tokens”方法完成这个任务。

tokenizer.add_special_tokens({'pad_token': '[PAD]'})
input = tokenizer(prompts, padding='max_length', max_length=20, return_tensors="pt")
print(input)

输出:

{'input_ids': tensor([[32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000,
         32000, 32000,     1,   887,   526,   451,   263, 13563,  7451, 29889],
        [32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000,
         32000, 32000,     1,   887,   526,   451,   263, 13563,  7451, 29889],
        [32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000, 32000,
         32000, 32000, 32000, 32000, 32000,     1,   887,   526,   451, 29889]]), 'attention_mask': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]])}

在将填充标记添加到Llama 2的词汇表之后,不要忘记调整其标记嵌入的大小。我在这篇文章中解释了如何做到这一点:文章链接。【这是收费的,但是看看文章提供的colab应该就会明白】

结论

一旦理解,填充就变得非常简单。

使用UNK标记进行填充,或者从头开始创建填充标记,都是非常安全的解决方案,适用于几乎所有因果型LLMs。但你应该始终查看标记器的工作方式。至少你应该了解它已经支持的特殊标记。例如,并非所有的LLMs都有UNK标记,有些LLMs的填充标记在词汇表中并未明确定义为填充标记等等。