最近发布的 Llama 3.1 为模型提供了令人难以置信的性能水平,缩小了闭源模型和开放权重模型之间的差距。你无需使用 GPT-4o 和 Claude 3.5 等冻结的通用 LLM,而是可以根据特定用例对 Llama 3.1 进行微调,以更低的成本实现更好的性能和可定制性。
在本文中,我们将全面概述监督微调。我们将将其与即时工程进行比较,以了解何时使用它有意义,详细介绍主要技术及其优缺点,并介绍主要概念,例如 LoRA 超参数、存储格式和聊天模板。最后,我们将通过在 Google Colab 中对 Llama 3.1 8B 进行微调,并使用 Unsloth 进行最先进的优化,在实践中实现它。
🔧 监督微调
监督式微调 (SFT) 是一种改进和定制预训练 LLM 的方法。它涉及在较小的指令和答案数据集上重新训练基础模型。主要目标是将预测文本的基本模型转变为可以遵循指令和回答问题的助手。SFT 还可以增强模型的整体性能、添加新知识或使其适应特定任务和领域。然后,经过微调的模型可以经过可选的偏好对齐阶段,以删除不需要的响应、修改其风格等。
下图显示了一个指令示例。它包括引导模型的系统提示、提供任务的用户提示以及模型预期生成的输出。
在考虑 SFT 之前,我建议尝试快速工程技术,例如少样本提示或检索增强生成(RAG)。实际上,这些方法可以使用闭源或开放权重模型(例如 Llama 3.1 Instruct)解决许多问题而无需进行微调。如果这种方法不能满足你的目标(在质量、成本、延迟等方面),那么当指令数据可用时,SFT 将成为可行的选择。请注意,SFT 还提供额外的控制和可定制性等好处,以创建个性化的 LLM。
然而,SFT 有局限性。当利用基础模型中已有的知识时,效果最佳。学习全新信息(例如未知语言)可能具有挑战性,并导致更频繁的幻觉。对于基础模型未知的新领域,建议先在原始数据集上对其进行持续预训练。
另一方面,指导模型(即已经微调的模型)可能已经非常接近你的要求。例如,一个模型可能表现非常好,但声明它是由 OpenAI 或 Meta 而不是你训练的。在这种情况下,你可能希望使用偏好对齐稍微引导指导模型的行为。通过为一小部分指令(100 到 1000 个样本之间)提供选定和拒绝的样本,你可以强制 LLM 说你训练了它而不是 OpenAI。
⚖️ SFT 技术
最流行的三种 SFT 技术是完全微调、LoRA 和 QLoRA。完全微调是最简单的 SFT 技术。它涉及在指令数据集上重新训练预训练模型的所有参数。这种方法通常能提供最佳结果,但需要大量计算资源(微调 8B 模型需要多个高端 GPU)。由于它会修改整个模型,因此它也是最具破坏性的方法,可能导致灾难性地忘记以前的技能和知识。
低秩自适应 (LoRA)是一种流行的参数高效微调技术。它不会重新训练整个模型,而是冻结权重并在每个目标层引入小型适配器(低秩矩阵)。这使得 LoRA 能够训练大量参数,这些参数的数量大大低于完全微调(不到 1%),从而减少了内存使用量和训练时间。这种方法是非破坏性的,因为原始参数被冻结,然后可以随意切换或组合适配器。
QLoRA(量化感知低秩自适应)是 LoRA 的扩展,可进一步节省内存。与标准 LoRA 相比,它最多可额外节省 33% 的内存,这在 GPU 内存受限时尤其有用。效率的提高是以更长的训练时间为代价的,QLoRA 的训练时间通常比常规 LoRA 多 39%。
虽然 QLoRA 需要更多训练时间,但其显著的内存节省使其成为 GPU 内存有限情况下的唯一可行选择。因此,我们将在下一节中使用这种技术在 Google Colab 上微调 Llama 3.1 8B 模型。
🦙 微调Llama3.1 8B
为了高效地微调[ Llama 3.1 8B](https://huggingface.co/meta-llama/Meta-Llama-3.1-8B) 模型,我们将使用Daniel 和 Michael Han 开发的[ Unsloth](https://github.com/unslothai/unsloth) 库。与其他选项相比,Unsloth 的自定义内核使其训练速度提高了 2 倍,内存使用率降低了 60%,使其成为 Colab 等受限环境的理想选择。遗憾的是,Unsloth 目前仅支持单 GPU 设置。对于多 GPU 设置,我推荐[ TRL](https://huggingface.co/docs/trl/en/index) 和[ Axolotl](https://github.com/OpenAccess-AI-Collective/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,因为它消耗更多的计算和 VRAM。最后,该参数会自动检测你的 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 有三个重要参数:
- 等级®,决定 LoRA 矩阵的大小。等级通常从 8 开始,但最高可达 256。更高的等级可以存储更多信息,但会增加 LoRA 的计算和内存成本。我们在这里将其设置为 16。
- Alpha (α),更新的缩放因子。Alpha 直接影响适配器的贡献,通常设置为等级值的 1 倍或 2 倍。
- 目标模块:LoRA 可应用于各种模型组件,包括注意力机制(Q、K、V 矩阵)、输出投影、前馈块和线性输出层。虽然最初专注于注意力机制,但将 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 格式,带有一个独特的“对话”列,其中包含 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 数据集mapping并包含 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)
现在我们可以指定运行的训练参数了。我想简单介绍一下最重要的超参数:
- 学习率:它控制模型更新参数的强度。太低,训练就会很慢,可能会陷入局部最小值。太高,训练可能会变得不稳定或发散,从而降低性能。
- LR 调度程序:它在训练期间调整学习率 (LR),从较高的 LR 开始,以获得快速的初始进展,然后在后期降低 LR。线性和余弦调度程序是两种最常见的选项。
- 批次大小:在更新权重之前处理的样本数量。批次大小越大,梯度估计值越稳定,可以提高训练速度,但也需要更多内存。梯度累积可以在更新模型之前通过多次前向/后向传递累积梯度,从而有效地实现更大的批次大小。
- 训练周期数:训练数据集的完整遍历次数。训练周期越多,模型查看数据的次数就越多,从而可能提高性能。但是,训练周期过多可能会导致过度拟合。
- 优化器:用于调整模型参数以最小化损失函数的算法。实际上,强烈推荐使用 AdamW 8 位:它的性能与 32 位版本一样好,同时占用更少的 GPU 内存。分页版本的 AdamW 仅在分布式设置中才有意义。
- 权重衰减:一种正则化技术,它为损失函数增加了对大权重的惩罚。它通过鼓励模型学习更简单、更通用的特征来帮助防止过度拟合。然而,过多的权重衰减会阻碍学习。
- 预热步骤:训练开始时的一段时间,学习率从较小的值逐渐增加到初始学习率。预热可以帮助稳定早期训练,尤其是在学习率或批量大小较大的情况下,通过允许模型在进行大量更新之前适应数据分布。
- 包装:批次具有预定义的序列长度。我们可以将多个小样本合并为一个批次,而不是每个样本分配一个批次,从而提高效率。
我使用 Google Colab 上的 A100 GPU(40 GB VRAM)在整个数据集(100k 个样本)上训练了模型。训练耗时 4 小时 45 分钟。当然,你可以使用 VRAM 更少、批处理大小更小的 GPU,但它们的速度并没有那么快。例如,在 L4 上大约需要 19 小时 40 分钟,而在免费的 T4 上则需要整整 47 小时。
在这种情况下,我建议仅加载数据集的子集以加快训练速度。你可以通过修改前面的代码块来实现,例如dataset = load_dataset(“mlabonne/FineTome-100k”, split=“train[:10000]”)仅加载 10k 个样本。
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=8,
gradient_accumulation_steps=2,
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?"},
]
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 位精度合并它们以最大化质量。我们首先将其保存在本地的“模型”目录中,然后将其上传到 Hugging Face Hub。你可以在mlabonne/FineLlama-3.1-8B上找到训练好的模型。
Python
接下来,我们以 16 位精度合并它们以最大化质量。我们首先将其保存在本地的“模型”目录中,然后将其上传到 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 的文章),我们将循环遍历列表以将其量化为q2_k、q3_k_m、q4_k_m、q5_k_m,并将这些量化上传到 Hugging Face。mlabonne /FineLlama-3.1-8B-GGUF包含我们所有的 GGUF。q6_kq8_0
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上提供的最终模型。现在该做什么?以下是一些关于如何使用你的模型的想法:
- 在Open LLM Leaderboard上进行评估(你可以免费提交)或使用其他评估方式(如LLM AutoEval )。
- 使用偏好数据集(如mlabonne/orpo-dpo-mix-40k)将其与直接偏好优化相结合,以提高性能。
- 使用AutoQuant将其量化为其他格式(如 EXL2、AWQ、GPTQ 或 HQQ),以实现更快的推理或更低的精度。
- 将其部署到带有ZeroChat的 Hugging Face Space 上,适用于已经过充分训练以遵循聊天模板(约 20k 个样本)的模型。