第二章中,我们探讨了语言模型的原理以及如何使用它们完成不同的任务,例如文本生成和序列分类。可以看到,通过适当的提示词和这些模型的零样本能力,语言模型在许多任务中无需进一步训练就能表现出色。我们还讲到了一些由社区提供的成千上万的预训练模型。在本章中,我们将学习如何通过在自有数据上进行微调来提升语言模型在特定任务上的表现。

尽管预训练模型展示了非凡的能力,但其通用目的的训练可能并不适合某些任务或领域。微调是为了使模型能够理解数据集或任务的细微差别而量身定制其理解能力。例如,在医疗研究领域,通用网络文本所预训练的语言模型并不能很好地执行任务,因此我们可以在医学文献的数据集上对其进行微调,以增强其生成相关医学文本或从医疗文件中提取信息的能力。另一个例子是制作对话模型。正如我们在第二章中学习到的,大型预训练模型通常是为了预测下一个词元而训练的,这通常不能直接适配对话。我们可以在包含日常对话和非正式语言结构的数据集上对这个模型进行微调,使其能够生成有趣的对话文本,就像在ChatGPT等界面中那样。

本章的目标是建立扎实的LLM微调基础,因此将涵盖以下内容:

  • 使用微调的编码器模型对文本进行分类
  • 使用解码器模型生成特定风格的文本
  • 通过指令微调使用单个模型解决多项任务
  • 参数高效微调技术,让我们能够使用更小的GPU训练模型
  • 使我们能够用更少的计算资源运行模型推理的技术

本文相关代码请见GitHub仓库

文本分类

在进入生成模型的领域之前,先来了解微调预训练模型通常的流程。为此,我们先从序列分类开始,在这个过程中,模型会为给定输入分配一个类别。序列分类是经典的机器学习问题之一。通过它,可以处理垃圾邮件检测、情感识别、意图分类和虚假内容检测等许多挑战。

我们将微调一个模型来对简短新闻文章摘要分类。读者很快就会知道,微调所需的计算资源和数据远远少于从头训练一个模型。通常的流程是:

  1. 确定任务的数据集
  2. 定义所需的模型类型(编码器、解码器或编码器-解码器)
  3. 找到满足需求的基础模型
  4. 预处理数据集
  5. 定义评估指标
  6. 训练及分享

1. 确定数据集

根据你的任务和用例,可使用公共或私有数据集(例如,贵司的数据集)。虽然预训练模型不需要标签数据,但我们会使用带标签的数据集进行序列分类。目标是将一个通用文本续写语言模型适配为一个文本分类器,需要告诉它检测的类别。查找公共数据集较好的地方有Hugging Face数据集KaggleZenodoGoogle数据集搜索。面对成千上万的数据集,我们需要辅助方式来找到适合自己用例的数据集。一种方法是过滤Hugging Face上的文本分类数据集

在下载量最多的数据集中,有一个著名的非商业数据集AG新闻数据集,它被用于基准测试文本分类模型以及研究数据挖掘、信息检索和数据流。

注:有时,我们可能希望与社区共享数据集。此时可以将其上传为数据集仓库。datasets库对常见数据类型(音频、图像、文本、csv、json、pandas等)提供了开箱即用的支持。对于自定义加载逻辑,还可以实现一个加载脚本,指定如何加载和拆分数据。

首先想到的应该是查看数据集。如下所示,数据集包含两列:一列是文本,另一列是标签。它提供了120,000个训练样本,足以微调一个模型。与预训练模型相比,微调所需的数据非常少,仅使用几千个示例就应该足够得到一个良好的基线模型。

from datasets import load_dataset

raw_datasets = load_dataset("ag_news")
raw_datasets
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 120000
    })
    test: Dataset({
        features: ['text', 'label'],
        num_rows: 7600
    })
})

来看一条具体的实例:

raw_train_dataset = raw_datasets["train"]
raw_train_dataset[0]
{'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}

第一个样本包含文本和标签,标签是……22指的是哪个类别?可以查看数据集的label字段来确定:

print(raw_train_dataset.features)
{'text': Value(dtype='string', id=None), 'label': ClassLabel(names=['World', 'Sports', 'Business', 'Sci/Tech'], id=None)}

搞定!那么,标签0表示世界新闻,1表体育,2表商业,3表示科技术。弄清楚这一点后,我们再决定使用哪种模型。

2. 定义使用哪种模型类型

我们回顾一下第二章。根据要解决的任务类型,可以使用三种transformer模型:

  • 编码器模型:擅长从序列中获取丰富的表现。它们输出包含关于输入的语义信息的嵌入。我们可以在这些嵌入之上添加一个小型网络,并对其进行训练以完成依赖语义信息的新特定任务(例如识别文本中的实体或分类序列)。
  • 解码器模型:非常适合生成新文本。
  • 编码器-解码器模型:非常适合需要基于给定输入生成新句子的任务。

现在,考虑是到对简短新闻文章摘要进行分类的任务,有三种可能的方法:

  1. 零样本或少样本学习:我们可以使用一个高质量的预训练模型,说明任务(例如“分类成这四个类别”),然后让模型完成其余工作。这种方法不需要任何微调。
  2. 文本生成模型:微调一个文本生成模型,使其在给定输入新闻文章时生成标签(例如“business”)。这种方法类似于T5模型的做法——它通过将许多不同任务形式化为文本生成问题来解决,最终形成一个可以解决多种任务的模型。
  3. 带分类头的编码器模型:通过在嵌入上添加一个简单的分类网络(称为头)来微调编码器模型。这种方法提供了一个对我们的用例量身定制的专业高效模型,是进行分类任务的理想选择。

3. 选择一个不错的基础模型

我们的模型需要:

  • 基于编码器架构。
  • 足够小的,以便可以在几分钟内完成微调。
  • 预训练效果优秀。
  • 能处理少量词token。

虽然BERT已经相对较旧,但它仍是一个很好的基础编码器架构,非常适合微调。考虑到我们希望快速训练模型并且计算资源有限,可以使用DistilBERT,它比BERT小40%,速度快60%,同时保留了97%的BERT能力。给定这个基础模型,我们可以对其进行微调,以完成多个下游任务,例如回答问题或分类文本。

4. 预处理数据集

第二章中讲到,每个语言模型都有其特定的分词器。要微调DistilBERT,我们必须确保整个数据集使用与预训练模型相同的分词器进行分词。可以使用AutoTokenizer加载相应的分词器,然后定义一个函数来对一批样本分词。transformer期望批次中的所有输入长度相同:通过添加padding=True,我们在样本中添加零,使其具有与最长输入样本相同的大小。

需要注意的是,transformer模型有一个最大上下文大小——语言模型在进行预测时可以使用的最大词元数。对于DistilBERT,这个限制是512个token,所以不要用它处理整本书。所幸我们的大多数样本都是简短的摘要。尽管大多数示例很短,但有些可能包含较长的文本。我们可以使用truncation=True将所有样本截断到模型的上下文长度,以防止出现问题。来看对两个示例的具体使用:

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])
{'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方法对整个数据集进行分词。

tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
tokenized_datasets
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()方法,用于根据标签和模型预测获得指标。

import evaluate

accuracy = evaluate.load("accuracy")
print(accuracy.description)
print(accuracy.compute(references=[0, 1, 0, 1], predictions=[1, 0, 0, 1]))
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分数的字典。在训练过程中评估模型时,将自动使用此函数来监控其进展。

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,还需要指定标签的数量(因为这会改变最后一层的神经元数量)。

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)
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."""
)
('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类是一个健壮且灵活的工具。(有几十个参数可供修改。推荐查看文档了解的所有的选项。)

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万个样本,但请随意调整这个数字——更多的数据应该会带来更好的结果。请注意,我们仍将使用整个测试集进行评估。

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,可以开始训练了。

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

这只花了较少的时间。最终的评估准确率和F1分数接近92%,还不错,特别是考虑到我们只使用了不到10%的数据。评估损失在各个epoch之间减少,这正是我们的目标。如果你想共享最终模型以供他人访问,需要在最后调用push_to_hub

trainer.push_to_hub()

虽然使用Trainer可能看起来像一个黑盒子,但底层它和我们在扩散章节中训练简单扩散模型时进行常规的PyTorch训练循环一样。从头编写这样的循环大致如下:

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,其他人就可以使用AutoModelpipeline()来访问它。示例如下。

# 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."""
)
[{'label': 'LABEL_1', 'score': 0.9112359881401062}]

让我们深入探讨一下指标。可以使用Trainer.predict方法获取测试数据集中所有样本的预测结果。输出是一个PredictionOutput对象,包含预测结果、标签ID和指标。通过查看前三个样本文本及其对应的预测和参考标签来确认没有疏漏。在网络上运行部分样本很重要,可确保一切正常工作。

tokenized_datasets["test"].select([0, 1, 2])
Dataset({
    features: ['text', 'label', 'input_ids', 'attention_mask'],
    num_rows: 3
})
# 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
0%|          | 0/238 [00:00<?, ?it/s]
# 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)
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函数。

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()
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()

生成式AI第五章 大语言模型微调 Part 1_人工智能