精通Transformer——参数高效微调

599 阅读26分钟

微调已经成为AI中的一种流行建模范式,尤其是在迁移学习中。本书中到目前为止的所有实验都基于更新所有参数的方式。因此,从现在开始,使用“全量微调”这个术语更为准确(也称为全模型微调或全参数微调)。

在本章中,我们将探讨部分微调策略。随着大型语言模型(LLM)参数的不断增加,微调和推理LLM变得极其昂贵。全量微调需要对所有参数进行完整更新,并为每个任务单独保存大型模型。不幸的是,这个过程在内存和运行时间方面都是昂贵的。例如,Bidirectional Encoder Representations from Transformers (BERT) 有3亿个参数,T5最多有110亿个,GPT有1750亿个,Pathways Language Model (PaLM) 有5400亿个参数,等等。因此,我们需要更加关注参数高效微调(PEFT)。

在本章中,我们将涵盖以下主题:

  • 什么是PEFT?
  • PEFT实践实验

技术要求

我们需要以下包:

  • adapter-transformers==3.2.1
  • transformers==4.29.0
  • peft==0.3.0
  • datasets==2.12.0

PEFT简介

在开始之前,让我们先问自己一个问题:在ChatGPT时代,我们知道大型语言模型(LLM)可以在不需要任何额外更新或微调操作的情况下解决许多问题,那么我们还需要进行微调操作吗?

答案是肯定的!我们目前使用诸如ChatGPT之类的模型来高效解决一些通用问题,例如情感分析、命名实体识别(NER)和摘要生成。然而,在工业界或学术界,我们经常需要处理受到文化、领域、时间和地理因素影响的非常特定的自然语言处理(NLP)任务。研究表明(参见《ChatGPT是通用自然语言处理任务解决方案吗?》,Chengwei Qin等),微调模型的效果优于类似ChatGPT的语言模型。因此,微调模型通常是更好的选择。

此外,还可以通过微调相对较小的模型,如BERT或T5,以在公司内部进行本地部署,从而降低信息安全泄露的风险。总之,现阶段我们仍然需要使用微调来满足NLP需求。

PEFT的优点可以总结如下:

  1. 多任务处理:处理多任务时,使用PEFT方法非常重要。全量微调为每个任务生成独立的微调模型参数,这在处理大量任务时可能代价高昂。PEFT使部署时任务切换变得容易。
  2. 避免灾难性遗忘:参数高效微调允许快速适应新任务,同时避免灾难性遗忘并节省参数。由于我们只更新与任务相关的参数而不修改主模型的参数,主模型不会发生显著的遗忘。
  3. 管理复杂过程:PEFT方法使我们在处理时间和内存复杂性时,有机会管理复杂的过程,例如超参数优化(HPO)。即使使用高性能硬件,在资源密集型应用中执行全量微调也是不切实际的。
  4. 多任务学习:PEFT方法更容易应用于多任务学习。诸如AdapterFusion等框架利用来自多个源任务的知识来提高目标任务的性能。

在本章中,我们将探讨PEFT方法,进行一些实验,并观察模型性能和训练效率的差异。我们还将探讨是否可以通过训练部分参数或使用其他高效微调方法来实现与全量微调相同的性能。首先,让我们讨论PEFT的几种方法。

理解PEFT的类型

在推理和微调过程中,为了让LLM更加普及,已经开发了几种方法。除了部分微调之外,一些方法还包括量化(如int8)、蒸馏和稀疏化。这些方法减少了推理和微调的内存需求。近年来,我们在模型修改方面看到了很大的努力,这些努力被称为PEFT,其目标是在保持与全量训练相当的性能的同时,尽可能减少更新的参数数量。尽管一些PEFT研究显示出比全量微调更好的结果,但这些成功可能对超参数非常敏感。

文献中有多种PEFT方法,例如Adapters、Prompt-tuning、Prefix-tuning、(IA)3、BitFit和大语言模型的低秩适应(LoRA)等。在一项调研中,这些方法被分为三大类(参见《Scaling Down to Scale Up: A Guide to Parameter-Efficient Fine-Tuning》,Vladislav Lialin, Vijeta Deshpande, and Anna Rumshisky):

  1. 附加方法(Additive methods)
  2. 选择性方法(Selective methods)
  3. 低秩微调(Low-rank fine-tuning)

附加方法

附加方法向Transformer模型添加新的特定任务的神经权重(参数)。原始模型的权重保持冻结状态,只在任务之间共享。在微调阶段,仅训练这些添加的权重,这导致<1%的参数效率,同时支持多任务学习。为了减少要更新的参数,可以在这个模型系列中应用瓶颈架构,将原始维度投影到较低的维度,然后再映射回原始维度。由于添加的权重可以特定于任务进行共享,这是一种易于使用的方法。adapter-transformers 是一个用于此目的的强大库,我们将使用它进行实践研究。

Adapters可以粗略分为串行和并行两种。串行Adapters是以顺序方式添加的Adapters层。Adapter Tuning(描述于《NLP的参数高效迁移学习》,Neil Houlsby等,国际机器学习会议,PMLR)和Prefix Tuning(描述于《Prefix-tuning: 优化生成的连续提示》,Xiang Lisa Li和Percy Liang)以及Prompt Tuning(描述于《参数高效提示调整的规模力量》,Brian Lester, Rami Al-Rfou,和Noah Constant)在模型的隐藏层以并行方式附加额外的标记。Prefix Tuning在每个Transformer层的注意力头部附加一个可学习的向量,而Prompt Tuning只将该向量附加到输入嵌入。Prefix Tuning和Prompt Tuning使用软提示(soft-prompting),即添加一个可训练的连续提示,而不是在提示工程阶段添加离散的单词或标记。通过训练,我们期望模型估计这个软(或连续)提示的嵌入。这类前缀(前缀、中缀和后缀)的缺点是减少了模型的可用序列长度。

选择性方法

这一家族的方法不向语言模型添加新参数,而是仅基于某些方法更新选定的参数。例如,Bias-only和BitFit方法仅训练网络中的偏置项,而冻结所有其他参数。稀疏微调方法,如FISH Mask,基于Fisher信息等公式选择模型的某些参数。SparseAdapter将稀疏化方法应用于添加的Adapter,而不是主模型。因此,它被认为是既选择性又附加的方法。

低秩微调

低秩结构在AI领域非常常见。许多任务具有一定的低秩结构,这有助于在低秩子空间中快速执行各种计算。这个家族中最重要的PEFT方法是LoRA(参见《LoRA: 大语言模型的低秩适应》,Edward J. Hu等)。它通过对自注意力机制中的Wq(query)和Wv(value)矩阵进行秩分解来进行低秩微调。所有预训练模型的参数保持冻结状态,只有这两个Wq(query)和Wv(value)矩阵通过重新参数化留为可训练状态。LoRA将它们分解为两个低秩矩阵的乘积。

这种重新参数化过程由于其选择性和低秩性质,显著减少了可训练参数的数量,同时实现了与全量训练相当的性能。这种方法的主要动机是适应器模型的延迟问题。基于适应器的模型由于必须顺序处理,LLM依赖于硬件并行性,因此会引入推理延迟问题。由于适应器层必须在基础模型之外计算,这不可避免地引入了额外的延迟。这些串行适应器模型适合模型微调,尤其是在单个GPU上。

AdaLoRA的作者(参见《自适应预算分配用于参数高效微调》,Qingru Zhang等)提出了LoRA的一种变体,以两种方式增强LoRA方法。首先,AdaLoRA通过赋予矩阵不同的重要性并修剪冗余的奇异值来微调LLM。这种方法强调,在微调预训练模型时,不同模块和层的权重矩阵具有不同的重要性。其次,虽然LoRA只对query和value投影应用SVD(奇异值分解),AdaLoRA则对所有权重矩阵应用SVD以提高其性能。

现在,是时候通过一些实验进行实践了。我们将在下一节中进行实验。

PEFT实践实验

在本节中,当使用CPU时,您可能会遇到一些问题或错误。我们建议您使用GPU进行训练。我们将使用PEFT解决两个不同的分类问题。在本书中,我们已经多次提到了情感分析问题。现在,为了使实验更加多样化,我们还将包含自然语言推理(NLI)问题。

我们将进行以下两个实验:

  1. 使用adapter-transformers高效微调BERT模型用于IMDb情感数据集
  2. 使用LoRA框架高效微调FLAN-T5用于NLI任务

使用Adapter Tuning微调BERT检查点

对于第一个问题,我们将专门针对IMDb情感任务进行处理,这是我们之前使用全量微调方法已经处理过的任务。这样,我们将有机会进行比较:

首先,安装必要的软件包,如下所示:

!pip install datasets==2.12.0 adapter-transformers==3.2.1

如您所见,我们不需要安装HF的transformers包,因为adapter-transformers可以作为HF的transformers库的替代品使用。

现在,我们将导入必要的模块:

import torch, os
from torch import cuda
import numpy as np
from transformers import AdapterTrainer
from transformers import (BertTokenizerFast, BertForSequenceClassification, Trainer, TrainingArguments)
from datasets import load_dataset

我们将微调一个bert-base-uncased模型:

model_path= 'bert-base-uncased'
tokenizer = BertTokenizerFast.from_pretrained(model_path)

如前面的实践实验中一样,我们从IMDb数据集中加载较小的部分:4K用于训练,1K用于测试,1K用于验证:

imdb_train= load_dataset('imdb', split="train[:2000]+train[-2000:]")
imdb_test= load_dataset('imdb', split="test[:500]+test[-500:]")
imdb_val= load_dataset('imdb', split="test[500:1000]+test[-1000:-500]")

我们定义tokenize_it()函数来对数据集进行编码,如下所示:

def tokenize_it(e):
    return tokenizer(e['text'], padding=True, truncation=True)

enc_train = imdb_train.map(tokenize_it, batched=True, batch_size=1000)
enc_test = imdb_test.map(tokenize_it, batched=True, batch_size=1000)
enc_val = imdb_val.map(tokenize_it, batched=True, batch_size=1000)

训练参数的初始化将与前面的vanilla训练相同。唯一不同的超参数是将learning_rate设置为2e-4。回想一下,在其他vanilla微调过程中,我们通常倾向于选择较低的学习率,大约在2e-5左右。选择较高的学习率值对于全量微调来说可能存在风险,因为我们会更新所有参数,可能导致灾难性遗忘。但现在,由于我们只更新了一些添加的参数,并没有接触整个BERT模型,所以您可以选择更高的学习率。

我们定义训练参数如下:

training_args = TrainingArguments(
    "/tmp",
    do_train=True,
    do_eval=True,
    num_train_epochs=3,
    learning_rate=2e-4,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=16,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    fp16=True,
    load_best_model_at_end=True
)

我们现在将在compute_acc()函数中定义要在训练期间监控的指标。目前,让我们简单地监控Accuracy指标:

def compute_acc(p):
    preds = np.argmax(p.predictions, axis=1)
    acc = {"Accuracy": (preds == p.label_ids).mean()}
    return acc

我们加载bert-base-uncased检查点如下:

from transformers import BertModelWithHeads
model = BertModelWithHeads.from_pretrained(model_path)

现在,我们将定义一个名为"imdb_sentiment"的adapter。因此,我们将冻结所有其他原始参数,只允许更新添加的参数:

model.add_adapter("imdb_sentiment")

我们将添加一个可训练的分类头,并将其与添加的adapter关联,名称为imdb_sentiment。对于这个头,我们还将指定它将有多少个类别:

model.add_classification_head("imdb_sentiment", num_labels=2)

现在,我们告知训练过程,将训练添加的adapter,如下代码所示:

model.train_adapter("imdb_sentiment")

我们这样做是因为在某些情况下,我们可能不希望训练添加的参数。特别是在多任务学习阶段,我们可能需要这样做。

让我们看看参数效率,如下所示:

trainable_params = model.num_parameters(only_trainable=True) / (2**20)
all_params = model.num_parameters() / (2**20)
print(f"{all_params=:.2f} M\n" +
      f"{trainable_params=:.2f} M\n" +
      f"The efficiency ratio is {100 * trainable_params / all_params:.2f}%")

输出如下:

all_params=105.83 M
trainable_params=1.42 M
the efficiency ratio = 1.34%

如您所见,我们的参数效率约为1.34%。

我们定义AdapterTrainer,而不是HF的vanilla Trainer类。以下代码触发训练过程:

trainer = AdapterTrainer(
    model=model,
    args=training_args,
    train_dataset=enc_train,
    eval_dataset=enc_val,
    compute_metrics=compute_acc
)
trainer.train()

训练输出如下:

image.png

正如我们所见,我们在1.34分钟内完成了3个epoch的模型微调。请记住这一点。现在让我们看看整体的准确率表现:

import pandas as pd
q = [trainer.evaluate(eval_dataset=data) for data in [enc_train, enc_val, enc_test]]
pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]

执行的输出如下:

image.png

我们的测试准确率大约为0.912。

结果表明,模型已经被快速且成功地微调。但它真的既快又成功吗?我们需要看看使用vanilla微调的实验结果。我们已经在第8章中进行了相同的IMDb实验。让我们回顾一下该实验的结果。以下截图显示了vanilla微调过程在三个epoch中的输出:

image.png

使用vanilla全量微调,训练过程花费了8.15分钟,而模型的微调只花了1.34分钟。正如我们所看到的,准确率表现是相当的。因此,我们可以安全地说,PEFT微调确实快速且成功!

使用LoRA高效微调FLAN-T5用于NLI任务

自然语言推理(NLI)涉及确定在给定前提下,一个假设是否为真(蕴涵),假(矛盾),或者未确定(中立)。我们将使用SNLI项目来完成这个任务。SNLI项目(链接)是一个包含570,000对句子对的集合,并标注了矛盾、蕴涵和中立的标签。

每个类别的示例如下:

前提 (Premise)假设 (Hypothesis)标签 (Label)
一个男人检查一个国家中的一个人物的制服。那个男人在睡觉。矛盾 (Contradiction)
一位年长和年轻的男人在微笑。两个男人在微笑并且在地板上玩耍的猫咪。中立 (Neutral)
一个由多名男性参与的足球比赛。有些人在进行一项运动。蕴涵 (Entailment)

正如上表所示,我们给出前提和假设,任务是将句子对映射到适当的类别。为了这个目的,我们将高效地训练FLAN-T5模型,首先安装必要的库:

!pip install transformers==4.29.0 peft==0.3.0 datasets==2.12.0

让我们导入必要的模块:

from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
from transformers import (default_data_collator, get_linear_schedule_with_warmup)
from peft import (get_peft_config, get_peft_model, get_peft_model_state_dict, LoraConfig, TaskType)
import torch, os
from torch.utils.data import DataLoader
from tqdm import tqdm
import pandas as pd
from datasets import (load_dataset, Dataset, DatasetDict)

SNLI数据集中有57万个示例。为了更快地进行原型开发,我们将使用1%的样本。以后可以使用相同的代码处理整个数据集。

dataset = load_dataset("snli")
snli_sampled = pd.DataFrame(dataset["train"])
snli_sampled = snli_sampled.sample(frac=0.01)

我们将查看采样后的SNLI数据集的标签分布:

snli_sampled.label.value_counts()

输出如下:

2    1870
1    1825
0    1798
-1     9
Name: label, dtype: int64

消除未标注的实例(标签为-1):

snli_sampled = snli_sampled[snli_sampled.label > -1]

标签名称如下:

names = dataset["train"].features["label"].names

输出:

['entailment', 'neutral', 'contradiction']

我们将需要一个id2label字典:

mapp = dict(enumerate(names))
mapp

输出:

{0: 'entailment', 1: 'neutral', 2: 'contradiction'}

数据集已经准备好了。然而,当前的数据集格式适合文本分类。我们可以微调一个BERT模型来完成这个任务,但在这个实验中,我们将以不同的方式处理这个任务,并通过重新表述实例来微调一个文本到文本的模型,FLAN-T5。我们已经在第4章介绍了FLAN-T5作为生成模型。FLAN框架(链接)能够在不更改模型架构的情况下,同时为1,800个不同的指令微调任务建模。该框架探索了T5模型在扩展语言模型的能力以执行基于指令的零样本任务方面的潜力。

由于FLAN-T5是一个文本到文本的模型,我们需要通过重新表述将当前的NLP任务(text1, text2-> label)转换为文本到文本的指令格式(text-in->text-out)。因此,我们需要使用模板将输入和输出都转换为文本。为此,我们将使用以下提示方法。您可以通过更多有趣的替代表达方式改进此提示方法,并重复实验。

让我们运行以下重新表述代码,并检查数据的新形式:

snli_sampled_df = pd.DataFrame(snli_sampled)
snli_sampled_df["text"] = snli_sampled_df.apply(
    lambda x: "S1:" + x.premise + " S2:" + x.hypothesis +
              ". The relation between S1 and S2 is labeled as entailment, neutral or contradiction ?",
    axis=1
)
snli_sampled_df["label"] = snli_sampled_df.apply(
    lambda x: f"It is {mapp[x.label]}",
    axis=1
)

输出看起来如下:

image.png

现在,我们有了两个新的文本和标签字符串,其中文本包括前提和假设的重新表述形式。同样,标签是原始标签的重新表述形式(例如,1 -> "it is neutral")。让我们检查一下最后生成的文本,如下所示:

S1:Three men in uniform walk around town. S2:Three men rob the residents. The relation between S1 and S2 is labeled as entailment, neutral or contradiction ?.

文本字段包含前提和假设。我们按顺序将它们命名为S1和S2,但我们也可以使用它们的原始名称,即premise和hypothesis。你可以尝试不同的方式。我们还注入了一个问题(例如,The relation between S1 and S2 is labeled as...?)以引导T5模型生成目标输出。为了引导模型输出期望的选项,我们提供了提示(… is labeled as entailment, neutral, or contradiction ?),因为生成式语言模型容易产生随机词语或幻觉。我们试图缩小输出空间。

我们将数据集分为两部分:训练集(70%)和验证集(30%),并将它们存储在snli_sampled_dict字典对象中,如下所示:

CUT = snli_sampled_df.shape[0] * 7 // 10
print(f"Training set size is {CUT}")
print(f"Validation set size is {snli_sampled_df.shape[0] - CUT}")
print(f"Total size is {snli_sampled_df.shape[0]}")
snli_sampled_dict = DatasetDict({
    "train": Dataset.from_pandas(snli_sampled_df[:CUT]),
    "validation": Dataset.from_pandas(snli_sampled_df[CUT:])
})

输出如下:

Training set size is 3847
Validation set size is 1650
Total size is 5497

我们工作的标记化和编码部分与文本(情感分析)和标记分类(NER)问题略有不同。因为输入和输出都是自然语言,我们需要通过tokenizer对它们进行编码。以下preprocess_function()函数执行此操作。我们通常在所有文本到文本问题中应用此过程:

def preprocess_function(examples):
    inputs = examples["text"]
    targets = examples["label"]
    model_inputs = tokenizer(inputs,
        max_length=max_length,
        padding="max_length",
        truncation=True,
        return_tensors="pt")
    labels = tokenizer(targets,
        max_length=max_target_len,
        padding="max_length",
        truncation=True,
        return_tensors="pt")
    labels = labels["input_ids"]
    labels[labels == tokenizer.pad_token_id] = -100
    model_inputs["labels"] = labels
    return model_inputs

现在,是时候选择模型了。模型选择是一个关键步骤,因为它必须与你的硬件资源兼容。那么,我们如何根据硬件容量选择模型呢?我们可以遵循一个简单的计算公式。公式非常简单,你只需要GPU支持的数量是语言模型参数数量的10倍。例如,一个BERT基础模型有1.1亿个参数,它将花费你(110M×10)1.2GB的GPU资源。这就是为什么BERT基础模型在最新资源上运行得非常轻松。

如果我们使用3亿参数的google/flan-t5-xl模型,代价将是3B×10=约30GB的GPU(一个参数需要10字节的GPU)。这只是一个非常粗略的计算。同样,flan-t5-base有2.5亿个参数(约需2.5GB的GPU),flan-t5-large有7.8亿个参数(约需8GB的GPU)也是其他的计算。由于16GB的GPU最近已成为广泛使用的资源,我们可以在此实验中从训练flan-t5-base开始。我们现在已经注释掉了另外两个更大的选择。由于我现在有一个NVIDIA A100 40GB的GPU,以后我可以用flan-t5-xlarge训练流水线。

现在,我们加载tokenizer如下:

model_name_or_path = "google/flan-t5-base" # 250M parameters
#model_name_or_path="google/flan-t5-large" #780M parameters
#model_name_or_path="google/flan-t5-xl" # 3B parameters
tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)

我们将通过我已准备好的处理函数处理snli_sample_dict对象。这里有一个序列长度问题需要仔细考虑:输入和输出的大小。为了提高效率,我尝试将这些值保持在尽可能小的范围内,但不破坏数据集,以便模型运行得更快。当我们检查数据集时,我们可以看到,基于最大token数,输入和输出大小的安全值分别约为150和10。没有输入或输出序列超过这些值。我们可以使用tokenizer.tokenize()函数来测量它。我们现在跳过这个计算。

让我们运行以下代码:

max_length = 150
max_target_len = 10
snli_processed = snli_sampled_dict.map(
    preprocess_function,
    batched=True,
    num_proc=1,
    remove_columns=snli_sampled_dict["train"].column_names,
    load_from_cache_file=False,
)
train_dataset = snli_processed["train"]
eval_dataset = snli_processed["validation"]

让我们快速查看编码后的数据集:

pd.DataFrame(train_dataset).head(3)

输出如下:

image.png

正如你所看到的,输入和标签已经从文本(字符串)转换为一系列的token ID。

现在,我们将在下面的代码中定义DataLoader。在选择批量大小时(这里为32),我们比较慷慨,因为我们的GPU容量足够大。如果这个值导致内存错误,请选择较小的批量大小。不过,请记住,较小的批量大小可能会影响模型性能。最近的报告表明,批量大小为16或32时可以达到最佳性能,但具体取决于你的任务:

batch_size = 32
train_dataloader = DataLoader(
    train_dataset,
    shuffle=True,
    collate_fn=default_data_collator,
    batch_size=batch_size,
    pin_memory=True)
eval_dataloader = DataLoader(
    eval_dataset,
    collate_fn=default_data_collator,
    batch_size=batch_size,
    pin_memory=True)

在下面的代码片段中,我们将通过控制with_peft=True变量来使用LoRA训练我们的模型。然后,为了进行比较,我们将在不使用LoRA的情况下执行vanilla训练。这两种训练的主要区别在于get_peft_model()方法表明T5模型将使用LoRA进行训练。我们通过LoraConfig对象安排配置的必要信息。另一个区别是学习率值。在我们之前的adapter-transformers实验中,当应用PEFT时,我们将学习率设置得更高。在当前的实验中,使用PEFT(LoRA)时学习率值为1e-3,而不使用PEFT时为5e-5。现在,我们将以with_peft=True设置运行此代码。然后,我们将此布尔值设置为False,这意味着我们应用vanilla训练,以查看两者之间的差异:

with_peft=True
model = AutoModelForSeq2SeqLM\
    .from_pretrained(model_name_or_path)
lr=2e-5
if with_peft:
    lr=1e-3
    peft_config = LoraConfig(
        task_type=TaskType.SEQ_2_SEQ_LM,
        inference_mode=False,
        r=8,
        lora_alpha=32,
        lora_dropout=0.1)
    model = get_peft_model(model, peft_config)
    model.print_trainable_parameters()

输出如下:

trainable params: 884K || all params: 248M || trainable: 0.35%

在这段代码中,r变量控制低秩子空间的程度,LoRA论文(Hu, Edward J., et al. “Lora: Low-rank adaptation of large language models.” arXiv preprint arXiv:2106.09685 (2021))推荐的值为8。因此,flan-t5-base的可训练参数数量为884,736,占整个模型参数的0.35%。如果将r变量设置为4并再次尝试,你会看到可训练参数数量减少到442,368(0.17%)。然而,我们需要控制在更改它时是否牺牲了成功率。

我们将使用经典的嵌套训练和评估循环,如库的遗留代码中建议的那样(链接):

device = "cuda"
model = model.to(device)
num_epochs = 3
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
lr_scheduler = get_linear_schedule_with_warmup(
    optimizer=optimizer,
    num_warmup_steps=0,
    num_training_steps=(len(train_dataloader) * num_epochs),
)
import time
st = time.time()
for epoch in range(num_epochs):
    model.train()
    total_loss = 0
    for step, batch in enumerate(tqdm(train_dataloader)):
        batch = {k: v.to(device) for k, v in batch.items()}
        outputs = model(**batch)
        loss = outputs.loss
        total_loss += loss.detach().float()
        loss.backward()
        optimizer.step()
        lr_scheduler.step()
        optimizer.zero_grad()
model.eval()
eval_loss = 0
eval_preds = []
for step, batch in enumerate(tqdm(eval_dataloader)):
    batch = {k: v.to(device) for k, v in batch.items()}
    with torch.no_grad():
        outputs = model(**batch)
        loss = outputs.loss
        eval_loss += loss.detach().float()
        eval_preds.extend(
            tokenizer.batch_decode(
            torch.argmax(outputs.logits, -1).detach().cpu().numpy(),
            skip_special_tokens=True)
        )
eval_loss_avg = eval_loss / len(eval_dataloader)
train_loss_avg = total_loss / len(train_dataloader)
print(f"{epoch=}-> {train_loss_avg=}\t {eval_loss_avg=}")
et = time.time()
elapsed_time = et - st

现在,我们将通过以下代码查看PEFT的整体性能:

zipped = zip(eval_preds, snli_sampled_dict["validation"]["label"])
q = [real.strip() in pred.strip() for pred, real in zipped]
print(f"{model_name_or_path=}")
print(f"{num_epochs=}")
print(f"{elapsed_time=:.2f} seconds" + (" with PEFT" if with_peft else " without PEFT"))
print(f"Accuracy={sum(q)/len(q):.2f}")

代码输出如下:

model_name_or_path='google/flan-t5-base'
num_epochs=3
elapsed_time=90.51 seconds with PEFT
Accuracy=0.86

现在,让我们使用with_peft=False运行相同的代码。我们将获得以下输出。正如你所看到的,没有LoRA的训练过程在运行时间上比使用LoRA更差:

输出如下:

model_name_or_path='google/flan-t5-base'
num_epochs=3
elapsed_time=117.82 seconds without PEFT
Accuracy=0.84

当我们尝试扩大模型规模时,我们得到如下表格的结果:

With LoRAWithout LoRAModel# paramsTime (sec.)AccuracyTimeAccuracy
flan-t5-base250M918611784
flan-t5-large780M2809036990
flan-t5-xlarge3B87992OOMOOM

注意:对于flan-t5-xlarge,使用批量大小为32时遇到了内存不足(OOM)错误。减小批量大小可以解决此错误,但在这种情况下,比较将失去效力。

这张表告诉我们两件事。首先,使用LoRA训练模型在速度和性能方面表现出良好的效率,而不牺牲准确率。另一方面,随着我们使用更大的模型扩展参数,训练变慢,但模型性能开始提升。正如你所看到的,我们分别为FLAN的基础、大型和超大型模型获得了86、90和92的准确率。但是我们不能说使用LoRA的模型训练在性能上超越了vanilla训练,至少目前是这样。

现在,我们将保存模型并查看使用的内存:

peft_model_path = "my_lora_model"
model.save_pretrained(peft_model_path)
!ls -lh $peft_model_path

输出如下:

total 9.2M
-rw-r--r-- 1 root root 333 May 25 20:22 adapter_config.json
-rw-r--r-- 1 root root 3.5M May 25 20:22 adapter_model.bin

注意:LoRA是一个可以更快训练的小型模型。但是,单独加载基础模型和LoRA模型可能会导致推理时的延迟。为了最小化这些延迟,PEFT库包括一个名为merge_and_unload()的函数。此函数将适配器权重与基础模型合并,使合并后的模型更易于作为独立模型使用。

合并并保存模型如下:

model = model.merge_and_unload()
model.save_pretrained("my_lora_model_merged")

加载合并并保存的模型非常简单:

model = AutoModelForSeq2SeqLM\
    .from_pretrained("my_lora_model_merged", load_in_8bit=True)

正如你所看到的,适配器模型只占用了3.5M的空间,而不是500MB的文件。让我们加载保存的模型并进行推理,如下所示:

from peft import PeftModel, PeftConfig
config = PeftConfig.from_pretrained(peft_model_path)
model = AutoModelForSeq2SeqLM\
    .from_pretrained(config.base_model_name_or_path)
model = PeftModel.from_pretrained(model, peft_model_path)
model.eval()
my_text = snli_sampled_dict["validation"]["text"][0]
my_label = snli_sampled_dict["validation"]["label"][0]
print(f"{my_text=}")
print(f"{my_label=}")

输出如下:

my_text: S1:A girl wearing a black jacket and pink boots is poking in the water of the creek with a stick, from the creek bank.S2:The girl is poking around in the creek trying to find something that she lost. The relation between S1 and S2 is labeled as entailment, neutral or contradiction ?
my_label: It is neutral.

让我们预测my_text

inputs = tokenizer(my_text, return_tensors="pt")
with torch.no_grad():
    outputs = model.generate(
        input_ids=inputs["input_ids"],
        max_new_tokens=10)
print(tokenizer.batch_decode(outputs.detach().cpu().numpy(), skip_special_tokens=True))

输出:

['It is neutral'] # 预测结果

我们成功地预测了该案例。接下来,我们将讨论量化的LoRA。

使用QLoRA进行微调

随着大型语言模型(LLMs)参数数量的增加,寻找更高效、更节省内存的方法成为了一个重要的研究方向。最近的一项研究提出了QLoRA模型(见《QLoRA: Efficient fine-tuning of quantized LLMs》,Tim Dettmers等人),该模型基于已经非常高效的LoRA模型进行了改进。这种方法在LLMs中得到了广泛应用。QLoRA通过将LLM量化到4位,显著减少了模型的内存使用,相较于LoRA更加高效。然后使用LoRA方法对量化后的LLM进行微调,使得微调后的模型在保持原始LLM大部分准确度的同时,速度更快,体积更小。

要使用QLoRA,只需通过以下代码片段初始化模型。其余代码与前面的代码相同。请注意,该代码需要bitsandbytes模块,可以通过以下命令安装:

!pip install bitsandbytes

将第17步中的最后两行代码替换为以下代码:

from transformers import BitsAndBytesConfig
from peft import prepare_model_for_kbit_training
from transformers import AutoModelForSeq2SeqLM

model_name = "google/flan-t5-base"
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16
)

model = AutoModelForSeq2SeqLM.from_pretrained(
    model_name_or_path,
    quantization_config=bnb_config,
    device_map={"": 0}
)
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, peft_config)

请从第17步重新开始这一过程,看看我们如何在保持原始模型和LoRA大部分准确度的同时,获得更快的运行时间。

做得好!恭喜你!借助PEFT方法,我们成功解决了两个不同的问题,在无需训练整个语言模型的情况下达到了相同的成功水平,并节省了时间。

总结

在本章中,我们讨论了如何通过PEFT使微调过程更加高效。我们介绍了三种不同的PEFT方法:增量方法、选择性方法和低秩方法。我们利用了adapter-transformers和HF的PEFT框架进行了实际操作实验。我们解决了文本分类和自然语言推理(NLI)任务,使用了两个Python PEFT库。

尽管LLMs有许多优点,但它们也为我们设置了诸如训练、微调和推理等巨大的障碍。如何克服这些障碍是一个重要的研究领域。未来,我们将重点关注如何处理、控制和利用LLMs,以及如何使它们更加普及。在本章中,我们仅专注于如何高效地微调它们,但仍有许多方面值得我们研究!

在下一章中,我们将讨论如何与LLMs合作。