最近发布的 Llama 3.1 为模型提供了令人难以置信的性能水平,缩小了闭源模型和开放权重模型之间的差距。你无需使用 GPT-4o 和 Claude 3.5 等冻结的通用 LLM,而是可以根据特定用例对 Llama 3.1 进行微调,以更低的成本实现更好的性能和可定制性。
在本文中,我们将全面概述监督微调。我们将将其与即时工程进行比较,以了解何时使用它有意义,详细介绍主要技术及其优缺点,并介绍主要概念,例如 LoRA 超参数、存储格式和聊天模板。最后,我们将通过在 Google Colab 中对 Llama 3.1 8B 进行微调,并使用 Unsloth 进行最先进的优化,在实践中实现它。
监督微调
监督微调(SFT)是一种改进和定制预训练 LLM 的方法。它包括在较小的指令和答案数据集上重新训练基础模型。其主要目标是将预测文本的基本模型转化为能够遵循指令和回答问题的助手。SFT 还能提高模型的整体性能,增加新知识,或使其适应特定任务和领域。经过微调的模型还可以通过可选的偏好对齐阶段来删除不需要的回复、修改风格等。
下图显示了一个指令示例。其中包括引导模型的系统提示、提供任务的用户提示以及模型预期生成的输出。
在考虑使用 SFT 之前,我建议尝试一下提示工程技术,如少量提示 (few-shot prompting) 或检索增强生成 (RAG)。在实践中,这些方法可以解决很多问题,无需微调,可以使用闭源模型或开放重量模型(如 Llama 3.1 Instruct)。如果这种方法不能满足你的目标(在质量、成本、延迟等方面),那么在有指令数据的情况下,SFT 就成了一个可行的选择。需要注意的是,SFT 还能提供额外的控制和定制功能,以创建个性化的 LLM。
不过,SFT 也有局限性。当利用基础模型中已有的知识时,它的效果最好。学习全新的信息(如未知语言)可能具有挑战性,会导致更频繁的幻觉。对于基础模型未知的新领域,建议先在原始数据集上对其进行持续的预训练。
相反,指导模型(即已经过微调的模型)可能已经非常接近你的要求。例如,一个模型可能表现得非常好,但却说它是由 OpenAI 或 Meta 训练出来的,而不是由你训练出来的。在这种情况下,你可能希望使用偏好对齐来稍微引导指示模型的行为。通过为一小部分指令集(100 到 1000 个样本之间)提供选择和拒绝样本,你可以迫使 LLM 说是你而不是 OpenAI 训练的。
SFT 技术
最流行的三种 SFT 技术是完全微调、LoRA 和 QLoRA。
全面微调是最直接的 SFT 技术。它包括在指令数据集上重新训练预训练模型的所有参数。这种方法通常能提供最佳结果,但需要大量计算资源(微调一个 8B 模型需要多个高端 GPU)。由于这种方法会修改整个模型,因此也是破坏性最大的方法,可能会导致对以前技能和知识的灾难性遗忘。
低级适应(Low-Rank Adaptation,LoRA)是一种流行的参数高效微调技术。它不是重新训练整个模型,而是冻结权重,并在每个目标层引入小型适配器(低秩矩阵)。这使得 LoRA 训练的参数数量大大低于完全微调(小于 1%),从而减少了内存使用量和训练时间。这种方法是非破坏性的,因为原始参数会被冻结,然后可以随意切换或组合适配器。
QLoRA(量化感知低阶适配)是 LoRA 的扩展,可以节省更多内存。与标准 LoRA 相比,QLoRA 最多可额外减少 33% 的内存,因此在 GPU 内存有限的情况下特别有用。效率提高的代价是训练时间延长,QLoRA 的训练时间通常比普通 LoRA 多出约 39%。
虽然 QLoRA 需要更多的训练时间,但在 GPU 内存有限的情况下,它可以节省大量内存,是唯一可行的选择。
对 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 分类器对其进行了重新过滤。请注意,该分类器并非为教学数据质量评估而设计,但我们可以将其作为一个粗略的参考。最终生成的 FineTome 是一个超高质量的数据集,其中包括对话、推理问题、函数调用等。
让我们先安装所有必需的库。
!pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git""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,因为它消耗更多计算量和 VRAM。最后,dtype 参数会自动检测 GPU 是否支持 BF16 格式,以便在训练过程中获得更高的稳定性(该功能仅限于 Ampere 和更新款的 GPU)。
max_seq_length = 20482048
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 和偏差。
此外,我们还将使用等级稳定的 LoRA(rsLoRA),它将 LoRA 适配器的缩放因子修改为与 1/√r 而不是 1/r 成比例。这可以稳定学习(尤其是对于较高的适配器等级),并随着等级的提高而改善微调性能。梯度检查点由 Unsloth 处理,以便将输入和输出嵌入卸载到磁盘,并节省 VRAM。
model = FastLanguageModel.get_peft_model(
model,
r=16,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 格式,其中有一个独特的 “对话 ”列,包含 JSONL 格式的信息。与 Alpaca 等更简单的格式不同,ShareGPT 非常适合存储多轮对话,这更接近用户与 LLM 的交互方式。
解析完指令-答案对后,我们要按照聊天模板重新格式化它们。聊天模板是构建用户与模型之间对话的一种方式。它们通常包括特殊标记,用于识别信息的开始和结束、谁在说话等。基本模型没有聊天模板,因此我们可以任意选择: ChatML、Llama3、Mistral 等。在开源社区,ChatML 模板(最初来自 OpenAI)是一个很受欢迎的选项。它只需添加两个特殊标记(<|im_start|> 和 <|im_end|>)来表示谁在说话。
如果我们将此模板应用到前面的指令示例中,就会得到以下结果:
<|im_start|>system
You are a helpful assistant, who always provide explanation. Think like you are answering to a five year old.<|im_end|>
<|im_start|>user
Remove the spaces from the following sentence: It prevents users to suspect that there are some hidden products installed on theirs device.
<|im_end|>
<|im_start|>assistant
Itpreventsuserstosuspectthattherearesomehiddenproductsinstalledontheirsdevice.<|im_end|>
在下面的代码块中,我们使用映射参数解析 ShareGPT 数据集,并包含 ChatML 模板。然后,我们加载并处理整个数据集,将聊天模板应用到每个对话中。
tokenizer = get_chat_template(
tokenizer,
mapping={"role": "from", "content": "value", "user": "human", "assistant": "gpt"},"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(40 GB 虚拟内存)对整个数据集(10 万个样本)进行了模型训练。训练耗时 4 小时 45 分钟。当然,你也可以使用 VRAM 更少、批量更小的小型 GPU,但它们的速度就差远了。例如,使用 L4 大概需要 19 小时 40 分钟,而使用免费的 T4 则需要 47 小时。
在这种情况下,我建议只加载数据集的一个子集,以加快训练速度。你可以修改前面的代码块,比如 dataset = load_dataset(“mlabonne/FineTome-100k”,split=“train[:10000]”),只加载 10k 个样本。或者,你也可以使用更便宜的云 GPU 提供商,如 Paperspace、RunPod 或 Lambda Labs。
trainer=SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="text","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": "Is 9.11 larger than 9.9?"},"from": "human", "value": "Is 9.11 larger than 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", 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 的文本生成-webui。由于你可以指定不同的精度(参见我关于 GGUF 和 llama.cpp 的文章),我们将在一个列表上循环,以 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"]"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 LLM 进行了微调。我们还为更大规模的运行提供了更高效的替代方案,并为进一步的步骤(包括评估、偏好对齐、量化和部署)提供了建议。