图片由作者使用DALL-E 3生成
最近发布的 Llama 3.1 提供了性能极高的模型,缩小了闭源模型和开源模型之间的差距。与其使用像 GPT-4o 和 Claude 3.5 这样的冻结版通用大语言模型,你可以针对特定应用场景对 Llama 3.1 进行微调,以实现更好的性能和更低的成本定制化。
图片由作者提供
在本文中,我们将全面介绍有监督的微调。我们将它与提示工程进行比较,以理解何时使用它合适,并详细说明主要技术及其优缺点。我们还将介绍一些主要概念,例如LoRA超参数、存储格式和聊天模板。最后,我们将通过使用Unsloth在Google Colab中对Llama 3.1 8B进行最先进的优化微调来实践这一过程。
本文中使用的所有代码都可以在 Google Colab 和 LLM 课程 中找到。
图片由作者提供
监督微调(SFT)是一种改进和定制预训练的大型语言模型的方法。它涉及在包含指令和答案的小型数据集上重新训练基础模型。主要目标是将一个基本的文本预测模型转变为能够遵循指令并回答问题的助手。SFT还可以提高模型的整体性能,添加新知识或适应特定任务和领域。微调后的模型还可以经过一个可选的偏好对齐阶段(参见我的关于DPO的文章),以移除不希望的响应,修改其风格等。
下图展示了一个指令样本。它包括一个系统提示来引导模型,一个用户提示来提供任务,以及模型预期生成的输出。您可以在💾 LLM 数据集 GitHub 仓库中找到高质量的开源指令数据集列表。
图片由作者提供
在考虑SFT之前,我建议尝试一些提示工程技术,例如few-shot提示或检索增强生成(RAG)。在实践中,这些方法可以解决许多问题,而无需进行微调,无论是使用闭源模型还是开源模型(例如Llama 3.1 Instruct)。如果这种方法无法满足您的目标(在质量、成本、延迟等方面),那么当有指令数据可用时,SFT就成为一个可行的选择。需要注意的是,SFT还提供了额外的控制和定制化能力,以创建个性化的LLM。
然而,SFT 存在一些限制。它在利用基础模型中已有的知识时表现最佳。学习全新的信息,如未知的语言,可能会比较困难,并导致更多的幻觉。对于基础模型未知的新领域,建议先在原始数据集上进行持续的预训练。
在另一个极端,指令模型(即已经微调的模型)可能已经非常接近你的需求。例如,一个模型可能表现得非常好,但它会声明是由 OpenAI 或 Meta 训练的,而不是你。在这种情况下,你可能希望稍微调整指令模型的行为,使其行为与你的偏好对齐。通过为一组指令提供选定和拒绝的样本(样本数量在 100 到 1000 之间),你可以迫使大语言模型声明是你训练的,而不是 OpenAI。
最流行的三种SFT技术是全量微调、LoRA和QLoRA。
图片由作者提供
全量微调 是最直接的SFT技术。它涉及在指令数据集上重新训练预训练模型的所有参数。这种方法通常能提供最佳结果,但需要大量的计算资源(需要几台高端GPU来微调一个8B模型)。由于它修改了整个模型,这也是最具破坏性的方法,可能导致之前技能和知识的灾难性遗忘。
低秩适应(LoRA) 是一种参数高效的微调技术。它不是重新训练整个模型,而是冻结权重并在每个目标层引入小型适配器(低秩矩阵)。这使得 LoRA 能够训练比全量微调少得多的参数(少于1%),从而减少内存使用和训练时间。由于原始参数被冻结,这种方法是非破坏性的,适配器可以随意切换或组合。
QLoRA(量化感知低秩适配) 是 LoRA 的一个扩展,提供了更大的内存节省。与标准 LoRA 相比,它最多可以减少 33% 的内存使用,因此在 GPU 内存受限时特别有用。这种效率提升是以更长的训练时间为代价的,QLoRA 的训练时间通常比常规 LoRA 多出约 39%。
尽管 QLoRA 需要更多的训练时间,但它显著的内存节省可以在 GPU 内存有限的情况下使其成为唯一可行的选择。因此,我们将在此后的部分中使用此技术在 Google Colab 上对 Llama 3.1 8B 模型进行微调。
为了高效地微调Llama 3.1 8B模型,我们将使用Daniel和Michael Han开发的Unsloth库。由于其自定义内核,Unsloth的训练速度比其他选项快2倍,内存使用量减少60%,非常适合像Colab这样的受限环境。遗憾的是,Unsloth目前仅支持单GPU设置。对于多GPU设置,我推荐使用流行的替代方案,如TRL和Axolotl(两者都包含Unsloth作为后端)。
在本示例中,我们将使用 mlabonne/FineTome-100k 数据集对它进行 QLoRA 微调。这是一个 arcee-ai/The-Tome 数据集(不包括 arcee-ai/qwen2–72b-magpie-en)的子集,我使用了 HuggingFaceFW/fineweb-edu-classifier 进行了重新过滤。请注意,这个分类器并不是为指令数据质量评估设计的,但我们可以用它作为粗略的替代方案。最终的 FineTome 是一个超高质量的数据集,包括对话、推理问题、函数调用等。
让我们开始安装所有所需的库。
!pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" !pip install --no-deps "xformers<0.0.27" "trl<0.9.0" peft accelerate bitsandbytes
安装完成后,我们可以按照以下方式导入它们。
import torch from trl import SFTTrainer from datasets import load_dataset from transformers import TrainingArguments, TextStreamer from unsloth.chat_templates import get_chat_template from unsloth import FastLanguageModel, is_bfloat16_supported
我们现在来加载模型。由于我们想使用QLoRA,我选择了预量化版本的unsloth/Meta-Llama-3.1–8B-bnb-4bit。相比原始的16位精度模型(16 GB),这个4位精度版本的meta-llama/Meta-Llama-3.1–8B要小得多(5.4 GB),并且下载速度更快。我们使用bitsandbytes库以NF4格式加载模型。
当加载模型时,我们必须指定一个最大序列长度,这限制了其上下文窗口。Llama 3.1 支持最长 128k 的上下文长度,但在本示例中我们将它设置为 2,048,因为更长的长度会消耗更多的计算资源和显存。最后,dtype
参数会自动检测你的 GPU 是否支持 BF16 格式,以在训练过程中获得更好的稳定性(此功能仅限于 Ampere 及更新的 GPU)。
max_seq_length = 2048 model, tokenizer = FastLanguageModel.from_pretrained( model_name="unsloth/Meta-Llama-3.1-8B-bnb-4bit", max_seq_length=max_seq_length, load_in_4bit=True, dtype=None, )
现在我们的模型已经以4位精度加载完毕,我们想要用LoRA适配器准备参数高效的微调。LoRA有三个重要的参数:
在这里,我们设置 r=16,α=16,并针对每个线性模块最大化质量。我们不使用 dropout 和偏置以加快训练速度。
此外,我们将使用Rank-Stabilized LoRA(rsLoRA),它将LoRA适配器的缩放因子修改为1/√r而不是1/r。这可以稳定学习(特别是在更高的适配器秩时),并允许随着秩的增加提高微调性能。梯度检查点由Unsloth处理,将输入和输出嵌入卸载到磁盘并节省VRAM。
model = FastLanguageModel.get_peft_model( model, r=16, lora_alpha=16, lora_dropout=0, target_modules=["q_proj", "k_proj", "v_proj", "up_proj", "down_proj", "o_proj", "gate_proj"], use_rslora=True, use_gradient_checkpointing="unsloth" )
通过这种 LoRA 配置,我们将只训练 80 亿参数中的 4200 万(0.5196%)。这展示了 LoRA 相比于全量微调的高效性。
我们现在加载并准备我们的数据集。指令数据集存储在一种特定的格式中:它可以是 Alpaca、ShareGPT、OpenAI 等格式。首先,我们需要解析这种格式以获取我们的指令和答案。我们的 mlabonne/FineTome-100k 数据集使用 ShareGPT 格式,其中包含一个唯一的“conversations”列,该列包含 JSONL 格式的消息。与简单的格式(如 Alpaca)不同,ShareGPT 更适合存储多轮对话,这更接近用户与 LLM 交互的方式。
一旦我们的指令-回答对被解析后,我们希望重新格式化它们以遵循一个 聊天模板。聊天模板是一种结构化用户与模型之间对话的方式。它们通常包括特殊标记来标识消息的开始和结束、谁在说话等。基础模型没有聊天模板,因此我们可以选择任何模板:ChatML、Llama3、Mistral 等。在开源社区中,ChatML 模板(最初来自 OpenAI)是一个流行的选择。它只是添加了两个特殊标记(和
)来指示谁在说话。
如果我们把这个模板应用到之前的指令样本中,我们会得到以下结果:
system 你是一个乐于助人的助手,总是会给出解释。想象你在回答一个五岁的小朋友。 user 请去掉下面这句话中的空格:It prevents users to suspect that there are some hidden products installed on theirs device. assistant Itpreventsuserstosuspectthattherearesomehiddenproductsinstalledontheirsdevice.
在以下代码块中,我们使用 mapping
参数解析 ShareGPT 数据集,并包含 ChatML 模板。然后,我们加载并处理整个数据集,将聊天模板应用于每一段对话。
tokenizer = get_chat_template( tokenizer, mapping={"role": "from", "content": "value", "user": "human", "assistant": "gpt"}, chat_template="chatml", ) def apply_template(examples): messages = examples["conversations"] text = [tokenizer.apply_chat_template(message, tokenize=False, add_generation_prompt=False) for message in messages] return {"text": text} dataset = load_dataset("mlabonne/FineTome-100k", split="train") dataset = dataset.map(apply_template, batched=True)
我们现在准备好指定运行的训练参数了。我想要简要介绍最重要的超参数:
我在 Google Colab 上使用 A100 GPU(40GB 显存)对整个数据集(100k 个样本)进行了模型训练。训练耗时 4 小时 45 分钟。当然,你也可以使用显存更小的 GPU 和更小的批次大小,但它们的速度要慢得多。例如,在 L4 上训练大约需要 19 小时 40 分钟,在免费的 T4 上则需要 47 小时。
在这种情况下,我建议只加载数据集的一部分以加快训练速度。你可以通过修改之前的代码块来实现,例如将 dataset = load_dataset("mlabonne/FineTome-100k", split="train[:10000]")
修改为只加载 10000 个样本。或者,你可以使用更便宜的云 GPU 供应商,如 Paperspace、RunPod 或 Lambda Labs。
trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset, dataset_text_field="text", max_seq_length=max_seq_length, dataset_num_proc=2, packing=True, args=TrainingArguments( learning_rate=3e-4, lr_scheduler_type="linear", per_device_train_batch_size=4, gradient_accumulation_steps=4, num_train_epochs=1, fp16=not is_bfloat16_supported(), bf16=is_bfloat16_supported(), logging_steps=1, optim="adamw_8bit", weight_decay=0.01, warmup_steps=10, output_dir="output", seed=0, ), ) trainer.train()
现在模型已经训练好了,让我们用一个简单的提示来测试一下。这并不是一个严格的评估,而只是一个快速检查,用于检测潜在的问题。我们使用 FastLanguageModel.for_inference()
来实现2倍的推理速度。
model = FastLanguageModel.for_inference(model) messages = [ {"from": "human", "value": "9.11是否大于9.9?"}, ] inputs = tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_tensors="pt", ).to("cuda") text_streamer = TextStreamer(tokenizer) _ = model.generate(input_ids=inputs, streamer=text_streamer, max_new_tokens=128, use_cache=True)
模型的响应是“9.9”,这是正确的!
现在让我们保存训练好的模型。如果你还记得关于LoRA和QLoRA的部分,我们训练的不是模型本身,而是一组适配器。在Unsloth中有三种保存方法:lora
只保存适配器,而 merged_16bit
/merged_4bit
则将适配器与模型合并,并以16位/4位精度保存。
在以下步骤中,我们将它们以16位精度合并以最大化质量。我们首先将其本地保存在“model”目录中,然后上传到Hugging Face Hub。您可以在mlabonne/FineLlama-3.1–8B找到训练好的模型。
model.save_pretrained_merged("model", tokenizer, save_method="merged_16bit") model.push_to_hub_merged("mlabonne/FineLlama-3.1-8B", tokenizer, save_method="merged_16bit")
Unsloth 还允许你直接将你的模型转换为 GGUF 格式。这是一种为 llama.cpp 创建的量化格式,并且与大多数推理引擎兼容,例如 LM Studio、Ollama 和 oobabooga 的 text-generation-webui。由于你可以指定不同的精度(参见我的关于 GGUF 和 llama.cpp 的文章:Quantize Llama 2 模型使用 ggml),我们将遍历一个列表来将其量化为 q2_k
、q3_k_m
、q4_k_m
、q5_k_m
、q6_k
、q8_0
并将这些量化版本上传到 Hugging Face。mlabonne/FineLlama-3.1-8B-GGUF 包含了我们所有的 GGUF。
quant_methods = ["q2_k", "q3_k_m", "q4_k_m", "q5_k_m", "q6_k", "q8_0"] for quant in quant_methods: model.push_to_hub_gguf("mlabonne/FineLlama-3.1-8B-GGUF", tokenizer, quant)
恭喜,我们从头微调了一个模型,并上传了可以在您喜欢的推理引擎中使用的量化版本。您可以尝试在 mlabonne/FineLlama-3.1–8B-GGUF 上提供的最终模型。接下来该做什么呢?这里有一些关于如何使用您的模型的建议:
本文提供了监督微调的全面概述,并介绍了如何将其应用于实践中,以Llama 3.1 8B模型为例。通过利用QLoRA高效的记忆使用,我们成功地在有限的GPU资源下对一个8B的大型语言模型进行了超高质量数据集的微调。我们还提供了更大规模运行的更高效替代方案,并提出了进一步步骤的建议,包括评估、偏好对齐、量化和部署。
希望这篇指南对你有所帮助。如果你对了解大语言模型(LLMs)感兴趣,我推荐你查看LLM 课程。如果你喜欢这篇文章,可以在 X 上关注我 @maximelabonne 和在 Hugging Face 上关注我 @mlabonne。祝你调整模型顺利!