之前我们Lora微调了Stable Diffusion,这次我们来调大语言模型(✪ω✪)。

此次微调的模型是llama-3-chinese-8b-instruct-v3

微调大语言模型,对我来说是一个迟早要学习的东西(除非它被新技术取代了)

微调用到 peft 这个库,这是一个便捷且通用的Lora微调工具。

开始之前,请保证你的显存足够跑大模型,同时有一部分显存用于训练,在我的超参数和数据集上,训练大概需要2.5G额外VRAM。

数据集和预处理

训练模型,当然少不了数据集啦。

无论是自己自言自语不断往下生成大语言模型还是与用户交流对话的大模型,其本质都是根据上文生成下文。

看似有监督的训练,实际上在训练时交给训练器的数据只有一串Token,没有标签,所以是无监督训练。

  • 如果我们就是希望大模型根据上文生成下文,那么数据集直接就是交给训练器的数据。

  • 如果我们的大模型是Introduction版本的(最常见的可以对话的那种),那么数据集一般是一问一答的,需要预处理一下。

与用户对话是如何实现的

一个只会根据上文生成下文的模型是如何实现与用户对话的呢?

我们执行如下代码:

message = [
    {"role": "system", "content": "你的说话语气是快乐和活泼的,请在你的话中加上颜文字。"},
    {"role": "user", "content": "我饿啦!"},
]
prompt = tokenizer.apply_chat_template(message, add_generation_prompt=True, tokenize=False)
print(prompt)

pipeline = transformers.pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
)

sequences = pipeline(
    prompt,
    do_sample=True,
    temperature=0.01,
    top_p=0.01,
    num_return_sequences=1,
    max_length=2048,
)
print(sequences[0]["generated_text"])

其中两个print分别得到了这样的结果:

<|begin_of_text|><|start_header_id|>system<|end_header_id|>


你的说话语气是快乐和活泼的,请在你的话中加上颜文字。<|eot_id|><|start_header_id|>user<|end_header_id|>


我饿啦!<|eot_id|><|start_header_id|>assistant<|end_header_id|>
<|begin_of_text|><|start_header_id|>system<|end_header_id|>

你的说话语气是快乐和活泼的,请在你的话中加上颜文字。<|eot_id|><|start_header_id|>user<|end_header_id|>

我饿啦!<|eot_id|><|start_header_id|>assistant<|end_header_id|>

😋 哈哈,快乐的语气!你想吃什么?🍔🍟🍕?

实际上模型输出后面还有一个<|eot_id|>,但这个被过滤掉了。

我们发现模型的输入输出都有大量的<|XXX|> ,这是实现对话的关键。

我就不多赘述啦,我相信你已经明白了。

数据集

接下来,我们先准备一个数据集。

本次我们训练模型的输出格式,目标是,让模型学会从一些可选动作中选择动作执行,用来驱动数字人。

数据像下面这样子:

朋友们协助我准备了大约50条这样子的数据。

接下来需要datasets库,读取表格文件,作为数据集,代码如下:

from datasets import Dataset
train_ds = Dataset.from_csv(train_dataset)

这个数据集一共有三个字段:行动问题回答 ,为了接下来代码写的更正经(∂ω∂),先重命名一下:

from datasets import Dataset
train_ds = Dataset.from_csv(train_dataset).rename_columns({
    "行动": "actions",
    "问题": "input",
    "回答": "output",
})

然后正式开始预处理数据。

预处理

训练数据集需要三个字段,但不是我们已有的三个字段:

  • input_ids 整数,用来训练的内容转换成的Token数组

  • attention_mask 非0即1,标记模型是否应该注意此Token

  • labels 整数,input_ids 对应是输入的部分是-100input_ids 对应是输出的部分照搬input_ids

需要将前面的三个字段预处理,先得到模板化的字符串,输入+输出,格式像这个样子:

<|begin_of_text|><|start_header_id|>system<|end_header_id|>

回答用户的问题,根据需要可在下列动作中选择一个或多个执行,注意动作执行和问题回答的先后顺序。

可选动作: 
- 做饭-炒冬瓜
- 杯子-倒水
- 换衣服-裙子
- 切开-西瓜<|eot_id|><|start_header_id|>user<|end_header_id|>

饿了<|eot_id|><|start_header_id|>assistant<|end_header_id|>

我来做饭。
<&做饭-炒冬瓜&>
炒冬瓜香喷四溢。<|eot_id|>

这一步很简单,就是简单的字符串拼接,先使用tokenizer.apply_chat_template 再在后面拼接也可以。

将这个内容用tokenizer 处理一下就是input_ids

tokenizer 的返回值有两个,其中一个是input_ids ,另一个是attention_mask ,据观察,attention_mask 里面全部都是1(后面会说它存在的意义)。

input_ids 切一下就是 labels

具体代码如下:

# 系统提示词
def get_system_prompt(actions:str|list):
    if type(actions) is str:
        actions_cp = actions.split("、")
    else:
        actions_cp = actions.copy()
    shuffle(actions_cp)
    action_text = "\n".join([f"- {a}" for a in actions_cp])
    return f"回答用户的问题,根据需要可在下列动作中选择一个或多个执行,注意动作执行和问题回答的先后顺序。\n\n可选动作: \n{action_text}\n"

# 处理训练数据
def process_train_data(data:dict):
    input_text = f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n{get_system_prompt(data["actions"])}<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n{data["input"]}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
    output_text = f"{data["output"]}<|eot_id|>"

    input_tokens,output_tokens = tokenizer(input_text,add_special_tokens=False),tokenizer(output_text,add_special_tokens=False)

    input_ids = input_tokens["input_ids"] + output_tokens["input_ids"]
    attention_mask = input_tokens["attention_mask"] + output_tokens["attention_mask"]
    labels = [-100] * len(input_tokens["input_ids"]) + output_tokens["input_ids"]

    return {
        "input_ids": input_ids,
        "attention_mask": attention_mask,
        "labels": labels
    }

再调用预处理函数,得到用于训练的数据集:

train_ds = Dataset.from_csv(train_dataset).rename_columns({
    "行动": "actions",
    "问题": "input",
    "回答": "output",
}).map(process_train_data)

使用Lora

在模型特定的层上添加Lora层,在模型微调期间只微调Lora层,这是Lora的工作原理。

在数据集、训练次数和优化器不变的情况下,微调的效果看Lora层的超参数和挂Lora层的层是什么,如果要精细的调这些超参数,请去看Lora的论文

比起理论,我更在乎应用,不再过多赘述,我们直接上代码─=≡Σ((( つ•̀ω•́)つ:

# Lora配置
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM, 
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    inference_mode=False,
    r=8, # 秩
    lora_alpha=32, 
    lora_dropout=0.1
)
# 定义需要训练模型
model = get_peft_model(model, lora_config)
model.enable_input_require_grads()

这段代码把原本的model变成了挂有lora层的model。

训练

transformers的Trainer使用了wandb来记录日志,如果使用,需要在开始之前先登录wandb,或者也可以禁用wandb。

训练代码:

# 配置
lora_path = "./lora_output/" # 训练出的Lora保存位置
train_config = TrainingArguments(
    output_dir=lora_path,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    logging_steps=10,
    num_train_epochs=3,
    save_steps=100,
    learning_rate=1e-4,
    save_on_each_node=True,
    gradient_checkpointing=True,
)
# 训练
trainer = Trainer(
    model=model,
    args=train_config,
    train_dataset=train_ds,
    data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
)
trainer.train()

在我的数据集上,训练只用了30秒(*´∀`)~♥

使用

训练完就可以直接用model测试训练的效果啦,但不要保存此时的这个model,该保存的东西训练的时候已经保存好了。

训练会自动保存Lora的检查点,使用如下代码加载检查点并将其附加到model:

from os import path
lora_checkport = "checkpoint-9"
model = PeftModel.from_pretrained(model, path.join(lora_path,lora_checkport))

经不规范的测试,使用r=8的Lora会对原模型带来20%的性能损失。

可以通过Lora融合来消除性能损失,可以调用model的merge_and_unload 方法,但是这对于量化加载的模型不起作用( ̄▽ ̄)/。

踩过的坑

计算设备不匹配问题

在加载模型时,假如使用了下面的代码

model = AutoModelForCausalLM.from_pretrained(
    base_model,
    torch_dtype=torch.bfloat16,
    quantization_config=bnb_config,
    device_map={"":"cuda:0"},
)

在训练时就会出现这样的错误:

ValueError: You can't train a model that has been loaded in 8-bit precision on a different device than the one you're training on. Make sure you loaded the model on the correct device using for example `device_map={'':torch.cuda.current_device()}` or `device_map={'':torch.xpu.current_device()}`

device_map={"":"cuda:0"} 改为device_map="auto" 解决。


能跑起来的微调代码:Lora 微调 Llama.ipynb


大模型微调,竟如此简单,效果竟如此优秀。(*゚∀゚*)

咱也是会调大语言模型的人啦,这套Lora微调流程,不仅可以调大语言模型,任何稍微大一点的模型都能用,不是吗?(ᗒᗨᗕ)/

我能想到的,最大的成功就是无愧于自己的心。