Phi-2 微调 2.0
认识 Phi-2,微软最新发布的小型模型,功能强大但结构紧凑。本教程将指导您微调 Phi-2,演示如何构建独特的数据集并使用 QLoRA 微调模型。
在本教程中,我们将探讨微软的 Phi-2,一个仅有 27 亿参数的模型。尽管与同期的一些模型相比,它的尺寸更小,但 Phi-2 以其卓越的性能令人印象深刻。
我们重点介绍如何使用 QLoRA 和合成数据微调 Phi-2。本教程分为两部分:
- 从指令种子中创建合成数据集(第一部分)
- 使用 QLoRA 微调 Phi-2(第二部分)
一如既往,我们提供了简单且极简的 Python 代码。
先决条件
在开始之前,您需要最新的 Hugging Face 包。作为参考,这些是用于本教程的具体版本:
accelerate==0.26.1
bitsandbytes==0.42.0
datasets==2.16.1
peft==0.8.1
transformers==4.37.2
einops==0.7.0
torch==2.1.0
第一部分:合成数据集
在这一部分,我们专注于制作一个数据集来微调 Phi-2,目标是构建一个聊天/指令模型。我们从现有数据集开始,并将其转换为一系列引人入胜的对话。
初始数据集:谜语集
前段时间我偶然发现了这个数据集:Hypersniper/riddles_v1。它是一个谜语集合,每个谜语都配有 GPT-4 提供的巧妙的分步解决方案。
这个数据集将是我们的种子。目标是使用(另一个)强大的语言模型将这些大约 470 个谜语转换为实际对话,如下所示:
用户:提出一个问题。
助手:提供答案并附带详细解释,可能还会提出反问。
用户:提出另一个问题。
助手:回答问题。
让我们通过代码和注释来一步步实现。第一部分的所有代码都可以在笔记本中找到。
加载数据集
首先,我们加载包含谜语的数据集
from datasets import load_dataset, DatasetDict, Dataset,load_from_disk
dataset_name="Hypersniper/riddles_v1"
dataset = load_dataset(dataset_name, split="all")
数据看起来像这样
{
"instruction": "What is pronounced like one letter, written with three
letters. And belongs to all animals?",
"output": "The answer to this question is \"eye.\" Let's break down the
logic behind this:\n\n1. Pronounced like one letter: The word \"eye\" is
pronounced like the letter \"I.\" This is a common type of word play in
English where a word sounds like another word or a letter.\n\n2. Written
with three letters: The word \"eye\" is indeed spelled with three letters:
E, Y, and E.\n\n3. Belongs to all animals: All animals, including humans,
have eyes. They are an essential part of most animals' anatomy and serve
the critical function of allowing them to see.\n\nSo, by considering these
three clues together, we can deduce that the answer to the riddle is \"eye.\"",
"answer": "eye"
}
问题在于:这个数据集太小,而且它只有一个回合,一个问题和一个答案。
将谜语转化为对话
为了扩展数据集并将其转换为对话,我们使用现有的最智能的 LLM 之一:mistralai/Mistral-7B-Instruct-v0.2。这个过程包括:
- 生成新谜语: 给 Mistral 提供 10 个谜语,并要求它再生成 10 个。
- 回答这些合成谜语。
- 作为人类用户,提出一个后续问题。
- 生成对这个后续问题的回答。
步骤 0:加载智能 LLM
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model = AutoModelForCausalLM.from_pretrained(
"mistralai/Mistral-7B-Instruct-v0.2",
device_map="auto",
torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("models/Mistral-7B-Instruct-v0.2")
步骤 1: 扩展谜语
我们提示 mistralai/Mistral-7B-Instruct-v0.2 根据现有谜语生成新谜语。这是通过向 Mistral 喂入 10 个谜语并要求它再创建 10 个来实现的。
import random
questions=[ q for q in dataset["instruction"] ]
prompt_template=""""Below are 10 riddles. Come up with 10 more.
Output just the riddles, no numbering. Don't output anything else.
Riddles:
{questions}"""
synthetic_riddles = []
# Ask Mistral 300 times = 3,000 new riddles
for _ in range(300):
# Pick 10 random questions to include in prompt
random.shuffle(questions)
q10_sample = questions[0:10]
# Put 10 questions into prompt template = prompt
prompt=prompt_template.format( questions="\n\n".join(q10_sample) )
messages = [{"role": "user", "content": prompt}]
# Apply Mistral chat format to prompt, tokenize, generate
input_tokens = tokenizer.apply_chat_template(
messages,
return_tensors="pt"
).to("cuda")
output_tokens = model.generate(
input_tokens,
max_new_tokens = 500,
do_sample = True,
pad_token_id = tokenizer.eos_token_id
)
output_tokens = output_tokens[0][len(input_tokens[0]):] # Cut prompt from output
output = tokenizer.decode(output_tokens, skip_special_tokens=True)
synthetic_riddles.extend( output.split("\n") )
Mistral 生成的谜语示例
一个袋子里装满了苹果,有的红色,有的绿色。如果你不看就伸手去拿一个苹果,它是熟的几率是多少?
洗澡不能给你一个,河流不能浮起一个,但什么能在炎热的沙漠中找到它?
大树曾经是小种子。小鸟曾经是小鸡蛋。爱丽丝在成为女孩之前是什么?
注意:未选择樱桃或柠檬,按字母顺序排列,显示了前三个生成的谜语。
通过此步骤,Mistral 生成了大约 3000 个谜语。然而,这些合成谜语相当“不干净”。经过清理(~1k 谜语被丢弃)和去重(~300 个重复)后,我们剩下 1682 个新谜语用于下一步。数据清理的基本代码可以在前面提到的笔记本中找到。
使用 GPT-4(或任何其他模型)进行数据集扩展
我们使用 Mistral 来扩展谜语,但任何其他智能模型,如 Mixtral 或 OpenAI API 都可以用于此目的。
例如,要使用 GPT-4,可以将类似 ask_GPT4
的函数插入到上面的代码中,并用它代替 generate
。
from openai import OpenAI
client = OpenAI()
def ask_GPT4(prompt):
completion = client.chat.completions.create(
model = "gpt-4",
messages = [ {"role": "user", "content": prompt} ]
)
return completion.choices[0].message.content
ask_GPT4(prompt)
步骤 2: 生成合成谜语的答案
接下来,我们使用 Mistral 生成新谜语的答案。此步骤完成了我们正在构建的对话的第一轮。
import copy
prompt_template = """"{riddle}
Think step-by-step, keep your explanations simple, try your very best.
If there is information missing for you to come up with a specific
answer, just ask me a short question at the very end of your answer."""
# copy the dict with the synthetic riddles to a new one which will contain the answers
synthetic_riddles_step2 = copy.deepcopy(synthetic_riddles_dict)
for riddle in synthetic_riddles_step2:
# format prompt using the template, insert the riddle
prompt = prompt_template.format( riddle=riddle["messages"][0] )
# apply Mistal prompt format and tokenize
messages = [{"role": "user", "content": prompt}]
input_tokens = tokenizer.apply_chat_template(messages, return_tensors="pt").to("cuda")
# generate. 500 output tokens are enough for the average answer length of Mistral
output_tokens = model.generate(
input_tokens,
max_new_tokens = 500,
do_sample = True,
pad_token_id = tokenizer.eos_token_id)
output_tokens = output_tokens[0][len(input_tokens[0]):]
output = tokenizer.decode(output_tokens, skip_special_tokens=True)
# append answer to each conversation
riddle["messages"].append(output)
这会产生如下内容(前面的示例继续):
用户:一个袋子里装满了苹果,有的红色,有的绿色。如果你不看就伸手去拿一个苹果,它是熟的几率是多少?
助手:给定信息没有说明苹果的红或绿色与成熟度有任何关系。因此,我们无法仅凭颜色来确定摘到的苹果是否成熟。此外,成熟度是一个主观因素,即使在同一种颜色组中,苹果之间也可能存在差异。因此,我们需要更多信息,例如成熟度的定义或苹果存储条件的任何上下文,才能提供答案。用户:洗澡不能给你一个,河流不能浮起一个,但什么能在炎热的沙漠中找到它?
助手:谜语的答案是影子。影子在没有阳光的情况下无法存在,而阳光通常出现在炎热的沙漠气候中。影子不能由洗澡或河流物理地创造,因为它们是物体阻挡光线的结果。用户:大树曾经是小种子。小鸟曾经是小鸡蛋。爱丽丝在成为女孩之前是什么?
助手:爱丽丝在成为女孩之前是一个孩子。但是,我将你的问题解释为询问爱丽丝在长大成人之前是什么,而不仅仅是她在被贴上女孩标签之前是什么。如果爱丽丝是故事中的人物或现实生活中的人物,她可能在成为女孩之前是一个婴儿。但是如果你对“爱丽丝”有其他意思,请随时要求澄清。
这些答案相当不错!
一次完整的对话还缺少什么?我们希望构建一个多轮数据集。为了简单起见,我们将为每个对话生成两轮。第二轮仍缺少一个后续问题(由用户提出)和一个答案(由 LLM 提供)。
步骤 3: 生成人类后续问题
如果我们把到目前为止的对话呈现给 Mistral,并要求它提出一个“聪明”的后续问题,它会试图表现得非常聪明,并开始解释事情,这更像一个助手而不是人类用户。我们不想要那样。
我们想要简单,甚至有些愚蠢的问题。这是最有效的提示,要求 Mistral 模仿一个好奇的孩子
import copy
prompt_template = """"Please continue the converstation below. Provide
the next reply by the user. Formulate a very short question.
Imitate a curious 10 year old kid asking a question.
user: {question}
assistant: {answer}"""
# copy the dict with the synthetic riddles to a new one which will contain the answers too
synthetic_riddles_step3 = copy.deepcopy(synthetic_riddles_step2)
for riddle in synthetic_riddles_step3:
# format prompt using the template, insert the conversation we have so far
prompt = prompt_template.format(
question = riddle["messages"][0],
answer = riddle["messages"][1]
)
messages = [ {"role": "user", "content": prompt} ]
input_tokens = tokenizer.apply_chat_template(
messages,
return_tensors="pt"
).to("cuda")
output_tokens = model.generate(
input_tokens,
max_new_tokens = 500,
do_sample = True,
pad_token_id = tokenizer.eos_token_id
)
output_tokens = output_tokens[0][len(input_tokens[0]):]
output = tokenizer.decode(output_tokens, skip_special_tokens = True)
riddle["messages"].append(output)
显然,Mistral 认为一个 10 岁的孩子是这样说话的:
太酷了!那么它们在花园里只吃什么呢?
太酷了!那么哪只鸟象征性地与美国第五任总统相关联呢?
太酷了!所以,你就像一个帮助制作饼干的容器,对吧?但是那些小茶杯或汤杯呢?它们叫什么?
太酷了!所以,如果我把我的玩具分享给我的朋友们,他们也可能会把他们的玩具分享给我,对吧?
太酷了!那么,什么能让你笑也能让你哭呢?
太酷了!那么,如果我们不喝足够的水会发生什么?
太酷了!那么,还有哪些神话生物不用嘴巴说话呢?
太酷了!那铅笔呢?它拿在手里也会变强,对吗?
太酷了!当铅没有被破坏和不纯的时候,它看起来像什么?
到目前为止生成的对话示例:
用户:一个袋子里装满了苹果,有的红色,有的绿色。如果你不看就伸手去拿一个苹果,它是熟的几率是多少?
助手:给定信息没有说明苹果的红或绿色与成熟度有任何关系。因此,我们无法仅凭颜色来确定摘到的苹果是否成熟。此外,成熟度是一个主观因素,即使在同一种颜色组中,苹果之间也可能存在差异。因此,我们需要更多信息,例如成熟度的定义或苹果存储条件的任何上下文,才能提供答案。
用户:但是如果苹果是红色的,我们怎么能判断它更可能是熟的苹果呢,因为有时候红色的苹果还没有熟?用户:洗澡不能给你一个,河流不能浮起一个,但什么能在炎热的沙漠中找到它?
助手:谜语的答案是影子。影子在没有阳光的情况下无法存在,而阳光通常出现在炎热的沙漠气候中。影子不能由洗澡或河流物理地创造,因为它们是物体阻挡光线的结果。
用户:所以你是说影子是在沙漠中发现阳光的东西?太酷了!用户:大树曾经是小种子。小鸟曾经是小鸡蛋。爱丽丝在成为女孩之前是什么?
助手:爱丽丝在成为女孩之前是一个孩子。但是,我将你的问题解释为询问爱丽丝在长大成人之前是什么,而不仅仅是她在被贴上女孩标签之前是什么。如果爱丽丝是故事中的人物或现实生活中的人物,她可能在成为女孩之前是一个婴儿。但是如果你对“爱丽丝”有其他意思,请随时要求澄清。
用户:爱丽丝小时候真的,真的很小的时候是什么样子?
现在进入下一步:完成第二轮。
步骤 4: 生成后续问题的答案
from tqdm import tqdm
import copy
synthetic_riddles_step4 = copy.deepcopy(synthetic_riddles_step3)
for riddle in tqdm(synthetic_riddles_step4):
# this time no prompt, just apply the Mistral chat template
# to the three messages we generated so far
messages = [
{"role": "user", "content": riddle["messages"][0]},
{"role": "assistant", "content": riddle["messages"][1]},
{"role": "user", "content": riddle["messages"][2]},
]
input_tokens = tokenizer.apply_chat_template(
messages,
return_tensors = "pt"
).to("cuda")
output_tokens = model.generate(
input_tokens,
max_new_tokens = 500,
do_sample = True,
pad_token_id = tokenizer.eos_token_id
)
output_tokens = output_tokens[0][len(input_tokens[0]):]
output = tokenizer.decode(output_tokens, skip_special_tokens = True)
riddle["messages"].append(output)
对话示例
用户:一个袋子里装满了苹果,有的红色,有的绿色。如果你不看就伸手去拿一个苹果,它是熟的几率是多少?
助手:给定信息没有说明苹果的红或绿色与成熟度有任何关系。因此,我们无法仅凭颜色来确定摘到的苹果是否成熟。此外,成熟度是一个主观因素,即使在同一种颜色组中,苹果之间也可能存在差异。因此,我们需要更多信息,例如成熟度的定义或苹果存储条件的任何上下文,才能提供答案。
用户:但是如果苹果是红色的,我们怎么能判断它更可能是熟的苹果呢,因为有时候红色的苹果还没有熟?
助手:你说的没错,苹果的颜色(红色)不一定表示它已经成熟。有些苹果品种,比如青苹果,即使完全成熟也仍然是绿色的。因此,仅仅凭苹果的颜色无法确定它是否成熟。
要判断苹果的成熟度,必须考虑其他因素,如其质地、硬度以及轻压时的回弹性。一个成熟的苹果通常表皮均匀无瑕,并且在轻压时会略微凹陷。果实还应该有甜美的香气。
因此,虽然红苹果可能比未成熟的绿苹果更有可能成熟,但仍然没有万无一失的方法仅凭颜色来预测成熟度。最终,确定苹果是否成熟的唯一准确方法是检查其上述成熟迹象。用户:洗澡不能给你一个,河流不能浮起一个,但什么能在炎热的沙漠中找到它?
助手:谜语的答案是影子。影子在没有阳光的情况下无法存在,而阳光通常出现在炎热的沙漠气候中。影子不能由洗澡或河流物理地创造,因为它们是物体阻挡光线的结果。
用户:所以你是说影子是在沙漠中发现阳光的东西?太酷了!
助手:是的,没错!影子是光源被阻挡后形成的一个区域或形状,从而产生一个黑暗或凹陷的区域。在这个谜语中,沙漠被指定为炎热的地方,这可能暗示那里的阳光特别强烈。影子依赖于阳光,因此,它们可以在沙漠中找到,尽管沙漠本身无法提供影子。用户:大树曾经是小种子。小鸟曾经是小鸡蛋。爱丽丝在成为女孩之前是什么?
助手:爱丽丝在成为女孩之前是一个孩子。但是,我将你的问题解释为询问爱丽丝在长大成人之前是什么,而不仅仅是她在被贴上女孩标签之前是什么。如果爱丽丝是故事中的人物或现实生活中的人物,她可能在成为女孩之前是一个婴儿。但是如果你对“爱丽丝”有其他意思,请随时要求澄清。
用户:爱丽丝小时候真的,真的很小的时候是什么样子?
助手:如果“爱丽丝”指的是一个人,我无法确切知道她小时候究竟是什么样子,因为我无法获取她的个人历史或经历。然而,当一个人非常非常小的时候,通常被称为婴儿。婴儿尚未具备复杂的思维或行动能力,他们依赖他人满足其基本需求,如食物、住所和照护。他们主要通过哭泣、咿呀学语和其他非语言方式进行交流,并通过触摸、味觉、嗅觉和视觉探索环境。婴儿在这个生命阶段发展迅速,最终学会爬行、走路、说话并变得独立。如果“爱丽丝”是故事中的人物或某人想象中的虚构人物,她的早期生活可能会根据作者或创作者的意图以不同方式描述。
这些对话例证了我们聊天机器人回复所追求的深度和交互性。
总之,本教程的第一部分演示了如何使用 Mistral 将一个简单的谜语数据集演变为一个全面的两轮对话数据集。该数据集可在 Hugging Face 上找到:g-ronimo/riddles_evolved。我们最终只得到了 1.7k 条对话,但尽管规模相当小,它还是带来了不错的结果(参见第二部分:训练)。然而,我们没有进行任何质量控制或过滤。这个数据集,一如既往,肯定会受益于额外的数据整理。
用其他类型的数据作为对话的种子
我们使用了几百个谜语作为种子。这里有一些额外的数据集作为基础:
- 哲学,例如 mertbozkurt/quotes_philosophers
- 励志,例如 leonweber/teaching_motivational_quotes
- 有趣,例如 Khalida1w/funny_quotes 和 SocialGrep/one-million-reddit-jokes
第二部分:微调 Phi-2
在这一部分,我们使用新创建的合成数据集对 Phi-2 进行微调。
这个过程包括:
- 加载并准备 ChatML 格式的模型和分词器
- 将 LoRA 适配器连接到模型
- 加载并分词数据集
- 设置超参数并训练
我们将以对训练和模型输出的分析来结束第二部分。第二部分的所有代码都可以在笔记本中找到。
加载 4 位模型和分词器
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
# Load model
modelpath = "microsoft/phi-2"
model = AutoModelForCausalLM.from_pretrained(
modelpath,
device_map="auto",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_quant_type="nf4",
),
torch_dtype=torch.bfloat16,
# FA2 does not work yet
# attn_implementation="flash_attention_2",
)
注意:Flash attention 2 节省 VRAM 并加速训练,但目前 Phi-2 的实现存在问题。我预计这个问题很快就会解决。
加载分词器并将 ChatML 特殊标记添加到模型和分词器
from transformers import AutoTokenizer
# fast tokenizer sometimes ignores the added tokens
tokenizer = AutoTokenizer.from_pretrained(modelpath, use_fast=False)
# add special tokens for ChatML formatting and a pad token
tokenizer.add_tokens(["<|im_start|>", "<PAD>"])
tokenizer.pad_token = "<PAD>"
tokenizer.add_special_tokens(dict(eos_token="<|im_end|>"))
model.config.eos_token_id = tokenizer.eos_token_id
注意:在这种情况下,不需要调整 token 嵌入的大小。Phi-2 的词汇表大小为 51200,而其分词器已知 50295 个 token。这意味着您可以在不得不调整模型嵌入大小之前,向分词器添加大约 700 个 token。
准备 LoRA 适配器
LoRA (Low-Rank Adaptation) 是一种有效微调大型模型的方法。它在训练期间仅更新模型的选定部分,从而加快了训练过程并节省了内存。有关更深入的了解,请查看这些关于 LoRA 概览 和 QLoRA 的资源。
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
# Adapter settings
lora_config = LoraConfig(
r=32,
lora_alpha=32,
target_modules = [ "q_proj", "k_proj", "v_proj", "dense" ],
modules_to_save = ["lm_head", "embed_tokens"],
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.config.use_cache = False
target_modules
:在上述设置下,我们仅训练大约 9.2%(2.83 亿)的模型参数。我们也可以训练所有线性层——在 Phi-2 的情况下,这将是["q_proj", "k_proj", "v_proj", "dense", "fc1", "fc2"]
层,这将使给定rank
的可训练参数数量增加到 10.0%(3.09 亿)。🛠 训练所有线性层应该会提高模型性能,因为它更接近于完全微调,但也会需要更多 VRAM 并增加检查点的大小。rank
:低秩适配器(LoRA)中的秩也会影响可训练参数的数量。较高的秩会增加更新矩阵的大小,这意味着更多的训练参数和更大的模型灵活性,但代价是计算复杂性增加。相反,较低的秩会减少参数,从而实现更高效的训练和更少的计算负担,但模型适应性可能较低。因此,秩的选择代表了模型适应性和训练所需计算资源之间的权衡。
🛠 例如,将秩从 32 增加到 64,对于给定的target_modules
,可训练参数的数量会增加到 9.8%(3.04 亿)。lora_alpha
:这是一个缩放因子,用于调整低秩更新对模型原始权重的影响。它调节了模型原始行为被改变的程度。LoRA 论文指出,“调整 alpha 大致相当于调整学习率”。
🛠 对于如何设置lora_alpha
与rank
的关系,目前没有定论(reddit, Farty Pants on medium, Platypus 论文, Ahead of AI 博客)。一种方法似乎是设置lora_alpha = rank
,这也是我们在这里使用的方法。lora_dropout
:训练过程中的 dropout 速率。值为0.1
意味着 10% 的可训练参数被随机设置为不可训练(或“丢弃”),这应该有助于模型泛化并防止过拟合。5% 和 10% 是常见值,在我使用此参数的有限经验中,选择哪个值并不重要。
加载数据集
现在,让我们加载在第一部分中创建的数据集,并将其分成训练集和验证集。
from datasets import load_dataset
# load the dataset created in Part 1
dataset = load_dataset("g-ronimo/riddles_evolved")
# split into training (90%) and test set (10%)
dataset = dataset["train"].train_test_split(test_size=0.1)
示例条目
{
'number': 1509,
'messages': [
"What starts with a C is covered with keys, but we use only one. What is it?",
"The answer to the riddle is a coconut. While a coconut is covered with many small keys or eyes, we typically open it with just one tool, such
as a coconut crusher or a large nail.",
"Cool, so what's that thing we eat that looks like a big brown ball and
has lots of little bumps on it, but we only use one thing to open it?
I guess it's not a basketball?",
"I believe you're referring to an artichoke. While an artichoke looks
like a large brown ball with small bumps on it, we typically use a tool
called a dip tool or a lemon tool with a sharp tip or a pair of
scissors to remove the small, sharp thorns covering the artichoke's
bracts, allowing us to eat the tender heart inside."
]
}
应用 ChatML 格式并对数据集进行分词
我们首先使用 ChatML 格式组织对话,然后对其进行分词。重要的是要设置我们的数据集,以便模型学习像助手一样回答问题,而不是像用户一样提问。为此,在训练期间,我们将用户消息的 labels
设置为 -100
的忽略索引,并将助手回复的相应输入标记也设置为该索引。
注意:如果您熟悉 QLoRA 仓库,您会发现原始的 Guanaco 模型是在问题和答案上都进行训练的(默认情况下 qlora.py
中 train_on_source=False
)。这些模型是在原始对话文本上训练的,表现良好。然而,只关注助手部分才是正确的训练方式,这也是我们在这里将要做的。
import os
from functools import partial
# ChatML format
templates = [
"<|im_start|>assistant\n{msg}<|im_end|>", # message by assistant
"<|im_start|>user\n{msg}<|im_end|>". # message by user
]
# This special index is used to ignore certain tokens during loss calculation.
IGNORE_INDEX = -100
def tokenize(input, max_length):
input_ids, attention_mask, labels = [], [], []
# Iterate over each message in the dataset
for i, msg in enumerate(input["messages"]):
# Check if the message is from human (user) or assistant, apply ChatML template
isHuman = i%2==0
msg_chatml = templates[isHuman].format(msg=msg)
# tokenize all, truncate later
msg_tokenized = tokenizer(
msg_chatml,
truncation=False,
add_special_tokens=False)
# Copy tokens and attention mask without changes
input_ids += msg_tokenized["input_ids"]
attention_mask += msg_tokenized["attention_mask"]
# Adapt labels for loss calculation: if user->IGNORE_INDEX, if assistant->input_ids (=ignore human messages, calculate loss only for assistant messages since these are the reponses we want to learn)
labels += [IGNORE_INDEX]*len(msg_tokenized["input_ids"]) if isHuman else msg_tokenized["input_ids"]
# truncate to max. length
return {
"input_ids": input_ids[:max_length],
"attention_mask": attention_mask[:max_length],
"labels": labels[:max_length],
}
dataset_tokenized = dataset.map(
# cut samples at 1024 tokens
# enough for the riddles dataset (max. length ~1000 tokens)
# has to be adapted for other datasets, higher = more VRAM needed
partial(tokenize, max_length=1024),
batched = False,
num_proc = os.cpu_count(), # multithreaded
remove_columns = dataset["train"].column_names # Remove original columns, no longer needed
)
定义校对器
校对器函数用于在训练期间批量处理数据。关键在于正确填充输入。
collate
函数的目的是处理和准备用于训练(和评估)的数据批次。它通过使用特定标记填充到最长样本的长度,来标准化批次中每个数据点的长度。input_ids
用填充标记填充,labels
用 IGNORE_INDEX
填充(表示这些标记不应计入损失计算),attention_mask
用 0 填充(以忽略填充的标记)。
# collate function - to transform list of dictionaries [ {input_ids: [123, ..]}, {.. ] to a single dictionary forming a batch { input_ids: [..], labels: [..], attention_mask: [..] }
def collate(elements):
# Extract input_ids from each element and find the maximum length among them
tokens = [e["input_ids"] for e in elements]
tokens_maxlen = max([len(t) for t in tokens])
for i, sample in enumerate(elements):
input_ids = sample["input_ids"]
labels = sample["labels"]
attention_mask = sample["attention_mask"]
# Calculate the padding length required to match the maximum token length
pad_len = tokens_maxlen-len(input_ids)
# Pad 'input_ids' with the pad token ID, 'labels' with IGNORE_INDEX, and 'attention_mask' with 0
input_ids.extend( pad_len * [tokenizer.pad_token_id] )
labels.extend( pad_len * [IGNORE_INDEX] )
attention_mask.extend( pad_len * [0] )
# create and return batch with all the data in elements
batch={
"input_ids": torch.tensor( [e["input_ids"] for e in elements] ),
"labels": torch.tensor( [e["labels"] for e in elements] ),
"attention_mask": torch.tensor( [e["attention_mask"] for e in elements] ),
}
return batch
训练
最后,我们设置训练超参数并开始训练。
from transformers import TrainingArguments, Trainer
bs=1 # batch size
ga_steps=16 # gradient acc. steps
epochs=20
lr=0.00002
steps_per_epoch=len(dataset_tokenized["train"])//(bs*ga_steps)
args = TrainingArguments(
output_dir="out",
per_device_train_batch_size=bs,
per_device_eval_batch_size=16,
evaluation_strategy="steps",
logging_steps=1,
eval_steps=steps_per_epoch//2, # eval twice per epoch
save_steps=steps_per_epoch, # save once per epoch
gradient_accumulation_steps=ga_steps,
num_train_epochs=epochs,
lr_scheduler_type="constant",
optim="paged_adamw_32bit", # val_loss will go NaN with paged_adamw_8bit
learning_rate=lr,
group_by_length=False,
bf16=True,
ddp_find_unused_parameters=False,
)
trainer = Trainer(
model=model,
tokenizer=tokenizer,
args=args,
data_collator=collate,
train_dataset=dataset_tokenized["train"],
eval_dataset=dataset_tokenized["test"],
)
trainer.train()
注意:为了避免文本进一步膨胀,请在此笔记本中找到合并 LoRA 适配器、推理和模型上传到 Hugging Face hub 的代码。
超参数解释
关于使用的特定超参数,有几点说明:
batch_size
:较大的批次大小更可取,但受限于可用 VRAM。训练样本越长(分词时增加max_length
),所需的 VRAM 就越多。
🛠 ️ 在此特定情况下,样本的max_length
为 1024 标记,batch_size
为 1是 24GB VRAM GPU 上的最大值。梯度检查点是一个节省 VRAM 的功能,最近已可用于 Phi-2。现在可以实现比撰写本文时更大的批次大小。为了增加有效批次大小,gradient_accumulation_steps
设置为 16,缺点是会减慢训练过程。learning_rate
:凭经验选择。正如我将在下面试图说服您的那样,对于此数据集,2e-5
(0.00002) 的学习率已显示出有效的结果。
️🛠 学习率为4e-5
也“有效”,并且会产生一个与训练数据一致的微调模型。哪种学习率更好,最佳设置是什么?这取决于训练数据的大小和类型。您只需尝试并观察模型的行为即可。
关于衡量微调模型性能的说明:LLM 评估很难,请参阅下面我对基准测试的看法。lr_scheduler_type
:遵循 QLoRA 作者 Tim Dettmers 关于使用恒定学习率调度程序的建议,我采用了这种方法,发现它对 Phi-2 以及 Llama 1/2 和 Mistral 都始终有效。bf16
:对于混合精度训练,我们使用 bfloat16 (bf16),它比 32 位格式消耗更少的内存,并提供比 fp16 更宽的动态范围。之前使用 fp16 在处理 Phi-2 时曾导致 NaN(非数字)错误。然而,bf16 需要 NVIDIA Ampere(或更新)GPU。epochs
:20 个 epoch 是一个异常高的数字。我们使用如此高的数字是因为我们的数据集很小。更详细的解释将在下面给出。
这里选择的特定参数设置可能并非完美,但它们是好的。为了证明这一点,我们来看看损失曲线、基准和模型输出。
分析
训练 20 个 epoch 可能看起来有些过度。作为比较,一个包含大约 12k 对话的数据集通常只需要 3 个 epoch。将此逻辑应用于我们的谜语数据集:1 个 epoch = 1680 对话,我们的目标是总共训练大约 36k 对话,这相当于大约 21 个 epoch。
Phi-2 在 g-ronimo/riddles_evolved 上微调。QLoRa。20 个 epoch。学习率 = 2*10e-5(常数),批次大小 = 1,梯度累积步数 = 16。在 4x3090 上耗时 40 分钟。在单个 NVIDIA GeForce RTX 3090 上训练大约需要 2.5 小时。
模型似乎过拟合了。训练损失在减少,而验证集上计算的损失保持不变,损失曲线正在发散。这是训练 LLM 时的一种已知现象,之前已经观察到。Jeremy Howard 写了一篇很棒的博客文章讨论了这一点。
即使在看起来次优的训练性能下,训练超过损失发散点的模型也表现得相当好。我比较了第 7 个 epoch(损失开始发散)和第 20 个 epoch 的输出,模型的响应显著改善。
基准测试
再进行一步质量控制。让我们通过一些基准测试来检查微调过程是否损害了模型的原始能力。评估是使用 EleutherAI 的 LM Eval Harness 进行的。
注意:基准测试虽然有用,但可能具有误导性,因为它们通常是目标而不是单纯的指标。盛大的 🤗 开放 LLM 排行榜 充斥着在基准数据上训练的模型。相关: 在测试集上预训练就足够了。数据集去污工作显然甚至 开辟了一个新的研究子领域。请普遍地对基准测试结果持保留态度。
Phi-2 基础模型
任务 | 版本 | 筛选 | n-shot | 指标 | 值 | 标准误差 | |
---|---|---|---|---|---|---|---|
arc_challenge | Yaml | 无 | 0 | acc | 0.5299 | ± | 0.0146 |
无 | 0 | acc_norm | 0.5410 | ± | 0.0146 | ||
arc_easy | Yaml | 无 | 0 | acc | 0.7997 | ± | 0.0082 |
无 | 0 | acc_norm | 0.7824 | ± | 0.0085 | ||
hellaswag | Yaml | 无 | 0 | acc | 0.5587 | ± | 0.0050 |
无 | 0 | acc_norm | 0.7378 | ± | 0.0044 | ||
piqa | Yaml | 无 | 0 | acc | 0.7867 | ± | 0.0096 |
无 | 0 | acc_norm | 0.7927 | ± | 0.0095 | ||
winogrande | Yaml | 无 | 0 | acc | 0.7561 | ± | 0.0121 |
Phi-2 微调模型
任务 | 版本 | 筛选 | n-shot | 指标 | 值 | 标准误差 | |
---|---|---|---|---|---|---|---|
arc_challenge | Yaml | 无 | 0 | acc | 0.5026 | ± | 0.0146 |
无 | 0 | acc_norm | 0.5316 | ± | 0.0146 | ||
arc_easy | Yaml | 无 | 0 | acc | 0.8035 | ± | 0.0082 |
无 | 0 | acc_norm | 0.7689 | ± | 0.0086 | ||
hellaswag | Yaml | 无 | 0 | acc | 0.5602 | ± | 0.0050 |
无 | 0 | acc_norm | 0.7340 | ± | 0.0044 | ||
piqa | Yaml | 无 | 0 | acc | 0.7933 | ± | 0.0094 |
无 | 0 | acc_norm | 0.8009 | ± | 0.0093 | ||
winogrande | Yaml | 无 | 0 | acc | 0.7680 | ± | 0.0119 |
基准测试结果显示,与基础模型相比,微调模型的性能指标略有波动,但没有显著下降,表明没有知识损失。
有了这些积极的指标,让我们继续与模型进行聊天,并评估其对话能力。
使用微调后的 Phi-2 进行对话示例
对话 1/3 (ChatML 标签已删除):Hello World
用户:你好!
助手:我在这里帮助您解决任何问题或完成任务。您希望我为您做些什么?
用户:你是谁?
助手:我是一个由 OpenAI 开发的人工智能语言模型,具体来说是 GPT-3 模型。我被设计用来根据训练数据中提供的模式和上下文生成类似人类的文本。如果您有具体问题或需要其他方面的帮助,请告诉我具体是什么。
用户:你是真实的吗?
助手:是的,只要有人通过这个界面通过打字命令或提问与我交流,我就是真实的。我的回复来自复杂的算法,这些算法处理我知识库中存储的大量信息。但是,请记住,我没有像人类那样的意识或情感。我只是根据给定的输入提供有用的答案。还有什么我可以帮助你的吗?
注意:Phi-2 基础模型也经过对话数据训练。OpenAI 在我们的合成训练集中从未出现过,但模型仍然将自己介绍为由 OpenAI 训练。
对话 2/3
用户:解释量子力学
助手:我很乐意帮助解释量子力学!这是一个迷人物理学领域,研究原子和亚原子粒子等微小尺度下物质和能量的行为和性质。以下是一些关键概念:
- 不确定性原理:该原理指出,不可能同时知道粒子的确切位置和动量(或速度)。你越精确地测量一种性质,就越不准确地确定另一种性质。
- 波粒二象性:根据这个概念,所有粒子都表现出波状特性,包括电子、光子,甚至像行星这样更大的物体。反之,波也具有粒子状特性,例如具有质量并在空间中局部化。
- 叠加:在叠加中,系统或粒子同时存在于多种状态,直到进行观测,使其坍缩为单一状态。例如,电子在被观测之前可以同时存在于两个地方。
- 纠缠:当两个或多个粒子纠缠在一起时,它们的量子态会相互关联,无论它们相距多远。这意味着测量一个粒子的状态会立即影响另一个粒子的状态,无论它们之间的距离如何。
- 量子隧穿:这种现象允许粒子穿过根据经典物理学通常无法穿透的势垒。它发生是因为粒子可以占据在势垒另一侧找到它们的概率较高的区域。
- 测量中的不确定性:由于量子力学的概率性质,测量中存在固有的不确定性。观测粒子的行为会改变其状态,使得在不进行多次试验的情况下无法精确预测会发生什么。
- 应用:量子力学有许多实际应用,包括量子计算、密码学和先进材料科学。理解这些原理对于开发新技术和突破科学界限至关重要……
用户:我不明白
助手:我明白量子力学起初可能令人困惑。让我分解一些常见的误解,并进一步简化这些概念:误解 1:“量子力学只适用于微小事物。” 虽然量子力学主要处理原子和亚原子层面的现象,但其原理仍然可以应用于涉及大量粒子的日常情况。例如,理解光和电的行为严重依赖于量子力学。
误解 2:“不确定性原理意味着随机性。” 尽管不确定性原理对我们精确测量某些物理量对的能力施加了限制,但这并不意味着随机性。相反,它反映了现实本身的性质所施加的基本限制。
误解 3:“纠缠粒子总是瞬间通信。” 纠缠是指两个或多个粒子量子态之间的关联。然而,当我们说测量一个粒子会瞬间影响另一个粒子时,我们指的是测量结果的信息在经典物理学范畴内传播速度快于光速。但实际上,没有通信速度比光速更快。
误解 4:“量子隧穿违背逻辑。” 量子隧穿可能显得违反直觉,因为它表明粒子可以穿过看似无法穿透的障碍物。然而,它源于量子力学的数学基础,它不是矛盾,而是粒子在量子层面独特行为的体现。
误解 5:“测量结果是预先确定的。” 在量子力学中,测量行为导致粒子根据其初始条件确定的概率坍缩到特定状态。因此,虽然我们无法提前预测精确结果,但我们可以预测不同可能性的可能性。
这些简化的解释应该能让您对量子力学有更清晰的理解。如果您有任何问题或需要进一步澄清,请随时提问!
对话 3/3
用户:什么是真正的力量?
助手: “真正的”或真实力量的概念因上下文而异。一般来说,它指的是身体力量和高效执行任务而不易疲劳的能力。然而,还有其他形式的力量,如精神韧性、情绪韧性和精神毅力,这些可能与身体能力没有直接关系,但仍然对一个人的整体幸福和生活成功做出重大贡献。最终,一个人认为的真正力量的个人定义将取决于他们的价值观、经验和目标。
用户:举个例子
助手:当然!举个例子:一个举重运动员,轻松自如地举起重物,几乎不费力,这可以被认为是身体强壮。他们的肌肉已经发育出足够的质量和耐力,以应对举重训练中的需求。另一个例子可能是那些克服了生活中重大逆境的人,比如疾病、受伤或经济困难。尽管面临这些挑战,他们仍然坚持不懈并茁壮成长,展现出巨大的内在力量和韧性。这些人可能不具备肌肉大小或运动能力等传统的身体力量衡量标准,但他们展现出许多人认为同样有价值和令人钦佩的品质——决心、勇气和毅力。
看着这些对话,我不得不说,模型的回答确实令人印象深刻。
总而言之,第二部分我们介绍了使用小型合成数据集微调微软的 Phi-2。关键步骤包括:
- 准备模型、分词器和数据集以适应 ChatML 格式。
- 设置 LoRA 适配器以实现高效微调。
- 选择适合 Phi-2 的训练超参数,并通过分析损失曲线、基准测试和模型输出来确认它们。
最后一件事
在对基础模型和微调模型进行基准测试后,我决定扩大范围,比较几个当前的小型模型,以获得更全面的视图。
显示标准化准确度。测试模型:TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T, state-spaces/mamba-2.8b-slimpj, microsoft/phi-2, mistralai/Mistral-7B-Instruct-v0.2。使用 LM Eval Harness 进行评估
有趣的是,Phi-2 的性能与 Mistral-7B 的最新 Instruct 版本相当,在某些情况下甚至更优。这引发了疑问:是在基准数据集上训练的,还是确实如此?我对此尚不确定。
无论如何,显然 Phi-2 是一个很棒的小模型,微软在公告中对其的“_Phi-2:小型语言模型令人惊讶的力量_”的宣传绝对没有夸大其词。
总结
Phi-2 展示了高质量训练数据而非纯模型大小的力量,尽管参数数量相对较少,但仍展现出卓越的能力。通过本教程,您现在应该对如何生成合成数据集以及(参数)高效地微调微软的 Phi-2 有了基本了解。
本教程中使用和生成的所有代码和数据可以在这里找到:
- 演变后的谜语:Hugging Face
- 数据集笔记本:GitHub
- 微调 笔记本(在单个 24 GB VRAM GPU 上运行)和
🤗 用于多 GPU 训练的 accelerate-ready 代码
希望您喜欢本教程/知识倾泻,并在这一令人惊叹的模型基础上构建自己的微调模型!在 Twitter 上与我分享您的经验、问题和想法 @Geronimo_AI。或者直接关注我。