【转载】大语言模型微调

原文地址:
Finetuning Large Language Models,by Sebastian Raschka, on 2023-04-22

大语言模型微调

核心思想与方法入门

在人工智能这个飞速发展的领域中,高效、有效地运用大语言模型(LLM)已变得愈发重要。但大语言模型的使用方式多种多样,如果你是刚入门的学习者,难免会感到无从下手。
本质上,我们将预训练大语言模型应用于新任务主要有两种方式:上下文学习(in-context learning)微调(finetuning)
本文将先简要介绍上下文学习的含义,再详细讲解微调大语言模型的各类方法。

上下文学习与索引

自GPT-2(https://d4mucfpksywv.cloudfront.net/better-language-models/language_models_are_unsupervised_multitask_learners.pdf)与GPT-3(https://arxiv.org/abs/2005.14165)问世以来,我们已经证实:在通用文本语料上预训练的生成式大语言模型具备上下文学习能力。也就是说,如果要执行模型未经过专门训练的特定任务或新任务,我们无需对预训练大语言模型进行额外训练或微调,只需通过输入提示词直接提供几个目标任务的示例即可,如下图所示。
figure01
图:上下文学习示例

如果无法直接访问模型(比如通过API调用模型时),上下文学习是非常实用的方法。
与上下文学习相关的概念是硬提示调优(hard prompt tuning),即通过修改输入内容来优化输出效果,如下图所示。
figure02
图:(硬)提示调优示意图

顺便一提,之所以叫“硬”提示调优,是因为我们直接修改输入的文本或词元(token)。后文我们还会介绍一种可微分的版本,叫做软提示调优(soft prompt tuning)(也常简称为提示调优)。
相较于参数微调,上述提示调优方法的资源消耗更低,但效果通常不及微调——因为它没有针对特定任务更新模型参数,可能难以适配任务的细节特性。此外,提示调优往往需要人工反复对比不同提示的效果,人力成本较高。

在深入讨论微调之前,再介绍一种纯上下文学习思路的衍生方法:索引(indexing)。在大语言模型领域,索引可以看作是上下文学习的一种变通方案,它能将大语言模型改造为信息检索系统,从外部资源和网站中提取数据。其流程是:索引模块将文档或网站拆分为更小的片段,将其转换为向量并存入向量数据库;当用户提交查询时,索引模块计算查询的嵌入向量与数据库中每个向量的相似度,最终取回相似度最高的前k个嵌入结果来生成回答。
figure03
图:索引方法示意图

三种传统的基于特征的方法与微调方法

当无法直接访问大语言模型时(比如通过API或用户界面交互时),上下文学习是一种实用且易用的方法。
但如果我们有权限直接操作模型,使用目标领域的数据针对特定任务对模型进行适配和微调,通常能得到更优的效果。那么,如何让模型适配目标任务?下图概括了三种传统方法。
figure04
图:三种传统的基于特征的方法与微调方法

为了让下文的讨论更具实操参考性,我们以BERT(https://arxiv.org/abs/1810.04805<)这类编码器架构的大语言模型为例,针对分类任务进行微调(为简化起见,这里的分类任务是判断电影评论的情感倾向是正面还是负面)。需要说明的是,这些方法不仅适用于编码器架构的大语言模型,同样也适用于GPT这类解码器架构的大语言模型,后续文章会给出对应的示例。此外,我们也可以对解码器架构的大语言模型进行微调,让它针对特定指令生成多句回答,而不只是做文本分类,相关实操示例也会在后续文章中提供。/p>

1)基于特征的方法

在基于特征的方法中,我们加载预训练大语言模型,将其作用于目标数据集。核心是生成训练集的输出嵌入向量,用这些嵌入作为输入特征来训练分类模型。这种方法在BERT这类侧重嵌入的模型中尤为常用,不过我们也可以从GPT类生成式模型中提取嵌入向量。
分类模型可以选用逻辑回归、随机森林、XGBoost等任意模型(不过根据经验,逻辑回归这类线性分类器在这里效果最好)。
从原理上,我们可以用如下代码来表示基于特征的方法:

model = AutoModel.from_pretrained("distilbert-base-uncased")
# ...
# tokenize dataset
# ...
# generate embeddings
@torch.inference_mode()
def get_output_embeddings(batch):
    output = model(
        batch["input_ids"],
        attention_mask=batch["attention_mask"]
    ).last_hidden_state[:, 0]
return {"features": output}

dataset_features = dataset_tokenized.map(
    get_output_embeddings, batched=True, batch_size=10)

X_train = np.array(imdb_features["train"]["features"])
y_train = np.array(imdb_features["train"]["label"])

X_val = np.array(imdb_features["validation"]["features"])
y_val = np.array(imdb_features["validation"]["label"])

X_test = np.array(imdb_features["test"]["features"])
y_test = np.array(imdb_features["test"]["label"])

# train classifier
from sklearn.linear_model import LogisticRegression

clf = LogisticRegression()
clf.fit(X_train, y_train)

print("Training accuracy", clf.score(X_train, y_train))
print("Validation accuracy", clf.score(X_val, y_val))
print("test accuracy", clf.score(X_test, y_test))

(感兴趣的读者可以在以下链接找到完整代码示例:https://github.com/rasbt/LLM-finetuning-scripts/tree/main/conventional/distilbert-movie-review

2)微调I —— 更新输出层

与上述基于特征的方法相关的一种常用方法是微调输出层(我们称之为“微调I”)。和基于特征的方法类似,我们冻结预训练大语言模型的所有参数,只训练新增的输出层——这相当于在嵌入特征上训练一个逻辑回归分类器,或是一个小型多层感知机。
对应的代码如下:

model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
     num_labels=2
)

# freeze all layers
for param in model.parameters():
    param.requires_grad = False

# then unfreeze the two last layers (output layers)
for param in model.pre_classifier.parameters():
    param.requires_grad = True

for param in model.classifier.parameters():
    param.requires_grad = True

# finetune model
lightning_model = CustomLightningModule(model)
trainer = L.Trainer(
    max_epochs=3,
    ...
)
trainer.fit(
    model=lightning_model,
    train_dataloaders=train_loader,
    val_dataloaders=val_loader)

# evaluate model
trainer.test(lightning_model, dataloaders=test_loader)

(感兴趣的读者可以在以下链接找到完整代码示例:https://github.com/rasbt/LLM-finetuning-scripts/tree/main/conventional/distilbert-movie-review

从理论上讲,由于使用的是同一个冻结的主干模型,这种方法在模型效果和训练速度上应该和基于特征的方法相近。不过基于特征的方法可以更方便地预计算并存储训练集的嵌入特征,因此在某些实际场景下可能更易用。

3)微调II —— 更新所有层

原始BERT论文(https://arxiv.org/abs/1810.04805)中提到,仅微调输出层也能达到与微调全层相近的效果,但微调全层的计算成本要高得多,因为涉及的参数量更大。例如,BERT-base模型大约有1.1亿个参数,而用于二分类的输出层只有1500个参数;即使是最后两层,参数量也只有6万,仅占模型总参数量的0.6%左右。
最终效果取决于目标任务、目标领域与模型预训练数据集的相似程度。但在实际应用中,微调全层几乎总能带来更优的模型效果。
因此,如果追求最优的模型效果,使用预训练大语言模型的黄金标准就是更新所有层(即本文所说的“微调II”)。原理上,微调II和微调I非常相似,唯一的区别是:我们不会冻结预训练大语言模型的参数,而是对其也进行微调:

model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
     num_labels=2
)

# freeze layers (which we don't do here)
# for param in model.parameters():
#    param.requires_grad = False

# finetune model
lightning_model = LightningModel(model)
trainer = L.Trainer(
    max_epochs=3,
    ...
)
trainer.fit(
    model=lightning_model,
    train_dataloaders=train_loader,
    val_dataloaders=val_loader)

# evaluate model
trainer.test(lightning_model, dataloaders=test_loader)

(感兴趣的读者可以在以下链接找到完整代码示例:https://github.com/rasbt/LLM-finetuning-scripts/tree/main/conventional/distilbert-movie-review

如果你想了解实际效果,上述代码片段基于预训练的DistilBERT-base模型训练了一个电影评论分类器(代码笔记本可在此处获取:https://github.com/rasbt/LLM-finetuning-scripts/tree/main/conventional/distilbert-movie-review),结果如下:

  1. 基于特征的方法+逻辑回归:测试准确率83%
  2. 微调I(更新最后2层):准确率87%
  3. 微调II(更新所有层):准确率92%

这一结果符合通用经验法则:微调的层数越多,模型效果通常越好,但计算成本也越高。
figure05
图:不同方法的计算成本与模型效果权衡经验参考

上述场景展示了微调最具代表性的三种极端情况:只训练最后一层、训练部分层与训练全层。当然,具体效果会因模型和数据集而异,尝试中间的不同层数配置也可能有收获。比如,有时只训练一半的层数就能达到和全量微调相近的效果(下一节会详细介绍参数高效微调)。感兴趣的读者可以参考下图:图中展示了DistilBERT模型在斯坦福IMDB情感分析数据集(https://ai.stanford.edu/~amaas/data/sentiment/)的2万条训练样本上微调后的预测效果与训练耗时。
figure06
图:预训练DistilBERT模型在IMDB电影评论数据集上的微调效果。代码可在此处获取:https://github.com/rasbt/LLM-finetuning-scripts/tree/main/conventional/distilbert-movie-review/layerwise-experiment

从图中可以看出,只训练最后一层的速度最快,但模型效果最差;如预期所料,训练的层数越多,模型效果越好,但计算成本也随之上升。最值得注意的是,当训练到两个全连接输出层+最后两个Transformer块(从左数第三个块)时,预测性能就已经趋于饱和。因此在这个特定的模型+数据集组合下,训练更多层数反而会造成计算资源的浪费。

参数高效微调

参数高效微调(Parameter-Efficient Finetuning, PEFT)让我们可以复用预训练模型,同时将计算量与资源消耗降到最低。总的来说,参数高效微调的优势至少有以下5点:

  • 降低计算成本(所需GPU数量更少、GPU耗时更短)
  • 加快训练速度(训练完成更快)
  • 降低硬件要求(在小显存GPU上也能运行)
  • 提升模型效果(减少过拟合)
  • 节省存储空间(大部分权重可在不同任务间共享)

前几节我们了解到,微调的层数越多,效果通常越好。但上述实验都是基于规模相对较小的DistilBERT模型。如果我们要微调的是更大的模型——比如最新的生成式大语言模型,它们的体积刚好勉强能放进GPU显存,那该怎么办?当然,我们可以用前面提到的基于特征的方法或者微调I,但如果我们想要达到接近微调II的模型效果呢?
多年来,研究者们开发了多种技术(https://arxiv.org/abs/2303.15647),能在只训练少量参数的前提下,让大语言模型微调达到优秀的效果,这类方法统称为**参数高效微调技术(PEFT)**。
下图总结了几种最常用的参数高效微调技术。
figure07
图:主流参数高效微调技术一览

那么这些技术的原理是什么?简而言之,它们的核心都是引入少量额外参数进行微调(而不是像前面的微调II那样微调所有层)。从某种意义上说,只微调最后一层的“微调I”也可以算作一种参数高效微调技术。但前缀调优(prefix tuning)、适配器(adapters)、低秩适配(low-rank adaptation)这类可以“修改”多层的技术,能以很低的成本实现好得多的预测效果。
本文篇幅已经很长了,而且这些技术都非常值得深入讲解,后续我会单独撰文介绍。

基于人类反馈的强化学习

基于人类反馈的强化学习(Reinforcement Learning with Human Feedback, RLHF) 结合监督学习与强化学习来微调预训练模型——这一方法因初代ChatGPT而普及,其技术基础是InstructGPT(https://arxiv.org/abs/2203.02155)。
在RLHF流程中,我们通过让人类对模型的不同输出进行排序或打分来收集人类反馈,以此作为奖励信号。收集到的奖励标签会被用于训练奖励模型,再由奖励模型引导大语言模型适配人类的偏好。
奖励模型本身通过监督学习训练得到(通常以预训练大语言模型为基座)。之后,我们利用奖励模型来更新待适配人类偏好的预训练大语言模型——训练时会用到一种名为**近端策略优化(Proximal Policy Optimization, PPO)**的强化学习算法(https://arxiv.org/abs/1707.06347)。
figure08
图:InstructGPT论文中展示RLHF流程的截图

为什么不直接用人类反馈来训练预训练模型,而是要先训练奖励模型?这是因为如果把人类纳入学习循环会形成瓶颈——我们无法实时获取人类反馈。
如前所述,本文篇幅已较长,更详细的讲解留待后续文章。

结语

对预训练大语言模型进行全层微调,仍然是适配新任务的黄金标准。但对于预训练Transformer模型,我们还有多种高效的替代方案。基于特征的方法、上下文学习、参数高效微调技术等,都能让大语言模型有效应用于新任务,同时将计算成本与资源消耗降到最低。
此外,基于人类反馈的强化学习(RLHF)可作为监督微调的替代方案,有进一步提升模型性能的潜力。

本杂志是我的个人兴趣项目。如果您希望在订阅(https://magazine.sebastianraschka.com/subscribe)之外支持我的创作,欢迎购买我的书籍(https://sebastianraschka.com/books)。如果您觉得内容有启发、有帮助,也请推荐给您的朋友和同事。
figure09
相关书籍链接:https://www.amazon.com/Machine-Learning-PyTorch-Scikit-Learn-scikit-learn-ebook-dp-B09NW48MR1/dp/B09NW48MR1/https://nostarch.com/machine-learning-and-ai-beyond-basicshttp://mng.bz/M96o
您的支持对我意义重大!非常感谢!

Leave a Reply

Your email address will not be published. Required fields are marked *

*