在第二章中,我们探讨了语言模型的原理以及如何使用它们完成不同的任务,例如文本生成和序列分类。可以看到,通过适当的提示词和这些模型的零样本能力,语言模型在许多任务中无需进一步训练就能表现出色。我们还讲到了一些由社区提供的成千上万的预训练模型。在本章中,我们将学习如何通过在自有数据上进行微调来提升语言模型在特定任务上的表现。
尽管预训练模型展示了非凡的能力,但其通用目的的训练可能并不适合某些任务或领域。微调是为了使模型能够理解数据集或任务的细微差别而量身定制其理解能力。例如,在医疗研究领域,通用网络文本所预训练的语言模型并不能很好地执行任务,因此我们可以在医学文献的数据集上对其进行微调,以增强其生成相关医学文本或从医疗文件中提取信息的能力。另一个例子是制作对话模型。正如我们在第二章中学习到的,大型预训练模型通常是为了预测下一个词元而训练的,这通常不能直接适配对话。我们可以在包含日常对话和非正式语言结构的数据集上对这个模型进行微调,使其能够生成有趣的对话文本,就像在ChatGPT等界面中那样。
本章的目标是建立扎实的LLM微调基础,因此将涵盖以下内容:
- 使用微调的编码器模型对文本进行分类
- 使用解码器模型生成特定风格的文本
- 通过指令微调使用单个模型解决多项任务
- 参数高效微调技术,让我们能够使用更小的GPU训练模型
- 使我们能够用更少的计算资源运行模型推理的技术
本文相关代码请见GitHub仓库。
文本分类
在进入生成模型的领域之前,先来了解微调预训练模型通常的流程。为此,我们先从序列分类开始,在这个过程中,模型会为给定输入分配一个类别。序列分类是经典的机器学习问题之一。通过它,可以处理垃圾邮件检测、情感识别、意图分类和虚假内容检测等许多挑战。
我们将微调一个模型来对简短新闻文章摘要分类。读者很快就会知道,微调所需的计算资源和数据远远少于从头训练一个模型。通常的流程是:
- 确定任务的数据集
- 定义所需的模型类型(编码器、解码器或编码器-解码器)
- 找到满足需求的基础模型
- 预处理数据集
- 定义评估指标
- 训练及分享
1. 确定数据集
根据你的任务和用例,可使用公共或私有数据集(例如,贵司的数据集)。虽然预训练模型不需要标签数据,但我们会使用带标签的数据集进行序列分类。目标是将一个通用文本续写语言模型适配为一个文本分类器,需要告诉它检测的类别。查找公共数据集较好的地方有Hugging Face数据集、Kaggle、Zenodo和Google数据集搜索。面对成千上万的数据集,我们需要辅助方式来找到适合自己用例的数据集。一种方法是过滤Hugging Face上的文本分类数据集。
在下载量最多的数据集中,有一个著名的非商业数据集AG新闻数据集,它被用于基准测试文本分类模型以及研究数据挖掘、信息检索和数据流。
注:有时,我们可能希望与社区共享数据集。此时可以将其上传为数据集仓库。datasets库对常见数据类型(音频、图像、文本、csv、json、pandas等)提供了开箱即用的支持。对于自定义加载逻辑,还可以实现一个加载脚本,指定如何加载和拆分数据。
首先想到的应该是查看数据集。如下所示,数据集包含两列:一列是文本,另一列是标签。它提供了120,000个训练样本,足以微调一个模型。与预训练模型相比,微调所需的数据非常少,仅使用几千个示例就应该足够得到一个良好的基线模型。
1 2 3 4 |
from datasets import load_dataset raw_datasets = load_dataset("ag_news") raw_datasets |
1 2 3 4 5 6 7 8 9 10 |
DatasetDict({ train: Dataset({ features: ['text', 'label'], num_rows: 120000 }) test: Dataset({ features: ['text', 'label'], num_rows: 7600 }) }) |
来看一条具体的实例:
1 2 |
raw_train_dataset = raw_datasets["train"] raw_train_dataset[0] |
1 2 |
{'text': "Wall St. Bears Claw Back Into the Black (Reuters) Reuters - Short-sellers, Wall Street's dwindling\\band of ultra-cynics, are seeing green again.", 'label': 2} |
第一个样本包含文本和标签,标签是……2
?2
指的是哪个类别?可以查看数据集的label
字段来确定:
1 |
print(raw_train_dataset.features) |
1 |
{'text': Value(dtype='string', id=None), 'label': ClassLabel(names=['World', 'Sports', 'Business', 'Sci/Tech'], id=None)} |
搞定!那么,标签0
表示世界新闻,1
表体育,2
表商业,3
表示科技术。弄清楚这一点后,我们再决定使用哪种模型。
2. 定义使用哪种模型类型
我们回顾一下第二章。根据要解决的任务类型,可以使用三种transformer模型:
- 编码器模型:擅长从序列中获取丰富的表现。它们输出包含关于输入的语义信息的嵌入。我们可以在这些嵌入之上添加一个小型网络,并对其进行训练以完成依赖语义信息的新特定任务(例如识别文本中的实体或分类序列)。
- 解码器模型:非常适合生成新文本。
- 编码器-解码器模型:非常适合需要基于给定输入生成新句子的任务。
现在,考虑是到对简短新闻文章摘要进行分类的任务,有三种可能的方法:
- 零样本或少样本学习:我们可以使用一个高质量的预训练模型,说明任务(例如“分类成这四个类别”),然后让模型完成其余工作。这种方法不需要任何微调。
- 文本生成模型:微调一个文本生成模型,使其在给定输入新闻文章时生成标签(例如“
business
”)。这种方法类似于T5模型的做法——它通过将许多不同任务形式化为文本生成问题来解决,最终形成一个可以解决多种任务的模型。 - 带分类头的编码器模型:通过在嵌入上添加一个简单的分类网络(称为头)来微调编码器模型。这种方法提供了一个对我们的用例量身定制的专业高效模型,是进行分类任务的理想选择。
3. 选择一个不错的基础模型
我们的模型需要:
- 基于编码器架构。
- 足够小的,以便可以在几分钟内完成微调。
- 预训练效果优秀。
- 能处理少量词token。
虽然BERT已经相对较旧,但它仍是一个很好的基础编码器架构,非常适合微调。考虑到我们希望快速训练模型并且计算资源有限,可以使用DistilBERT,它比BERT小40%,速度快60%,同时保留了97%的BERT能力。给定这个基础模型,我们可以对其进行微调,以完成多个下游任务,例如回答问题或分类文本。
4. 预处理数据集
第二章中讲到,每个语言模型都有其特定的分词器。要微调DistilBERT,我们必须确保整个数据集使用与预训练模型相同的分词器进行分词。可以使用AutoTokenizer
加载相应的分词器,然后定义一个函数来对一批样本分词。transformer期望批次中的所有输入长度相同:通过添加padding=True
,我们在样本中添加零,使其具有与最长输入样本相同的大小。
需要注意的是,transformer模型有一个最大上下文大小——语言模型在进行预测时可以使用的最大词元数。对于DistilBERT,这个限制是512个token,所以不要用它处理整本书。所幸我们的大多数样本都是简短的摘要。尽管大多数示例很短,但有些可能包含较长的文本。我们可以使用truncation=True
将所有样本截断到模型的上下文长度,以防止出现问题。来看对两个示例的具体使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
from transformers import AutoTokenizer checkpoint = "distilbert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(checkpoint) def tokenize_function(batch): return tokenizer( batch["text"], truncation=True, padding=True, return_tensors="pt" ) tokenize_function(raw_train_dataset[:2]) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
{'input_ids': tensor([[ 101, 2813, 2358, 1012, 6468, 15020, 2067, 2046, 1996, 2304, 1006, 26665, 1007, 26665, 1011, 2460, 1011, 19041, 1010, 2813, 2395, 1005, 1055, 1040, 11101, 2989, 1032, 2316, 1997, 11087, 1011, 22330, 8713, 2015, 1010, 2024, 3773, 2665, 2153, 1012, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [ 101, 18431, 2571, 3504, 2646, 3293, 13395, 1006, 26665, 1007, 26665, 1011, 2797, 5211, 3813, 18431, 2571, 2177, 1010, 1032, 2029, 2038, 1037, 5891, 2005, 2437, 2092, 1011, 22313, 1998, 5681, 1032, 6801, 3248, 1999, 1996, 3639, 3068, 1010, 2038, 5168, 2872, 1032, 2049, 29475, 2006, 2178, 2112, 1997, 1996, 3006, 1012, 102]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 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, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])} |
在上例中,tokenize_function()
接收一批样本,使用DistilBERT的分词器对它们进行分词,并通过填充和截断确保长度一致。可以看到,第一个元素比第二个短,所以在末尾有一些ID为0
的额外词元。零对应于[PAD]
词元,在推理过程中会被忽略。注意,该样本的注意力掩码(attention mask)在末尾也有0
——这确保模型只关注实际的词元。
现在我们理解了分词过程,可以使用map
方法对整个数据集进行分词。
1 2 |
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True) tokenized_datasets |
1 2 3 4 5 6 7 8 9 10 |
DatasetDict({ train: Dataset({ features: ['text', 'label', 'input_ids', 'attention_mask'], num_rows: 120000 }) test: Dataset({ features: ['text', 'label', 'input_ids', 'attention_mask'], num_rows: 7600 }) }) |
5. 定义评估指标
除了监控损失外,还应定义一些下游指标来监控训练过程。我们将使用evaluate库,这是一个具有带各种指标的标准化接口工具。指标的选择取决于任务类型。对于序列分类,适合的指标有:
- 准确率(Accuracy):表示正确预测的比例,提供模型整体性能的顶层视图。
- 精确率(Precision):正确标记为正例的实例占所有标记为正例的实例的比例。它帮助我们理解正例预测的准确性。
- 召回率(Recall):模型正确预测的实际正例比例。它有助于揭示模型捕获所有正例实例的能力,因此如果存在假阴性,召回率会较低。
- F1分数(F1 Score):精确率和召回率的调和平均数,提供了一个考虑假阳性和假阴性的平衡度量。
evaluate中的指标提供了一个description
属性和一个compute()
方法,用于根据标签和模型预测获得指标。
1 2 3 4 5 |
import evaluate accuracy = evaluate.load("accuracy") print(accuracy.description) print(accuracy.compute(references=[0, 1, 0, 1], predictions=[1, 0, 0, 1])) |
1 2 3 4 5 6 7 8 9 |
Accuracy is the proportion of correct predictions among the total number of cases processed. It can be computed with: Accuracy = (TP + TN) / (TP + TN + FP + FN) Where: TP: True positive TN: True negative FP: False positive FN: False negative {'accuracy': 0.5} |
定义一个compute_metrics()
函数,该函数在给定预测实例(包含标签和预测)的情况下,返回一个包含准确率和F1分数的字典。在训练过程中评估模型时,将自动使用此函数来监控其进展。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
f1_score = evaluate.load("f1") def compute_metrics(pred): # 接收包含标签和预测的EvalPrediction labels = pred.label_ids preds = pred.predictions.argmax(-1) # Compute F1 score and accuracy f1 = f1_score.compute( references=labels, predictions=preds, average="weighted" )[ "f1" ] # 使用所加载的f1_score计算标签和预测的F1分数 acc = accuracy.compute(references=labels, predictions=preds)[ "accuracy" ] # 计算准确率 return {"accuracy": acc, "f1": f1} # 通过构建字典来返回指标 |
6. 训练模型
该开始训练模型了!回顾一下,DistilBERT是一个编码器模型。如果直接使用原始模型,我们将x像第二章中那样获得嵌入,因此不能直接使用这个模型进行分类。为分类文本序列,我们将这些嵌入传递给分类头。在微调过程中,我们不会使用固定的嵌入:所有模型参数、原始权重和分类头都是可训练的。这需要分类头是可微分的,并且我们会在transformer底座模型之上使用神经网络。这个分类头将嵌入作为输入并输出类别概率。为什么要训练所有权重?通过训练所有参数,会使嵌入对这个特定的分类任务更有用。
尽管我们会使用一个简单的前馈网络,但也可以使用更复杂的网络作为分类头,甚至使用经典模型,例如逻辑回归或随机森林(此时,我们将模型用作特征提取器并冻结权重)。使用简单层效果很好,计算效率高,并且是最常见的方法。
注:如果你在计算机视觉领域做过迁移学习,可能熟悉冻结基础模型权重的概念。在NLP中,通常不这么做,因为我们的目标是使内部语言表示执行下游任务。在计算机视觉中,冻结一些层很常见,因为基础模型学习到的特征更通用,对许多任务有用。例如,一些层捕捉到的特征如边缘或纹理,广泛适用于视觉任务。是否冻结层取决于上下文,包括数据集大小、计算量以及预训练和微调任务之间的相似性。在本章后面,我们将学习一种叫做适配器(adapter)的技术,通过它可使用冻结的LLM。
要训练带有分类头的模型,我们可以使用AutoModelForSequenceClassification
,还需要指定标签的数量(因为这会改变最后一层的神经元数量)。
1 2 3 4 5 6 7 8 |
import torch from transformers import AutoModelForSequenceClassification device = "cuda" if torch.cuda.is_available() else "cpu" num_labels = 4 model = AutoModelForSequenceClassification.from_pretrained( checkpoint, num_labels=num_labels ).to(device) |
1 2 3 4 5 |
import pprint pprint.pp( """Some weights of DistilBertForSequenceClassification were not initialized from the model checkpoint at distilbert-base-uncased and are newly initialized: ['classifier.bias', 'classifier.weight', 'pre_classifier.bias', 'pre_classifier.weight'] You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.""" ) |
1 2 3 4 5 6 |
('Some weights of DistilBertForSequenceClassification were not initialized ' 'from the model checkpoint at distilbert-base-uncased and are newly ' "initialized: ['classifier.bias', 'classifier.weight', 'pre_classifier.bias', " "'pre_classifier.weight']\n" 'You should probably TRAIN this model on a down-stream task to be able to use ' 'it for predictions and inference.') |
我们会看到一个关于某些权重被新初始化的错误。这很合理——我们有一个适配分类任务的新分类头,需要对其进行训练。
在模型初始化后,终于可以开始训练了。有不同的方法可以用来训练模型。如果读者熟悉PyTorch,可以编写自己的训练循环。或者,transformers提供了一个高级类Trainer
,它简化了训练循环的许多复杂性。
在创建Trainer
之前的第一步是定义TrainingArguments
,它指定了用于训练的超参数,如学习率和权重衰减,确定每批的样本数量,设置评估间隔,以及决定是否通过推送到Hub与其生态共享我们的模型。我们不会修改超参数,因为TrainingArguments
提供的默认值通常表现良好。不过,还是鼓励读者去探索和实验这些参数。Trainer
类是一个健壮且灵活的工具。(有几十个参数可供修改。推荐查看文档了解的所有的选项。)
1 2 3 4 5 6 7 8 9 10 11 |
from transformers import TrainingArguments batch_size = 32 # You can change this if you have a big or small GPU training_args = TrainingArguments( "trainer-chapter4", push_to_hub=True, # 是否在每次保存模型时将其推送到Hugging Face Hub。可以通过save_strategy修改保存的频率,默认是几百步。 num_train_epochs=2, # 执行的epoch总数 to perform;一个epoch是训练数据的一次全传递。 evaluation_strategy="epoch", # 何时在验证集上评估模型。默认每500步,但通过指定epoch,在每个 epoch 结束时评估。 per_device_train_batch_size=batch_size, # 训练每核的批次大小。如显存耗尽可减小这一数字。 per_device_eval_batch_size=batch_size, ) |
现在已经有了所需的所有组件:
- 一个带相应分类头的待微调预训练模型
- 训练参数
- 计算指标的函数
- 训练和评估数据集
- 分词器,添加它来确保它随模型一起推送到Hub
AG News数据集包含12万个样本,这远远超过了我们获取良好初步结果所需的样本数量。为了进行初步快速训练,我们将使用1万个样本,但请随意调整这个数字——更多的数据应该会带来更好的结果。请注意,我们仍将使用整个测试集进行评估。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
from transformers import Trainer shuffled_dataset = tokenized_datasets["train"].shuffle(seed=42) small_split = shuffled_dataset.select(range(10000)) trainer = Trainer( model=model, args=training_args, compute_metrics=compute_metrics, train_dataset=small_split, eval_dataset=tokenized_datasets["test"], tokenizer=tokenizer, ) |
一切准备就绪且初始化好了Trainer
,可以开始训练了。
1 |
trainer.train() |
训练会报告损失、评估指标及训练速度详情。以下总结表:
Metric | Epoch 1 Value | Epoch 2 Value |
---|---|---|
eval_loss | 0.2624 | 0.2433 |
eval_accuracy | 0.9117 | 0.9184 |
eval_f1 | 0.9118 | 0.9183 |
eval_runtime | 15.2709 | 14.5161 |
eval_samples_per_second | 497.678 | 523.557 |
eval_steps_per_second | 15.585 | 16.396 |
train_runtime | – | 213.9327 |
train_samples_per_second | – | 93.487 |
train_steps_per_second | – | 2.926 |
train_loss | – | 0.2714 |
push_to_hub
。
1 |
trainer.push_to_hub() |
虽然使用Trainer
可能看起来像一个黑盒子,但底层它和我们在扩散章节中训练简单扩散模型时进行常规的PyTorch训练循环一样。从头编写这样的循环大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
from transformers import AdamW, get_scheduler optimizer = AdamW(model.parameters(), lr=5e-5) # 优化器保存模型的当前状态,并根据梯度更新参数 lr_scheduler = get_scheduler("linear", ...) # 学习率调度器定义学习率在训练过程中的变化 for epoch in range(num_epochs): # 遍历所有数据若干个epoch for batch in train_dataloader: # 遍历训练数据中的所有批次 batch = {k: v.to(device) for k, v in batch.items()} # 将批次移动到设备上并运行模型 outputs = model(**batch) loss = outputs.loss # 计算损失并进行反向传播 loss.backward() optimizer.step() # 更新模型参数,调整学习率,并将梯度重置为零 lr_scheduler.step() optimizer.zero_grad() |
Trainer
负责处理这一切,包括进行评估和预测,将模型推送到Hub、使用多GPU进行训练、保存即时检查点、记录日志等许多任务。
如果将模型推送到Hub,其他人就可以使用AutoModel
或pipeline()
来访问它。示例如下。
1 2 3 4 5 6 7 |
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="AlanHou/trainer-chapter5") pipe( """The soccer match between Spain and Portugal ended in a terrible result for Portugal.""" ) |
1 |
[{'label': 'LABEL_1', 'score': 0.9112359881401062}] |
让我们深入探讨一下指标。可以使用Trainer.predict
方法获取测试数据集中所有样本的预测结果。输出是一个PredictionOutput
对象,包含预测结果、标签ID和指标。通过查看前三个样本文本及其对应的预测和参考标签来确认没有疏漏。在网络上运行部分样本很重要,可确保一切正常工作。
1 |
tokenized_datasets["test"].select([0, 1, 2]) |
1 2 3 4 |
Dataset({ features: ['text', 'label', 'input_ids', 'attention_mask'], num_rows: 3 }) |
1 2 3 4 5 6 7 |
# Run inference for all samples trainer_preds = trainer.predict(tokenized_datasets["test"]) # Get the most likely class and the target label preds = trainer_preds.predictions.argmax(-1) references = trainer_preds.label_ids label_names = raw_train_dataset.features["label"].names |
1 |
0%| | 0/238 [00:00<?, ?it/s] |
1 2 3 4 5 6 7 |
# Print results of the first 3 samples samples = 3 texts = tokenized_datasets["test"]["text"][:samples] for pred, ref, text in zip(preds[:samples], references[:samples], texts): print(f"Predicted {pred}; Actual {ref}; Target name: {label_names[pred]}.") print(text) |
1 2 3 4 5 6 |
Predicted 2; Actual 2; Target name: Business. Fears for T N pension after talks Unions representing workers at Turner Newall say they are 'disappointed' after talks with stricken parent firm Federal Mogul. Predicted 3; Actual 3; Target name: Sci/Tech. The Race is On: Second Private Team Sets Launch Date for Human Spaceflight (SPACE.com) SPACE.com - TORONTO, Canada -- A second\team of rocketeers competing for the #36;10 million Ansari X Prize, a contest for\privately funded suborbital space flight, has officially announced the first\launch date for its manned rocket. Predicted 3; Actual 3; Target name: Sci/Tech. Ky. Company Wins Grant to Study Peptides (AP) AP - A company founded by a chemistry researcher at the University of Louisville won a grant to develop a method of producing better peptides, which are short chains of amino acids, the building blocks of proteins. |
预测结果与参照标签一致,并且标签合理。现在深入探讨一下指标。
在机器学习分类任务中,混淆矩阵作为表格总结了模型的性能,展示了真阳性、真阴性、假阳性和假阴性预测的计数。对于多类别分类,矩阵变成一个维度等于类别数的方阵,每个单元格代表标签和预测类别组合的实例计数。行表示实际(真实)类别,而列表示预测类别。分析这个矩阵可以提供模型在区分特定类别方面的优势和劣势。
例如,通过查看下列混淆矩阵,我们可以看到商业文章经常被错误标记为科技文章。为了获得混淆矩阵,我们使用evaluate库,它可加载混淆矩阵指标。为显示矩阵,我们可以使用sklearn库中的plot_confusion_matrix
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import matplotlib.pyplot as plt from sklearn.metrics import ConfusionMatrixDisplay confusion_matrix = evaluate.load("confusion_matrix") cm = confusion_matrix.compute( references=references, predictions=preds, normalize="true" )["confusion_matrix"] fig, ax = plt.subplots(figsize=(6, 6)) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=label_names) disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False) plt.title("Normalized confusion matrix") plt.show() |
生成文本
我们刚刚微调了一个基于编码器的模型进行文本分类。下面深入研究训练一个用于文本生成的模型。与文本分类不同,生成式模型通常不需要标注数据。例如,如果我们的目标是生成代码,我们可以收集一个许可代码数据集(例如The Stack),并从头开始训练一个模型。虽然这很有趣,但要获得不错的结果需要大量计算资源(需要数周甚至几个月的训练时间!)。
注:Kocetkov, Denis等的论文《The Stack: 3 TB许可授权的源代码》
注:虽然我们可以在没有标注数据的情况下构建强大的生成式模型,但一种称为RLHF(基于人类反馈的强化学习)的新方法允许我们在训练中引入人类标注数据,以使模型输出对齐某些偏好输出。例如,希望避免模型生成有害内容。我们将在后面学习更多关于这种方法的内容。
与其从头开始训练一个开放式文本生成模型,不如微调一个现有模型,以生成特定风格的文本。这种方法允许我们利用模型现有的语言知识,大大减少对大量数据和算力的需求。例如,可以使用几百条推文来生成具有你独特写作风格的新推文。在这个例子中,我们将使用AG News数据集来训练模型生成商业新闻。
首先过滤出所有标记为商业的样本(标签为2
),并删除不必要的标签列。
1 2 |
filtered_datasets = raw_datasets.filter(lambda example: example["label"] == 2) filtered_datasets = filtered_datasets.remove_columns("label") |
第二个问题是选择哪个基础模型。在微调过程中,选择合适的基础模型是一个至关重要的决定。很多因素会影响这个决定,下面探讨其中的一些因素:
- 模型大小:在个人电脑上部署一个600亿参数的模型是不现实的。模型大小的选择取决于预期的推理需求、硬件容量和部署要求。在本章的后面,我们将深入研究一些技术,这些技术使得使用相同的计算资源来运行具有更多参数的模型成为可能。
- 训练数据:微调模型的性能与基础模型的训练数据与推理数据的匹配度相关。例如,微调一个模型生成符合自有代码库风格的代码时,最好从一个专门针对代码预训练的模型开始。考虑数据源的专业度,特别是并非所有模型都会披露其训练数据。同样,我们不会希望用一个主要基于英语的模型来生成韩语文本。并非所有模型都会披露其数据来源,这使得了解这一点变得具有挑战性。
- 上下文长度:不同的模型有不同的上下文长度限制。上下文长度是模型在进行预测时可以使用的最大标记数。例如,如果上下文长度为1024,则模型可以使用最后的1024个标记来进行预测。要生成长篇文本,需要一个具有较大上下文长度的模型。
- 许可证:选择基础模型时,许可证也至关重要。考虑模型是否符合你的使用要求。模型可能具有商业或非商业许可证,并且开源许可证与开放访问许可证之间存在区别。了解这些许可证对于确保遵守法律和使用限制至关重要。例如,虽然有些模型允许商业使用,但它们可能规定了允许的使用案例和不应使用模型的场景。
评估生成式模型仍是一个挑战,有各种基准来评估具体方面。诸如ARC用于科学问题,HellaSwag用于常识推理以及其它用作不同能力的代理的基准。Hugging Face Open LLM Leaderboard收集了数千个模型的基准结果,并可根据模型大小和类型进行筛选。然而,需要注意的是,这些基准只是系统比较的工具,最终的模型选择应始终基于其在现实任务中的表现。举一个具体的例子,Open LLM Leaderboard中使用的基准并不专注于对话,因此不应作为选择对话模型的主要标准。
下表显示了几个著名的开源预训练LLM(大型语言模型)。需要考虑的因素很多。这个表格并不详尽;还有许多其他开放的LLM,例如Mosaic MPT、Stability StableLM和Microsoft Phi,读者在读到本文时还会有更多。同样,这个表格不包括代码模型。对于这类模型,可能需要查看Big Code Models Leaderboard,在那里可以找到诸如CodeLlama(Meta的知名模型)和BigCode的模型(一个使用宽松许可证代码训练的模型)等。
Model | Creator | Size | Training Data | Open LLM Performance | Context length | Vocab size | License |
---|---|---|---|---|---|---|---|
GPT-2 | OpenAI | 117M 345M 762M 1.5B | Unreleased. Up to 40GB of text from a web scrape | 30.06 33.64 34.08 36.66 | 1024 | 50257 | MIT |
GPT-Neo | EleutherAI | 125M 1.3B 2.7B 20B | The Pile 300B tokens 380B tokens 420B tokens | 31.19 36.04 38.96 43.95 | 2048 | 50257 | MIT |
Falcon | TII UAE | 7B 40B 180B | Partially released RefinedWeb built on top of CommonCrawl 1.5T tokens 1T tokens 3.5T tokens | 47.01 61.48 67.85 | 2048 | 65024 | Apache 2.0 (7B and 40B) Custom (180B) |
Llama 2 | Meta | 7B 13B 70B | Unreleased. 2T tokens | 54.32 58.66 67.35 | 4096 | 32000 | Custom |
Mistral | Mistral | 7B | Unreleased | 60.45 | 8000 | 32000 | Apache 2.0 |
此外,值得注意的是,这个表格偏向于主要基于英语数据训练的模型。然而,强大的中文模型如InternLM、ChatGLM、Qwen和Baichuan也是预训练语言模型领域的重要贡献者。这些信息是为了在选择实验模型时提供参考,而不是列出所有开源模型的详尽列表。
鉴于我们希望在没有强大GPU的环境中进行数据量非常小的快速训练,我们将微调GPT-2的最小变体。鼓励读者尝试使用更大的模型和不同的数据集。在本章后面,我们将探讨一些用于推理和训练大型模型的技术。
和之前一样,我们从加载模型和分词器开始。GPT-2的一个特别之处是它没有指定填充标记,但在进行分词时我们需要这样的标记,以确保所有样本具有相同的长度。我们可以将填充标记设置为与文本结束标记相同。
1 2 3 4 5 6 7 8 |
from transformers import AutoModelForCausalLM model_id = "gpt2" tokenizer = AutoTokenizer.from_pretrained(model_id) tokenizer.pad_token = ( tokenizer.eos_token ) # Needed as gpt2 does not specify padding token. model = AutoModelForCausalLM.from_pretrained(model_id).to(device) |
我们对数据集进行分词(但使用GPT2的分词器)。
1 2 3 4 5 6 7 8 9 |
def tokenize_function(batch): return tokenizer(batch["text"], truncation=True) tokenized_datasets = filtered_datasets.map( tokenize_function, batched=True, remove_columns=["text"], # We only need the input_ids and attention_mask ) |
1 |
tokenized_datasets |
1 2 3 4 5 6 7 8 9 10 |
DatasetDict({ train: Dataset({ features: ['input_ids', 'attention_mask'], num_rows: 30000 }) test: Dataset({ features: ['input_ids', 'attention_mask'], num_rows: 1900 }) }) |
在分类示例中,我们对所有样本进行了填充和截断,以确保它们具有相同的长度。除了在分词阶段进行这些操作外,我们还可以使用数据整合器(collator)来完成这项工作。数据整合器是将样本组装成一个批次的工具。transformers库提供了一些开箱即用的整合器,用于不同任务(例如语言建模)。整合器会动态地将批次中的样本填充到最大长度。除了填充外,语言建模整合器还会为语言建模任务结构化输入,这比之前稍微复杂一些。在语言建模中,我们将输入向右移动一个元素,并将其作为标签。例如,如果输入是I love Hugging Face
,,那么标签就是love Hugging Face
。模型的目标是根据前一个词预测下一个词。实际上,数据整合器会创建一个标签列,其中包含输入的副本。稍后,模型将负责移动输入和标签。
1 2 3 |
from transformers import DataCollatorForLanguageModeling data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False) |
来看如何对三个样本进行操作。如下所示,每个样本的长度不同(37、55和51)。
1 2 3 4 |
samples = [tokenized_datasets["train"][i] for i in range(3)] for sample in samples: print(f"input_ids shape: {len(sample['input_ids'])}") |
1 2 3 |
input_ids shape: 37 input_ids shape: 55 input_ids shape: 51 |
凭借整合器,样本填充为批次中的最大长度(55)并且添加了label
列。
1 2 3 |
out = data_collator(samples) for key in out: print(f"{key} shape: {out[key].shape}") |
1 2 3 |
input_ids shape: torch.Size([3, 55]) attention_mask shape: torch.Size([3, 55]) labels shape: torch.Size([3, 55]) |
最后,我们需要定义训练参数。本例中,我们修改了几个参数,以展示TrainingArguments
提供的一些控制和灵活性。让我们仔细看看几个关键参数,展示它们对模型训练的重大影响:
- 权重衰减:权重衰减是一种正则化技术,通过向损失函数添加惩罚项来防止模型过拟合。它防止学习算法分配过大的权重。在
TrainingArguments
中调整权重衰减参数可以微调这种正则化效果,从而影响模型的泛化能力。 - 学习率:学习率是决定优化步长的关键超参数。在
TrainingArguments
中,可以指定学习率,从而影响训练过程的收敛速度和稳定性。仔细调整学习率可以显著影响模型的性能。 - 学习率调度器类型:学习率调度器决定了训练期间学习率的变化方式。不同的任务和模型架构可能受益于特定的调度策略。
TrainingArguments
提供了定义学习率调度器类型的选项,使我们可以尝试各种调度,如恒定学习率、余弦衰减等。
1 2 3 4 5 6 7 8 9 10 11 12 |
training_args = TrainingArguments( "sft_cml", push_to_hub=True, per_device_train_batch_size=8, weight_decay=0.1, lr_scheduler_type="cosine", learning_rate=5e-4, num_train_epochs=2, evaluation_strategy="steps", eval_steps=200, logging_steps=200, ) |
完成所有这些设置后,就像在分类示例中一样,最后一步是使用所有组件创建一个Trainer
实例。主要的区别是这次我们使用了数据整理器,并使用的是5000个样本。
1 2 3 4 5 6 7 8 |
trainer = Trainer( model=model, tokenizer=tokenizer, args=training_args, data_collator=data_collator, train_dataset=tokenized_datasets["train"].select(range(5000)), eval_dataset=tokenized_datasets["test"], ) |
1 |
trainer.train() |
Metric | Epoch 0.32 Value | Epoch 0.64 Value | Epoch 0.96 Value | Epoch 1.28 Value | Epoch 1.6 Value | Epoch 1.92 Value |
---|---|---|---|---|---|---|
loss | 3.7271 | 3.346 | 3.0685 | 2.1435 | 1.9834 | 1.8937 |
learning_rate | 0.0004690767 | 0.0003839567 | 0.0002656976 | 0.0001435552 | 4.774575e-05 | 1.971325e-06 |
eval_loss | 3.6065 | 3.4732 | 3.3985 | 3.4433 | 3.4203 | 3.3980 |
eval_runtime | 4.7941 | 4.9057 | 4.7142 | 4.7107 | 4.9247 | 4.7953 |
eval_samples_per_second | 396.317 | 387.303 | 403.041 | 403.333 | 385.812 | 396.218 |
eval_steps_per_second | 49.644 | 48.515 | 50.486 | 50.523 | 48.328 | 49.632 |
1 |
trainer.push_to_hub() |
和此前一样,我们可以使用pipeline
指定任务(text-generation
)加载模型及运行推理。
1 2 3 4 5 6 7 |
from transformers import pipeline pipe = pipeline("text-generation", model="AlanHou/sft_cml", device=device) pipe.tokenizer.pad_token_id = 50256 # pad_token_id for gpt2 print(pipe("Q1", pad_token_id=tokenizer.eos_token_id)[0]["generated_text"]) print(pipe("Wall", pad_token_id=tokenizer.eos_token_id)[0]["generated_text"]) print(pipe("Google", pad_token_id=tokenizer.eos_token_id)[0]["generated_text"]) |
1 2 3 |
Q1 profit rises, says Santander Santander still ahead, but sees no gain ATLANTA (Reuters) - SBC, Europe #39;s seventh-largest bank, is claiming annual profits of up to \$2.35 billion Wall St. Looks Ahead; Wall Street Awaits Data (Reuters) Reuters - Stocks were set for a slightly\higher open on today as a fall in crude oil prices and reassuring U.S.\economy data about jobs kept Wall Google posts surge in revenue Shareholders bought up to 30 percent in an unusual auction that began Wednesday in Philadelphia and ended last night in New York with \$2 billion in initial public offerings and 1.5 million shares of debt. The company posted a |
可以看到,生成的文本结构与AG新闻的商业部分相似。但生成的内容有时可能是不连贯的,这很正常,因为我们使用了一个较小的基础模型,其质量不高且训练数据很少。使用Mistral 7B或Llama 2的70B变体之类的大模型,肯定会生成更连贯的文本,同时保持相同的格式。
在评估生成文本的质量时,常用的指标是困惑度(perplexity)。困惑度衡量了语言模型对给定数据集的预测能力。较低的困惑度值表示更好的性能,表明模型可以更准确地预测下一个词。虽然困惑度提供了定量指标,但定性评估,包括人工判断,对于评估生成文本的整体连贯性和与预期任务的相关性也至关重要。平衡定量和定性评估可以确保对文本生成模型进行更全面的评估。
指令
在本章的第一部分,我们讨论了微调基于编码器的模型以完成特定的文本分类任务,如话题分类。但这种方法需要为每个任务训练一个新模型。如果我们遇到一个未见过的任务,比如识别文本是否为垃圾邮件,会没有现成的预训练模型可用,需要为其微调一个模型。这促使我们探索其他技术,简要讨论不同方法的优点、局限和用途:
- 微调多个模型:我们可以为每个任务选择并微调一个基础模型,以构建一个专用模型。在微调过程中,所有的模型权重都会更新,这意味着如果我们要解决五个不同的任务,我们最终会有五个微调过的模型。
- 适配器(Adapter):我们可以冻结基础模型并训练一个称为适配器的小型辅助模型,而不必修改所有的模型权重。每个新任务仍然需要不同的适配器,但它们的体积非常小,因此我们可以轻松地拥有多个适配器而不会增加负担。接下来的部分会讲解适配器。
- 提示词:正如在第一章中学到的,我们可以利用强大的预训练模型的零样本和少样本能力来解决不同的任务。使用零样本方法,我们编写一个详细说明任务的提示词。使用少样本方法,添加一些解决任务的示例来提高模型的性能。这些能力的表现取决于基础模型的能力。一个非常强大的模型,如GPT-4,可能会产生非常棒的零样本结果,这对于处理各种任务非常有用,如撰写长邮件或总结书籍章节。
- 指令微调:指令微调是一种改进LLM零样本性能的可选且简单的方法。指令微调将任务形式化为指令,如“这篇文章的主题是商业还是体育?”或“将’how are you’翻译成西班牙语”。这种方法主要涉及构建包含多任务指令的数据集,然后使用这些指令数据集的融合对预训练语言模型进行微调。创建指令微调的数据集相对简单;例如,可以利用AG新闻,将输入和标签结构化为指令,通过构建这样的提示词:
123456To which of the "World", "Sports, "Business" or "Sci/Tech" categoriesdoes the text correspond to? Answer with a single word:Text: Wall St. Bears Claw Back Into the Black (Reuters)Reuters - Short-sellers, Wall Street's dwindling\\band ofultra-cynics, are seeing green again.
通过构建足够大的多样化指令数据集,我们可以得到一个通用的指令微调模型,它可以解决许多任务,甚至是未出现过的任务,这是由于跨任务的泛化能力。这一理念是Flan模型的核心,Flan可以直接解决62个任务。这一概念在Flan T5模型中得到了进一步扩展,它是一组开源的指令微调T5模型,能够解决超过1000个任务。需要注意的是,这种模型是用输入(指令)和输出(回答)文本进行训练的;与GPT-2微调示例不同,这是一种监督训练技术。指令微调在T5或BART等编码器-解码器架构中非常流行,因为数据集的输入-输出结构非常适合这种方法。
什么时候应该使用微调、指令微调或提示词工程呢?这取决于任务、可用资源、期望的实验速度等。通常,特定任务或领域的微调模型表现更好。另一方面,它不能直接处理未出现过的任务。指令微调更具灵活性,但定义数据集和结构需要额外的处理。提示词工程是快速实验的最灵活方法,因为它不需要你直接训练模型,但需要一个更强大的基础模型,并且对生成的控制有限。
指令微调研究综述
我们不会构建一个端到端的指令微调示例,因为这主要是一个数据集任务,而不是建模任务,但我们来讨论一些优秀的论文,以便读者可以深入研究这个主题。
- Finetuned Language Models Are Zero-Shot Learners (February 2022)训练了一个名为Flan的模型,使用指令微调,超过了基础模型的零样本性能和其他模型的少样本性能。
- 在Flan之后,出现了一波新的数据集论文。Cross-Task Generalization via Natural Language Crowdsourcing Instructions (March 2022)引入了自然语言指令,这是一个包含61个任务的数据集,有人工指令和193,000对输入-输出对,通过将现有的NLP数据集映射到统一的架构来生成。这样做的前提是,人类可以通过从其他任务的实例中学习(以监督的方式)来遵循指令解决未出现过的问题。作者对编码器-解码器模型BART进行指令微调,相对不使用指令,跨任务泛化能力提升了19%。模型看过的任务越多,表现越好。
- Multitask Prompted Training Enables Zero-Shot Task Generalization (March 2022)遵循了类似不同任务统一数据架构的概念。作者微调T5构建了T0,它是一种在多任务混合数据集上训练的编码器-解码器模型,能够泛化到更多任务。一个令人兴奋的亮点是,数据中表示的任务越多,模型达到的中位性能越高,同时不减少多样性。
- 这一概念后来扩展为Super-NaturalInstructions: Generalization via Declarative Instructions on 1600+ NLP Tasks (October 2022),这是一个包含超过1600个任务和500万个示例的新数据集。这些项目的区别在于数据集的生成方式。T0是基于已有的任务实例构建指令,而自然语言指令则是由NLP研究人员制定指令,众包工作者构建数据集实例。
另一种方法是使用大型语言模型生成输出:Unnatural Instructions (December 2022)是一个基于种子示例自动生成的示例数据集,并要求生成第四个示例。通过让模型对每条指令重新措辞来扩充数据集。Self-Instruct (May 2023)引导语言模型自身生成。其想法是让模型生成指令,然后根据指令生成输入,最后生成输出。(实际中要更微妙。作者提供了8条随机采样指令,要求模型生成更多的任务指令,还删除了相同和近似指令。)合成生成的数据集往往包含更多噪声,可能导致模型比量更少但更精心整理的人类生成数据训练的模型健壮性要差。LIMA (May 2023)是一个更小的英语指令数据集,虽然只有一千个实例,但作者能够微调一个稳健的LLaMA模型。这得益于一个强大的预训练模型和非常仔细的训练数据策划。
这些只是指令微调模型大爆发的冰山一角。Flan-T5是使用FLAN数据集微调的T5模型。Alpaca是一个在InstructGPT生成的指令数据集上微调的LLaMA。WizardLM是一个在Evol-Instruct数据集上微调的LLaMA指令模型。ChatGLM2是一个在英语和中文指令上训练的双语模型。我们不断看到将强大的基础模型与多样化的指令数据集(可以是人类或模型生成的)相结合的模式。
Learning to Generate Task-Specific Adapters from Task Description (June 2021)是另一种提高泛化能力的方法。作者生成任务特定的参数,称为适配器。虽然适配器已经存在多年,但它们的采用最近在自然语言和图像生成中变得广泛。对于有数十亿参数的语言模型,许多人希望微调其领域或任务。下一节将讨论适配器。
回顾本节,指令微调的两个主要组件是强大的基础模型和高质量的指令数据集。指令数据集的质量对于模型至关重要。这些数据集可以是合成生成的(例如,使用自指令),手动生成的,或者两者的结合。研究一再表明,训练数据中表示的任务越多,模型越好。最后,指令模板可能会极大地影响最终性能。现有的数据集在任务的数量和多样性之间进行权衡。
适配器简介
下面深入探讨第四种方法:适配器。到目前为止,我们已经探讨了用于文本分类的DistilBERT微调和生成特定风格文本的GPT-2。这两者在微调过程中修改了模型的所有权重。微调比预训练更有效,因为我们不需要太多的数据或计算能力。然而,随着更大模型的趋势不断增长,在消费者硬件上进行传统微调变得不可行。此外,如果我们想为不同任务微调一个编码器模型,最终会有多个模型。
欢迎使用PEFT!参数高效微调(PEFT)是一组技术,使得在不微调所有模型参数的情况下调整预训练模型成为可能。通常,我们添加少量额外参数,称为适配器,然后微调它们,同时冻结原始预训练模型。这样有什么效果?
- 更快的训练和更低的硬件需求:在进行传统微调时,我们更新许多参数。使用PEFT,只更新适配器,与基础模型相比,其参数数量很少。因此,训练完成得更快,并且可以使用较小的GPU。
- 更低的存储成本:在微调模型后,我们只需要存储适配器,而不是每次微调都存储整个模型。当某些模型需要超过100GB的存储时,如果每个下游模型都需要再次保存所有参数,将无法很好地扩展。适配器可能是原始模型大小的1%。如果我们有100个微调的100GB模型,传统微调需要10,000GB的存储,而PEFT只需要200GB(原始模型和100个1GB的适配器)。
- 相当的性能:PEFT模型的性能通常与完全微调的模型旗鼓相当。
- 无延迟:很快就会看到,训练后,适配器可以合并到预训练模型中,这意味着最终的大小和推理延迟将是相同的。
听起来好得令人难以置信。如何实现的呢?有多种PEFT方法。最流行的包括前缀调优、提示词调优和低秩适配(LoRA),在本章中重点介绍LoRA。LoRA使用低秩分解将权重更新表示为两个较小的矩阵,称为更新矩阵。虽然这可以应用于transformer模型的所有块,我们通常只将它们应用于注意力块。
PEFT是一个使用transformers和diffusers实现这些技术的简单库。首先,来看如何为前一节的GPT-2模型构建一个适配器。第一步是创建一个PEFT方法的配置。对于LoRA,我们可以控制秩r
,它控制更新矩阵的大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
from peft import LoraConfig, get_peft_model peft_config = LoraConfig( r=8, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM", fan_in_fan_out=True, ) model = AutoModelForCausalLM.from_pretrained("gpt2") peft_model = get_peft_model(model, peft_config) peft_model.print_trainable_parameters() |
1 |
trainable params: 294,912 || all params: 124,734,720 || trainable%: 0.2364 |
初始模型有接近1.25亿个参数,但其中只有约29.5万个会被训练。仅占原始模型大小的0.24%。PEFT的理念是我们可以引入这个模型,并获得与原模型相当的性能,而它的大小只有原模型的1/400。
底层原理是什么呢?在微调一个基础模型时,我们是在更新各层。计算更新矩阵可能会占用大量内存,因此LoRA尝试用更小的矩阵来更新矩阵达到近似效果。例如,假设有一个包含10,000行和20,000列的更新矩阵。这意味着更新矩阵有2亿个值。使用LoRA,我们用两个较小的秩为r
的矩阵来表示更新矩阵。假设秩为8,第一个矩阵A有10,000行和8列,而矩阵B有8行和20,000列(确保相同的输入和输出大小)。A有80,000个值,B有160,000个值。我们从2亿个值减少到240,000个值。这小了800倍!LoRA假设这些矩阵可以让权重更新矩阵近似度很高。(本例灵感来自Sebastian的优秀文章,)
我们谈到了r
参数。如前所述,它控制LoRA矩阵的维度,这会产生能力与过拟合之间的权衡。秩太高会导致适配器过于复杂而产生过拟合。秩太低则会导致性能不足。第二个关键参数是alpha
,它控制适配器对原始模型的影响程度。高alpha
会赋予适配器更大的权重。选择r
和alpha
的值取决于问题和模型。对于大语言模型,可以先让秩为8,且alpha始终是秩的两倍。
微调后,我们可以将LoRA权重合并回原始模型。这意味着运行推理所需的计算量有或没有LoRA是完全相同的。
$$scaling = {\alpha \over {r}}$$
$$weight = weight + scaling \times (B \times A )$$
图5-1. LoRA图
LoRA(低秩适配器)如此小巧的好处在于它们变得非常便携且适合于生产环境。想象一下一个使用场景,用户期望聊天机器人或图像生成器能够生成10种不同风格,而这些风格对初始模型是未知的。我们可以根据需要加载和卸载适配器,而不是微调初始模型十次并临时加载模型。最新的一些技术,例如LoRAX,可以在单GPU上服务于超过一百个微调的适配器。
还有一些使用场景可能需要合并多个适配器。就像更新单个适配器一样,我们可以持续更新多个适配器。
$$weight += scaling_1 \times (B_1 \times A_1 )$$
$$weight += scaling_2 \times (B_2 \times A_2 )$$
$$weight += scaling_3 \times (B_3 \times A_3 )$$
最后一个问题是应该更新LoRA的哪些参数。在更多模块中使用LoRA通常会带来稍好的性能,但也需要更多的内存,这可能是值得的事情,可以通过target_modules
参数来完成。我们可以在注意力模块中使用LoRA进行快速实验,这通常是PEFT库的默认设置(默认目标模型取决于模型架构)。也可以使用target_modules=all-linear
选择所有线性模块,排除输出模块。
虽然在本章中我们主要关注文本生成的微调,但PEFT在其他领域也被广泛使用,例如图像生成(我们将在第7章中探讨)、图像分割等。
量化简介
PEFT能以更少的计算和磁盘空间来微调模型。然而,推理期间模型的大小并没有减少。如果在推理一个有300亿参数的模型,仍然需要一个强大的GPU来运行它。例如,一个176B参数的模型如Bloom需要8个A100 GPU,这些GPU非常强大且昂贵(每个成本超过1.5万美元)。在本节中,我们将讨论一些技术,这些技术可以让我们在不降低性能的情况下使用更小的GPU来运行模型。
假设有一个70亿参数的模型。每个参数都有一个数据类型或精度。例如,float32
(FP32
,也称为全精度)类型以32位存储一个浮点数。70亿参数是2240亿位(70亿 * 32位),相当于28GB(2240亿 bit = 280亿字节 = 26吉字节)。FP32
允许以高精度表示广泛的数字,这对于预训练模型非常重要。
然而,在许多情况下不需要这样大的范围。此时,我们可以使用float16
(或FP16
,也称为半精度)。FP16
的精度和数字范围较低(最大可能的数字是64,000),这带来了新的风险:模型可能会溢出(如数字不在可表示的范围内)。
第三种数据类型是脑浮点(Brain Floating-Point),或bfloat16
。BF16
和FP16
一样使用16位,但以不同的方式分配这些位,以便为较小的数字(如神经网络权重中通常看到的那些)获得更高的精度,同时仍覆盖与FP32
相同的总区间。
使用全精度进行训练和推理通常会带来最佳结果,但速度显著较慢。对于训练,人们已经找到了进行混合精度训练的方法,这提供了显著的加速。在混合精度训练中,权重以全精度作为参考,但以半精度进行运算。使用半精度来更新全精度权重。精度对推理没有显著影响,因此我们可以使用半精度加载模型。PyTorch默认情况下以全精度加载所有模型,因此如果我们想使用float16
或bfloat16
,需要在加载模型时指定类型,传递bfloat16
参数。
1 |
model = AutoModelForCausalLM.from_pretrained("gpt2", torch_dtype=torch.float16) |
加载7B模型时,如果每个参数使用16位而不是32位,则需要14GB的GPU内存,这对于某些消费级GPU完全没问题。像Llama、Mistral和Zephyr这样的7B模型已经成为消费级GPU的流行解决方案,但还有带更多参数的优秀模型。例如,如果我们想在半精度下使用一个34B的模型,则需要68GB的GPU,这远远超出了任何消费级GPU的范围。我们有什么办法可以使用这些模型吗?
直观上,我们可以认为直接减少数字的范围或精度以达到四分之一精度(每个参数使用一个字节,即8位)。不幸的是,这样做会导致显著的性能下降。我们可以通过8位量化实现四分之一精度。8位量化技术的核心思想是将一种类型的值(如fp16
)映射到int8
,这样可以表示[-127, 127]或[0, 256]范围内的值。
有不同的8位量化技术。我们来探讨一下绝对值最大量化(absmax quantization)。给定一个向量,我们首先计算其最大绝对值。然后将127(最大可能值)除以这个最大值。这会产生一个量化因子——在我们用向量乘以这个因子时,可以保证最大的值为127。可以对数组进行反量化以恢复原始数值,但精度会丢失。通过运行代码可以更好地理解这一点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import numpy as np def scaling_factor(vector): m = np.max(np.abs(vector)) return 127 / m array = [1.2, -0.5, -4.3, 1.2, -3.1, 0.8, 2.4, 5.4] alpha = scaling_factor(array) quantized_array = np.round(alpha * np.array(array)).astype(np.int8) dequantized_array = quantized_array / alpha print(f"Scaling factor: {alpha}") print(f"Quantized array: {quantized_array}") print(f"Dequantized array: {dequantized_array}") print(f"Difference: {array - dequantized_array}") |
1 2 3 4 5 6 |
Scaling factor: 23.518518518518515 Quantized array: [ 28 -12 -101 28 -73 19 56 127] Dequantized array: [ 1.19055118 -0.51023622 -4.29448819 1.19055118 -3.10393701 0.80787402 2.38110236 5.4 ] Difference: [ 0.00944882 0.01023622 -0.00551181 0.00944882 0.00393701 -0.00787402 0.01889764 0. ] |
这些差异会导致性能退步。因此,传统的量化技术在处理数十亿参数的模型时未能成功。LLM.int8()是一种允许我们进行8位量化而不退化的技术。该技术的核心思想是提取异常值,即超出某些范围的值,并在FP16
中计算这些异常值的矩阵乘法,而其余部分使用int8
。这种混合精度结构允许我们在8位下处理99.9%的值,并在全精度或半精度下处理1%的值,从而不导致性能下降。那么有什么问题呢?LLM.int8()
的主要目标是减少运行模型推理所需的大型GPU资源。由于额外的转换开销,推理的速度会比使用fp16
慢(慢15-30%)。需要注意的是,虽然近年来的所有GPU都提供了int8
的张量核支持,但一些旧的GPU可能对此支持不佳。
低精度推理的边界正在被新的4位和2位量化技术推动。甚至还有使用小于1位量化的探索。实现无降级的量化是非常令人兴奋的研究领域,特别是模型越来越大。
在本节的开始,我们需要28GB的GPU来加载一个7B参数的模型。现在,我们可以在没有退化的情况下,用7GB的GPU加载相同的模型,代价是推理速度会稍有下降(但并不是太多)。以8位加载模型只需添加load_in_8bit
即可。
1 2 3 4 5 |
from transformers import AutoModelForCausalLM from transformers import BitsAndBytesConfig quantization_config = BitsAndBytesConfig(load_in_8bit=True) model = AutoModelForCausalLM.from_pretrained("gpt2", quantization_config=quantization_config) |
除了量化之外,我们还可以采取其他措施来处理非常大的模型。一种流行的推理技术叫做offloading。如果一个模型太大无法装入GPU,可以将其分成多个检查点分片,这些分片由transformers自动处理。这样做有什么好处?如果模型太大,我们可以只加载适当的层或分片,并将其他操作卸载到CPU RAM中,但这样速度要慢得多。这使我们能够处理任何大小的模型,但推理速度的降低使得其对许多大型模型不太可行。
小结
我们来回顾一下PEFT和量化。
- PEFT使我们通过添加适配器和冻结基础模型权重来使用更少的计算资源进行微调。这加速了训练,因为只有少量的权重是可更新的。
- 量化让我们使用比存储时更少的位来加载模型。这减少了加载和运行推理所需的GPU资源。
为什么不同时使用这二者呢?设想一下用8位训练一个模型。不幸的是,如前所述,在预训练或微调大模型时,高精度值是很重要的。另一方面,PEFT冻结了基础模型,只使用一个小的适配器,因此我们可以在这里使用较低的精度,同时达到相同的性能。
QLoRA允许我们用较小的GPU微调大模型。这种技术与LoRA非常相似,但结合了量化。首先,基础模型被量化为4位并冻结。然后,添加LoRA适配器(两个矩阵)并保持在bfloat16
。当进行微调时,QLoRA使用4位存储的基础模型和16位半精度模型进行计算。
再次加载4位模型只需要传递load_in_4bit
参数和device_map="auto"
。我们用Mistral 7B来试试这个方法。
注:我们在本节使用的是默认值,但transformers允许通过使用
BitsAndBytesConfig
对量化技术进行更细粒度的控制,这使得改变计算类型、嵌套量化等成为可能。
1 2 3 4 5 |
model = AutoModelForCausalLM.from_pretrained( "mistralai/Mistral-7B-v0.1", load_in_4bit=True, device_map="auto", ) |
QLoRA只是我们工具箱中的一个工具,但不是银弹。它显著减少了GPU需求,同时保持了相同的性能,但也增加了训练模型所需的时间。PEFT部分的所有优点在这里同样适用,使得QLoRA在社区中成为快速微调7B模型的一种流行技术。
我们来做一次快速的QLoRA微调,让生成式模型以特定风格生成文本。以下是各个组件的介绍:
- 模型:我们将使用Mistral模型。Mistral是一个非常高质量的7B模型。我们使用
load_in_4bit
和device_map="auto"
来加载模型并进行4位量化。 - 数据集:我们将使用Guanaco数据集,该数据集包含人类与OpenAssistant模型之间的对话并经过人工打分。
- PEFT配置:我们将指定一个具有较好初始默认值的
LoraConfig
:秩 (r
)为8,alpha
为其两倍。 - 训练参数:和之前一样,我们可以配置训练参数(如评估频率和训练轮次)以及模型的超参数(学习率、权重衰减或训练轮次)。
最后一个组件是Trainer
。在前面的部分中,我们使用了transformers的Trainer
,这是一个通用工具,可以封装PyTorch训练代码并添加方便的实用工具来共享模型。当微调一个用于自回归技术的大型语言模型(LLM)时,trl库的SFTTrainer
类是一个不错的工具。它是Trainer
的一个封装,优化了文本生成。它包含有用的LoRA和量化技术以及简单的数据集加载和处理工具。和之前一样,我们可以传递模型(现在已量化)和数据集(我们将使用300个样本进行快速训练)。SFTTrainer
已经带有默认的整合器和数据集工具,因此不需要对数据进行分词和预处理。唯一需要指定的是哪个字段包含训练文本(使用dataset_text_field
)。最后,我们传递一个PEFT配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
from trl import SFTTrainer from datasets import load_dataset from peft import LoraConfig from transformers import TrainingArguments dataset = load_dataset("timdettmers/openassistant-guanaco", split="train") peft_config = LoraConfig( r=8, lora_alpha=16, lora_dropout=0.05, task_type="CAUSAL_LM", ) training_args = TrainingArguments( "sft_cml5", push_to_hub=True, per_device_train_batch_size=8, weight_decay=0.1, lr_scheduler_type="cosine", learning_rate=5e-4, num_train_epochs=2, evaluation_strategy="steps", eval_steps=200, logging_steps=200, gradient_checkpointing=True, ) trainer = SFTTrainer( model, args=training_args, train_dataset=dataset.select(range(300)), dataset_text_field="text", peft_config=peft_config, max_seq_length=512, ) trainer.train() |
1 |
trainer.push_to_hub() |
以上代码可能需要一个小时或更长时间来运行。别忘了,QLoRA还会导致训练速度变慢。当推送模型时,我们只推送了适配器。来看看如何使用模型和适配器进行推理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
from peft import PeftModel tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1") model = AutoModelForCausalLM.from_pretrained( "mistralai/Mistral-7B-v0.1", torch_dtype=torch.float16, device_map="auto", ) model = PeftModel.from_pretrained( model, "AlanHou/sft_cml5", torch_dtype=torch.float16, ) model = model.merge_and_unload() # This is the main difference pipe = pipeline("text-generation", model=model, tokenizer=tokenizer) pipe("### Human: Hello!###Assistant:", max_new_tokens=100) |
以上代码会输出类似下面的内容:
1 2 3 4 5 6 7 8 9 10 11 |
### Human: Hello ###Assistant: Hello! ### Human: How are you? n###Assistant: I'm doing well, how are you? ### Human: I'm good, thanks! ###Assistant: That's great to hear! ### Human: What's the weather like today? ###Assistant: It's currently 72 degrees and sunny in San Francisco. |
真棒!我们刚刚在无需大规模GPU的情况下将一个7B模型微调为对话模型。
项目实操:检索增强生成 (RAG)
大型语言模型(LLMs)只包含用于训练数据或传递给它们的上下文数据。如果想问LLM关于特定主题的信息,只有在其数据中包含该答案时才知道。RAG是一种技术,模型可以访问存储的文档,因此LLM的回答会结合用户输入和这些文档。可惜文档数量可能有数百万,因此不能将它们全部传递给模型。为解决这个问题,我们使用嵌入模型(例如第1章的句子变换器)将文档编码为向量,并将这些向量存储起来(通常称为向量数据库)。
然后我们使用最近邻搜索找到与用户输入最相似的文档。最后,我们将用户输入和检索到的文档传递给LLM。这种方法非常强大,因为它允许模型访问大量信息,并且比重新训练模型更容易更新信息。
我们的目标是构建一个管道,其中:
- 用户输入一个问题
- 管道检索与问题最相似的文档
- 管道将问题和检索到的文档传递给LLM
- 管道生成回答
我们无需为此任务训练任何模型。对于检索,建议使用sentence_transformers预训练模型。对于生成,可以使用你喜欢的模型,例如我们在前一节中训练的模型。
总结
本章探讨了微调大型语言模型的不同技术。我们首先通过传统微调对编码器模型进行文本分类。不过,同样的方法也可用于其他任务,例如从给定文本中回答问题及识别文本中的实体。然后我们探讨了如何微调解码器模型以生成文本。我们还讨论了微调与零样本或少样本生成的优缺点。并且还了解了指令微调如何使生成式模型开箱即用地处理众多任务。
尽管这些技术非常强大,但扩展到越来越大的最新模型是具有挑战性的。因此,我们探讨了如何使用量化技术在较小的GPU上运行大模型的推理。我们还讨论了PEFT技术,以减少计算和磁盘空间需求来微调模型。随后,结合这两种技术微调了一个7B模型,使其成为对话模型。
现在,读者已经掌握了为自有任务微调大型模型的基础知识。新的量化和PEFT技术不断被开发,但基础和目标保持不变。鼓励读者尝试不同的模型和数据集,看看它们的表现。
练习
- 基础模型和微调模型有什么区别?对话模型属于哪种类型?
- 在什么情况下你会选择基础编码器模型进行微调?
- 解释微调、指令微调和QLoRA之间的区别。
- 使用适配器会导致模型体积变大吗?
- 加载一个70B模型在半精度、8-bit量化和4-bit量化下需要多少GPU内存?
- 为什么QLoRA会导致训练速度变慢?
- 在微调过程中,我们在什么情况下会冻结模型权重?
挑战 - 问答任务。我们解决一个问答任务。目标是接收问题和上下文作为输入,模型输出答案在上下文中的位置。例如,给定问题
What’s the weather like today?
和上下文It’s currently 72 degrees and sunny in San Francisco.
“, 模型应该输出72 degrees and sunny
。SQuAD是一个适合该任务的数据集。如何使用编码器来实现呢?