在本系列中,我将介绍使用开源 LLM 为此目的构建轻量级助手所需的步骤。在这里,"轻量级 "指的是模型的训练和推理分别需要 16GB 和 8GB 的 GPU RAM,并且在需要时可以在 CPU 上高效运行。为此,我将使用 Llama-2-7b-hf-chat、Zephyr-7b-beta 和 OpenChat-3.5-0106,它们都符合这一描述。
ChatGPT-3.5-Turbo 基线
为了对这项任务有所了解,我们将首先使用 ChatGPT 实现它。这将为我们提供一个强大模型的参考点,并估算出任务的难度。
首先,我们需要定义助手选择保持沉默的机制。为此,我们将指示模型返回"(沉默)"作为其响应。这样的预测可以在后处理时进行过滤。另一种方法是要求模型返回一个空预测,但从轶事来看,这对某些模型似乎并不可靠(它们不习惯保持沉默!)。
在第二个方面,OpenAI 的应用程序接口可以方便地让我们为对话中的每条信息提供参与者的姓名,遗憾的是,这对于常见的开源模型来说并不适用(在这种情况下,我们需要一个变通办法),但对于 ChatGPT 来说,我们应该没问题。
现在还剩下一个关键的决定: 提示符。对于我们的用例,我特意选择了简短而准确的提示(如果最终回复的语气不对,可以随时调整):
You are an assistant in a group conversation between multiple users.
Your task is to help with relevant information or when directly asked.
Do not be overzealous. If you do not have anything important to say,
respond with "(silence)".
现在我们已经拥有了所需的一切,让我们试一试吧。使用本笔记本中实现的聊天循环,我们可以得到以下对话:
最初的结果虽不完美,但也令人鼓舞: 助手偶尔会选择保持沉默(遵守说明中的格式),或者提供有用的信息,但有时也会进行不必要的闲聊。将提示改为:
You are an assistant in a group conversation between multiple users.
Your task is to help with relevant information or when you are directly
addressed as "assistant". Do not be overzealous, remember that most of
the time the users will be speaking to each other, not to you. If you
do not have anything important to say, respond with "(silence)".
并在每条用户信息后插入该提醒系统信息:
Remember that the users are most likely to be speaking to each other,
not to you. If you do not have anything important to say, respond with
"(silence)".
从这次对话中可以看出,这似乎没有什么大的区别:
如果在提示方面做更多的工作,模型的性能很可能会得到显著提高,但目前这对我们的目的来说已经足够了: 我们有了一个可以比较的基线,同时也表明这个问题即使不是微不足道,也是可以解决的。
开源模型和微调
我们已经看到,尽管有一些小问题,ChatGPT-3.5-Turbo 仍能在群组对话中扮演半主动参与者的角色。遗憾的是,7B 参数类的常见开源模型却并非如此,它们最终会动不动就做出回应。幸运的是,开源 LLM 的好处在于,我们可以通过微调使其适应我们的任务。
值得指出的是,微调并不适用于所有情况。例如,如果你想教一个模型新的事实,微调就不是合适的工具(更好的方法是检索增强生成)。但是,如果你想改变回答的语气或格式(就像我们在这里所做的),微调正是你所需要的。
数据集生成
微调的一个关键决定因素是数据集。我们需要提供一组多用户对话的良好示例,在这些对话中,助手基本上保持沉默,但偶尔也会提供一些有用的信息。为了快速引导这样的数据集,我使用了托管在 replicate.com 上的 Mixtral-8x7B-Instruct-v0.1。具体来说,我使用该提示生成了 50 个合成对话:
Generate a conversation representing a chat between two users.
The users are Cynthia and Fred and they are discussing potential
Christmas gifts for friends. An assistant chimes in when it can fill
in trivia, otherwise it remains silent. The conversation should have
between 10 and 12 turns. Return the conversation in a JSON format,
like this:
[
{
"role": "user",
"name": "Alice",
"content": "Hi Grace! How are you?"
},
{
"role": "user",
"name": "Grace",
"content": "I'm good, how about you?"
},
{
"role": "user",
"name": "Alice",
"content": "Doing fine as well. I've been reading a book by the author of the Da Vinci code. Sorry, forgot his name"
},
{
"role": "assistant",
"content": "That’s Dan Brown! He also authored a few other books, for example \"Angels & Demons\" and \"Inferno\"."
}
]
很明显,这样得到的数据集质量不高,也没有经过精心策划,因此不建议将其用于生产模型。我将在随后的文章中讨论一些提高数据集质量的方法,以及评估结果模型的方法。不过,这个数据集对于我们现在的目的来说已经足够好了,那就是验证一个小型模型是否可以用于多用户聊天助手。
这里有数据集生成笔记本,生成的数据集已上传到 HuggingFace 存储库。下面是生成的对话框示例:
关于聊天模板的注意事项
使用预训练聊天模型时,最好确保输入格式与模型训练时的格式一致。随着 2023 年 9 月 HuggingFace 引入标记化器的 apply_chat_template 方法,这一点变得更加容易。该方法负责将用户、系统和助手的各种提示和回复格式化为模型所需的格式。
遗憾的是,并非所有模型都已更新为聊天模板,因此我建议检查每个模型的 apply_chat_template 输出,并与模型文档进行比较。
在微调的情况下(而不是仅仅使用现成的模型进行推理),我们不一定要遵循规定的格式。事实上,对于非聊天模型,定义自己的聊天模板是必要的。不过,对于聊天模型来说,坚持使用现有的聊天模板可能会让微调任务变得更容易,从而减少训练步骤,降低出现不必要的副作用(比如灾难性遗忘)的可能性。
对于我们选择的 Zephyr、Llama-7b-chat 和 OpenChat-3.5 模型,我们很幸运:它们都正确定义了聊天模板,并且 apply_chat_template 也能正常工作。
微调
现在我们准备开始微调。如前所述,我们的目标是在 16GB 的 GPU 内存中完成训练,使其能够在单个 T4 GPU 上运行(无需寻找超稀有的神奇宝贝......呃,我是说 A100)。为此,我们将使用 4 位量化和 LoRA。
在开始训练之前,我们需要对之前创建的合成数据集稍作修改:
数据集预处理过程如下:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(HF_BASE_MODEL_NAME, use_fast=False)
from datasets import Dataset
from huggingface_hub import hf_hub_download
import json
def build_dataset():
local_filename = hf_hub_download(
repo_id=HF_DATASET_NAME,
filename=HF_DATA_FILE_NAME
)
with open(local_filename) as f:
conversations = f.readlines()
result = []
for conversation in conversations:
lines = json.loads(conversation)
transformed_lines = []
idx = 0
while idx < len(lines):
assert lines[idx]['role'] == 'user'
transformed_lines.append({
'role': 'user',
'content': f"{lines[idx]['name']}: {lines[idx]['content']}",
})
idx += 1
if idx == len(lines) or lines[idx]['role'] != 'assistant':
# Insert artificial (silence) response
transformed_lines.append({
'role': 'assistant',
'content': '(silence)',
})
else:
transformed_lines.append({
'role': 'assistant',
'content': f"(response) {lines[idx]['content']}",
})
idx += 1
result_row = {
'text': tokenizer.apply_chat_template(tokenize=False, conversation=transformed_lines)
}
result.append(result_row)
return result
dataset = Dataset.from_list(build_dataset())
请注意,这里不包括系统提示。原因是我们正在为这一特定任务对模型进行微调,因此向模型提供说明是多余的: 它可以从训练中学习应该做什么。这样做的好处是,既缩短了训练时间,又能加快推理速度。
完成数据集的准备工作后,我们现在加载量化模型:
import torch
from transformers import AutoModelForCausalLM
torch_compute_type = torch.bfloat16 if USE_BFLOAT16 else torch.float16
model = AutoModelForCausalLM.from_pretrained(
active_config['base_model_name'],
torch_dtype=torch_compute_type,
bnb_4bit_quant_type='nf4',
bnb_4bit_compute_dtype=torch_compute_type,
load_in_4bit=True,
device_map={'':0},
trust_remote_code=True,
use_cache=True
)
然后,我们定义适配模型(即与基础模型的低等级 "差异"):
from peft import LoraConfig, get_peft_model
peft_config = LoraConfig(
lora_alpha=16,
lora_dropout=0.1,
r=64,
bias="none","none",
task_type="CAUSAL_LM",
)
# Note: This is needed for Zephyr, otherwise we get this:
# RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn
model.enable_input_require_grads()
peft_model = get_peft_model(model, peft_config)
并将训练器和训练参数实例化:
from transformers import TrainingArguments
output_dir = "peft_model"
# These arguments (LR, gradient norm, etc.) seem to be fairly frequently
# used for QLoRA. Default arguments work too, but require about 50% more
# epochs. Also tried optim='lion_32bit' out of curiosity, the result was
# pretty much the same as the default (AdamW), but each epoch was 30-40%
# slower.
training_args = TrainingArguments(
output_dir=output_dir,
num_train_epochs=TRAIN_EPOCHS,
per_device_train_batch_size=4,
gradient_accumulation_steps=2,
gradient_checkpointing=True,
logging_steps=1,
bf16=USE_BFLOAT16,
#optim='lion_32bit',
learning_rate=2e-4,
max_grad_norm=0.3,
warmup_ratio=0.03,
lr_scheduler_type="constant",
)
上面使用的设置是相当标准的(我鼓励你根据需要进行调整)。真正重要的是历元数、学习率和批次大小。以上是对我有用的特定配置,可能是一个很好的起点,但显然不能替代真正的超参数搜索。
现在,我们可以实例化训练器并开始训练了:
from trl import SFTTrainerimport SFTTrainer
max_seq_length = 1024
trainer = SFTTrainer(
model=peft_model,
train_dataset=dataset,
peft_config=peft_config,
max_seq_length=max_seq_length,
tokenizer=tokenizer,
args=training_args,
dataset_text_field='text',
)
trainer.train().train()
很快,在 T4 上只用了 8 分钟!让我们使用与 OpenAI API 案例相同的笔记本创建一个对话管道和循环,测试一下它的性能如何。下面是一个使用 OpenChat-3.5-0106 微调模型的对话示例:
这是非常令人鼓舞的:该模型遵循我们的格式要求,似乎在何时插话、何时保持沉默方面做出了合理的决定。
仅完成培训
首先,为什么我们要关心不教模型预测用户消息呢?可以从隐私的角度提出一个论点:如果真实的对话被用作训练数据,那么最终用户可能会说服模型泄露一些用户消息(无论如何,助手的响应也可能包含敏感信息)。第二个论点:尝试预测用户消息是不必要的,结果会造成浪费。这可能意味着你需要训练更长的时间才能得到好的结果,从而冒着不必要的副作用(再次强调,这主要是灾难性的遗忘)。
根据你的用例,这两个参数可能都是没有意义的,并且模型可能在上面描述的训练过程中做得很好。然而,如果不是,或者你只是好奇,我鼓励你继续读下去。
HuggingFace的trl库为我们提供了一个解决这个特殊问题的工具,它被实现为DataCollatorForCompletionsOnlyLM。这个排序器将表示用户消息的令牌的标签更改为“忽略”标签,这意味着模型没有经过训练来预测它们。当然,用户消息仍然用作预测助手消息的上下文。
DataCollatorForCompletionsOnlyLM需要我们传递两个字符串,它可以使用这两个字符串来查找用户消息(instruction_template参数)和助手消息(response_template)的开始。我们可以通过检查apply_chat_template的输出来找到它们:对于Zephyr,它们是“<|user|>”和“<|assistant|>”,对于Llama,它们是“[INST]”和“[/INST]”。让我们试一下:
trainer.data_collator = DataCollatorForCompletionOnlyLM(
response_template="<|assistant|>","<|assistant|>",
instruction_template="<|user|>",
tokenizer=tokenizer
)
trainer.train()
### Output:
# UserWarning: Could not find response key `<|assistant|>` in the following instance: [...] This instance will be ignored in loss calculation. Note, if this happens often, consider increasing the `max_seq_length`.
哦,这看起来很糟糕。本质上,训练器不能找到我们的模板片段,因此忽略了我们所有的样本。根据前面的上下文,像“<|user|>”这样的字符串可以有不同的标记化表示。幸运的是,DataCollatorForCompletionsOnlyLM允许我们传递这些分隔符字符串的标记化版本,而不是文字版本。为了找到这些标记化的版本,我们可以检查一个聊天模板的标记化输出:
conversation = [
{ 'role': 'user', 'content': "hi!" },
{ 'role': 'assistant', 'content': "Hello!" }
]
for token in tokenizer.apply_chat_template(conversation):
print(f"Token Id: {token}, Value: '{tokenizer.decode([token])}'")
### Output
# Token Id: 523, Value: '<'
# Token Id: 28766, Value: '|'
# Token Id: 1838, Value: 'user'
# Token Id: 28766, Value: '|'
# Token Id: 28767, Value: '>'
# Token Id: 13, Value: '
# '
# Token Id: 5365, Value: 'hi'
# Token Id: 28808, Value: '!'
# Token Id: 2, Value: '</s>'
# Token Id: 28705, Value: ''
# Token Id: 13, Value: '
# '
# Token Id: 28789, Value: '<'
# Token Id: 28766, Value: '|'
# Token Id: 489, Value: 'ass'
# Token Id: 11143, Value: 'istant'
# Token Id: 28766, Value: '|'
# Token Id: 28767, Value: '>'
# Token Id: 13, Value: '
# '
# Token Id: 16230, Value: 'Hello'
# Token Id: 28808, Value: '!'
# Token Id: 2, Value: '</s>'
# Token Id: 28705, Value: ''
# Token Id: 13, Value: '
# '
从输出中我们可以推断出“<|assistant|>”被标记为[28789,28766,489,11143,28766,28767],而“<|user|>”被标记为[28789,28766,1838,28766,28767]。我在下表中包含了一些常见模型的标记化序列。
有了这个在手,我们现在可以使用更新的数据整理器重试训练:
response_template = [28789, 28766, 489, 11143, 28766, 28767]
instruction_template = [28789, 28766, 1838, 28766, 28767]
trainer.data_collator = DataCollatorForCompletionOnlyLM(
response_template=response_template,
instruction_template=instruction_template,
tokenizer=tokenizer
)
trainer.train()
这消除了警告,训练损失开始减少。我们现在可以等待模型训练完成并将模型上传到HuggingFace Hub。
peft_model.push_to_hub(active_config['finetuned_model_name'])
tokenizer.push_to_hub(active_config['finetuned_model_name'])
冒烟测试
现在让我们通过运行这个笔记本(可以使用消费级8GB GPU在本地执行)来看看模型在实践中是如何运行的。这里是一个例子对话,再次从OpenChat-3.5-0106微调模型: