1、微调
特定任务或领域上进一步训练大型语言模型(LLM)的过程。这可以通过使用预训练的LLM作为起点,然后在特定任务或领域的标记数据集上训练它来完成。
1.1、常见的几种微调方式
- 全模型微调(Fine-Tuning the Entire Model):
这种方法是对整个预训练模型进行微调,包括所有层的权重。这种方式通常需要较大的计算资源和时间,但可以获得较好的性能,特别是在目标任务与预训练任务有较大差异时。
- 低秩自适应(Low-Rank Adaptation,LoRA):
通过引入低秩矩阵来近似调整模型权重,从而减少可训练参数的数量,降低计算成本。lora在aigc领域用的比较多,比如最常见的stable-diffusion,它是一个基于文本生成图片的模型,而sd当中的lora模型,常常用来控制角色的画风、动作、姿态等等。
- 量化(Quantization):
对模型参数进行量化,减少模型的大小和内存占用,例如使用4位量化技术,降低模型的存储需求。
- P-Tuning 和 P-Tuning v2:
使用可训练的LSTM模型动态生成虚拟标记嵌入,通过调整这些嵌入来微调模型。
1.2、环境准备
// 需要注意的是,Qwen项目中提供的finetune.py依赖DeepSpeed 和 FSDP。基于CUDA,微调的设备必须拥有GPU,因此我的本地cpu环境不选择此微调脚本的方法。或者说大多数微调脚本的方法
我使用transformers框架的Trainer训练类。环境准备无须进行其他操作。
2、设计实施
2.1、数据准备与编码
首先,我采用了第一章节使用过的多元分类数据集:今日头条文本分类数据集 / 数据集 / HyperAI超神经
2.1.1、 处理数据集
原始数据集中,每行数据是按照_!_进行分割的,因此提取标题、分类结果,拼接提示词,获得我们想要的query和answer,保存在一个临时文件中。
# 提示词
system_prompt = "你是一个专门分类新闻标题的分析模型。你的任务是判断给定新闻短文本标题的分类。"
user_prompt = ("请将以下新闻短文本标题分类到以下类别之一:故事、文化、娱乐、体育、财经、房产、汽车、教育、" "科技、军事、旅游、国际、股票、农业、游戏。输入如下:\n{}\n请填写你的输出:")
# 字段映射
type_list = ["故事", "文化", "娱乐", "体育", "财经", "房产", "汽车", "教育", "科技", "军事", "旅游", "国际", "股票", "农业", "游戏"] type_map = {"news_story": "故事", "news_culture": "文化", "news_entertainment": "娱乐", "news_sports": "体育", "news_finance": "财经", "news_house": "房产", "news_car": "汽车", "news_edu": "教育", "news_tech": "科技", "news_military": "军事", "news_travel": "旅游", "news_world": "国际", "stock": "股票", "news_agriculture": "农业", "news_game": "游戏"}
# 处理训练集
def dataset_jsonl_transfer(origin_path, new_path):
messages = []
# 读取旧的JSONL文件
with open(origin_path, "rb") as file:
for line in file:
# 解析每一行的json数据
# 按照_!_分割line字符串
parts = line.decode("utf-8").split("_!_")
title = parts[3]
title_type = type_map.get(parts[2])
print(f"title {title}, type {title_type}")
message = {
"query": user_prompt.format(title),
"answer": title_type,
}
messages.append(message)
# 保存重构后的JSONL文件
with open(new_path, "w", encoding="utf-8") as file:
for message in messages:
file.write(json.dumps(message, ensure_ascii=False) + "\n")
2.1.2、 数据预处理
接下来进行数据预处理。因为第一章中我们都知道,需要使用tokenizer来对自然语言文本分词转成token。不同的llm大模型使用的分词方式和结果可能都不完全一样。我们在分词、推理、微调使用同一个大模型的分词即可。
tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False)
数据预处理中的三个主要概念:
| 定义 | 作用 | 示例 | |
|---|---|---|---|
| input_ids | 一个整数列表,表示输入文本的token化结果。每个token(词或子词)都被映射到一个唯一的整数ID,这些ID是模型词汇表中的索引。 | 模型的主要输入,模型通过这些ID来理解和处理文本数据。它们告诉模型要处理哪些具体的词或子词。 | 假设输入文本是 "Hello, world!",经过tokenization后,可能会得到 input_ids 为 [101, 7592, 1010, 2088, 102],其中每个数字对应于词汇表中的一个token。 |
| attention_mask | 一个二进制列表,指示模型在处理输入时应该关注哪些token。通常,1表示该token是有效的,0表示该token是填充的(padding)。 | 注意力掩码,使模型能够区分有效的输入token和填充token,从而避免在计算注意力时将填充部分纳入考虑。这对于处理变长输入非常重要。 | 对于输入文本 "Hello, world!",如果填充到最大长度为10,attention_mask 可能是 [1, 1, 0, 0, 0, 0, 0, 0, 0, 0],表示前两个token是有效的,后面的都是填充。 |
| labels | 一个整数列表,表示模型在训练过程中需要预测的目标值。通常,labels 中的值对应于 input_ids 中的token,指示模型应该学习预测哪些token。 | 对于生成任务,labels 通常是目标文本的token ID。其他情况,例如在监督学习中,labels 用于计算损失函数,指导模型学习;对于分类任务,labels 可能是类别的ID。 | 如果目标是生成回答 "Hi there!",则 labels 可能是 [102, 1234, 5678, 102],其中每个数字对应于目标文本的token ID。 |
# 数据预处理
def process_func(example):
MAX_LENGTH = 256
input_ids, attention_mask, labels = [], [], []
instruction = tokenizer(f"{example['query']}")
response = tokenizer(f"{example['answer']}")
input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
attention_mask = (
# 加[1]的目的是对应[tokenizer.pad_token_id],一个句子的结尾符
instruction["attention_mask"] + response["attention_mask"] + [1]
)
# 用于监督学习的标签,这里将查询部分的标签设置为-100(表示忽略),因为查询部分不是我们需要模型学习的部分,只有回答部分是模型需要预测的。
labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]
# 如果矢量长度超过最大长度,则进行截断
if len(input_ids) > MAX_LENGTH:
input_ids = input_ids[:MAX_LENGTH]
attention_mask = attention_mask[:MAX_LENGTH]
labels = labels[:MAX_LENGTH]
# input_ids和attention_mask告诉模型如何处理输入的文本数据,
# labels则是模型需要学习预测的目标值。这种格式确保了数据能够正确地被模型接收并进行训练。
return {"input_ids": input_ids, "attention_mask": attention_mask, "labels": labels}
2.1.1、 完整代码
import json
import os
import pandas as pd
from datasets import Dataset
from peft import LoraConfig, TaskType
from swanlab import SwanLabEnv
from transformers import AutoTokenizer, TrainingArguments, Trainer, \
DataCollatorForSeq2Seq, AutoModelForCausalLM
from swanlab.integration.huggingface import SwanLabCallback
# todo 需要修改的地方 model_path 替换成自己模型的地址
model_path = "C:\Users\<模型地址>"
# todo API_KEY 替换成swanlab上自己账号的API_KEY
os.environ[SwanLabEnv.API_KEY.value] = "xxx"
# todo 准备好的原始数据集(字节多元分类数据集)
dataset_path = "../train_data/toutiao_cat_train_data.txt"
# 处理好的数据集,按照json格式的query-answer格式,可以准备输入大模型中
dataset_output_path = "../train_data/toutiao_cat_data_output.txt"
eos_token_id = [151645, 151643]
system_prompt = "你是一个专门分类新闻标题的分析模型。你的任务是判断给定新闻短文本标题的分类。"
user_prompt = ("请将以下新闻短文本标题分类到以下类别之一:故事、文化、娱乐、体育、财经、房产、汽车、教育、"
"科技、军事、旅游、国际、股票、农业、游戏。输入如下:\n{}\n请填写你的输出:")
tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False)
model = AutoModelForCausalLM.from_pretrained(model_path, device_map='cpu').eval()
type_list = ["故事", "文化", "娱乐", "体育", "财经", "房产", "汽车", "教育",
"科技", "军事", "旅游", "国际", "股票", "农业", "游戏"]
type_map = {"news_story": "故事", "news_culture": "文化", "news_entertainment": "娱乐", "news_sports": "体育",
"news_finance": "财经", "news_house": "房产", "news_car": "汽车", "news_edu": "教育", "news_tech": "科技",
"news_military": "军事", "news_travel": "旅游", "news_world": "国际", "stock": "股票",
"news_agriculture": "农业", "news_game": "游戏"}
model.enable_input_require_grads() # 开启梯度检查点时,要执行该方法
# lora训练参数
config = LoraConfig(
# 设置lora模型的任务类型为因果语言模型。果语言模型是一种序列生成模型,它在生成每个词时只考虑前面的词,而不考虑后面的词。
task_type=TaskType.CAUSAL_LM,
# 模型中需要进行Lora训练的模块名称。这些模块通常是模型中的线性投影层,如查询(q_proj)、键(k_proj)、值(v_proj)等。
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
# 在训练模式下,模型会计算梯度并更新参数;而在推理模式下,模型只会进行前向传播,不会更新参数。
inference_mode=False, # 训练模式
# 这行代码设置了Lora模型的秩(rank)为8。Lora模型的秩决定了其参数矩阵的压缩程度,较低的秩可以减少模型的参数数量和计算量。
r=8,
# alpha参数在Lora模型中用于控制模型参数的稀疏性,较高的alpha值会导致更多的参数被压缩为零。
lora_alpha=32,
# Dropout是一种正则化技术,通过在训练过程中随机丢弃一部分神经元来防止模型过拟合。
lora_dropout=0.1,
)
# model = get_peft_model(model, config)
args = TrainingArguments(
# todo 需要修改的地方 output_dir 替换成微调后输出模型的地址
output_dir="D:\project\llm\output\Qwen2.5-1.5b_1001",
# 训练的批量大小
per_device_train_batch_size=4,
# 梯度积累的步数
gradient_accumulation_steps=4,
# 日志记录的步数,这里每10步记录一次日志
logging_steps=10,
# 训练的epoch数
num_train_epochs=2,
# 保存模型检查点的步数
save_steps=100,
# 学习率
learning_rate=1e-4,
save_on_each_node=True,
gradient_checkpointing=True,
report_to="none",
)
# swanlab参数
swanlab_callback = SwanLabCallback(
project="Qwen2-fintune",
experiment_name="Qwen2.5-1.5B",
description="使用通义千问Qwen2.5-1.5B模型在toutiao_cat_data数据集上微调。",
config={
"model": "Qwen2.5-1.5B",
"dataset": "toutiao_cat_data",
},
)
# 处理训练集
def dataset_jsonl_transfer(origin_path, new_path):
messages = []
# 读取旧的JSONL文件
with open(origin_path, "rb") as file:
for line in file:
# 解析每一行的json数据
# 按照_!_分割line字符串
parts = line.decode("utf-8").split("_!_")
title = parts[3]
title_type = type_map.get(parts[2])
print(f"title {title}, type {title_type}")
message = {
"query": user_prompt.format(title),
"answer": title_type,
}
messages.append(message)
# 保存重构后的JSONL文件
with open(new_path, "w", encoding="utf-8") as file:
for message in messages:
file.write(json.dumps(message, ensure_ascii=False) + "\n")
# 数据预处理
def process_func(example):
MAX_LENGTH = 256
input_ids, attention_mask, labels = [], [], []
instruction = tokenizer(f"{example['query']}")
response = tokenizer(f"{example['answer']}")
input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
attention_mask = (
# 加[1]的目的是对应[tokenizer.pad_token_id],一个句子的结尾符
instruction["attention_mask"] + response["attention_mask"] + [1]
)
# 用于监督学习的标签,这里将查询部分的标签设置为-100(表示忽略),因为查询部分不是我们需要模型学习的部分,只有回答部分是模型需要预测的。
labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]
if len(input_ids) > MAX_LENGTH: # 做一个截断
input_ids = input_ids[:MAX_LENGTH]
attention_mask = attention_mask[:MAX_LENGTH]
labels = labels[:MAX_LENGTH]
# input_ids和attention_mask告诉模型如何处理输入的文本数据,
# labels则是模型需要学习预测的目标值。这种格式确保了数据能够正确地被模型接收并进行训练。
return {"input_ids": input_ids, "attention_mask": attention_mask, "labels": labels}
# 训练
def train():
# 获取训练集
train_df = pd.read_json(dataset_output_path, lines=True)
train_ds = Dataset.from_pandas(train_df)
train_dataset = train_ds.map(process_func, remove_columns=train_ds.column_names)
# 开始训练
trainer = Trainer(
model=model,
args=args,
train_dataset=train_dataset,
data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
callbacks=[swanlab_callback],
)
trainer.train()
if __name__ == "__main__":
# 执行该命令,将训练数据转化成json格式
# dataset_jsonl_transfer(dataset_path, dataset_output_path)
# 开始训练
train()
2.2、微调参数说明
- per_device_train_batch_size = 2
较小的batch_size可能会带来训练时梯度的较大波动,而较大的batch_size则可能使训练过程更加平滑(也即可以提高训练的稳定性和效率),但可能提高内存消耗;
一般从较小的batch_size=2开始尝试,并逐渐增大,直到达到内存限制。
- gradient_accumulation_steps = 4
在更新模型参数之前,累计的梯度步数。允许在多个前向传播中累积梯度,从而模拟更大的批量大小,而不需要实际增加内存消耗。
- num_train_epochs = 2
设置训练的epoch数为2,表示整个训练数据集将被遍历2次。根据数据集的大小和模型的复杂性选择合适的epoch数。通常,较大的数据集需要较少的epoch,而较小的数据集可能需要更多的epoch。过多的epoch可能导致过拟合,即模型在训练集上表现良好,但在验证集或测试集上表现不佳。
一般从2到5个epoch开始,监控训练和验证损失。如果设置过大,可能会导致模型在训练过程中收敛不稳定,此时应当适时调整epoch数。
- learning_rate = 1e-4
决定模型在每次迭代中更新参数的速度。学习率过大可能导致训练不稳定,学习率过小则可能导致收敛速度过慢。
一般从1e-4/1e-5开始,监控训练过程中的损失变化,必要时进行调整。可以考虑使用学习率调度器(如余弦退火、学习率衰减等)来动态调整学习率。
2.3、微调执行与结果
执行主函数,train()方法,会出现下面的输出:
来到swanlab中,可以可视化地看到微调的过程:
其中我们需要关注的参数主要是:train/loss以及最终微调模型去做测评集的准确度:
- train/loss:这是训练过程中的损失函数值。损失函数是用来衡量模型预测与真实标签之间差异的指标。在训练过程中,模型的目标是尽量减小这个损失值,从而提高模型的预测准确性。
- 如果损失不再下降或开始上升,可能需要调整学习率或其他参数。
- 如果损失在某个epoch后突然增加,可能是过拟合的迹象,可以考虑使用早停(early stopping)或增加正则化。
- train/grad_norm:这是梯度范数,表示梯度向量的长度。梯度范数在优化过程中用于控制梯度的规模,特别是在使用梯度裁剪(Gradient Clipping)技术时,梯度范数可以帮助防止梯度爆炸,确保训练过程的稳定性。
- 监控梯度范数的变化。如果梯度范数突然增大,可能需要降低学习率,以避免模型发散。
- 如果梯度范数过小,可能表示模型学习缓慢,可以考虑增加学习率或调整其他训练参数。
- train/learning_rate:学习率是优化算法中一个非常重要的超参数,它决定了在每一步更新中模型参数变化的幅度。学习率过小可能导致训练过程非常缓慢,而学习率过大可能会导致模型在最优解附近振荡,甚至错过最优解。
- 监控学习率的变化,特别是在使用学习率调度器时。如果学习率过高,可能导致训练不稳定;如果过低,可能导致收敛速度缓慢。
- 根据训练损失和梯度范数的变化,适时调整学习率。例如,如果损失不再下降,可以考虑降低学习率。
- train/epoch:一个epoch是指模型完整地遍历一遍训练数据集的过程。在每次epoch结束时,模型会更新一次参数。epoch的数量是训练过程中一个重要的指标,它表示模型已经训练了多少轮次。