从零开始:Windows系统下Qwen2.5大模型的实践教程(二)-lora微调

1,351 阅读13分钟

1、微调

特定任务或领域上进一步训练大型语言模型(LLM)的过程。这可以通过使用预训练的LLM作为起点,然后在特定任务或领域的标记数据集上训练它来完成。

1.1、常见的几种微调方式

  1. 全模型微调(Fine-Tuning the Entire Model):

这种方法是对整个预训练模型进行微调,包括所有层的权重。这种方式通常需要较大的计算资源和时间,但可以获得较好的性能,特别是在目标任务与预训练任务有较大差异时。

  1. 低秩自适应(Low-Rank Adaptation,LoRA):

通过引入低秩矩阵来近似调整模型权重,从而减少可训练参数的数量,降低计算成本。lora在aigc领域用的比较多,比如最常见的stable-diffusion,它是一个基于文本生成图片的模型,而sd当中的lora模型,常常用来控制角色的画风、动作、姿态等等。

  1. 量化(Quantization):

对模型参数进行量化,减少模型的大小和内存占用,例如使用4位量化技术,降低模型的存储需求。

  1. P-Tuning 和 P-Tuning v2:

使用可训练的LSTM模型动态生成虚拟标记嵌入,通过调整这些嵌入来微调模型。

1.2、环境准备

// 需要注意的是,Qwen项目中提供的finetune.py依赖DeepSpeedFSDP。基于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、微调参数说明

  1. per_device_train_batch_size = 2

较小的batch_size可能会带来训练时梯度的较大波动,而较大的batch_size则可能使训练过程更加平滑(也即可以提高训练的稳定性和效率),但可能提高内存消耗;
一般从较小的batch_size=2开始尝试,并逐渐增大,直到达到内存限制。

  1. gradient_accumulation_steps = 4

在更新模型参数之前,累计的梯度步数。允许在多个前向传播中累积梯度,从而模拟更大的批量大小,而不需要实际增加内存消耗。

  1. num_train_epochs = 2

设置训练的epoch数为2,表示整个训练数据集将被遍历2次。根据数据集的大小和模型的复杂性选择合适的epoch数。通常,较大的数据集需要较少的epoch,而较小的数据集可能需要更多的epoch。过多的epoch可能导致过拟合,即模型在训练集上表现良好,但在验证集或测试集上表现不佳。
一般从2到5个epoch开始,监控训练和验证损失。如果设置过大,可能会导致模型在训练过程中收敛不稳定,此时应当适时调整epoch数。

  1. learning_rate = 1e-4

决定模型在每次迭代中更新参数的速度。学习率过大可能导致训练不稳定,学习率过小则可能导致收敛速度过慢。
一般从1e-4/1e-5开始,监控训练过程中的损失变化,必要时进行调整。可以考虑使用学习率调度器(如余弦退火、学习率衰减等)来动态调整学习率。

2.3、微调执行与结果

执行主函数,train()方法,会出现下面的输出:

image.png

来到swanlab中,可以可视化地看到微调的过程:

swanlab微调过程.png

其中我们需要关注的参数主要是:train/loss以及最终微调模型去做测评集的准确度:

  1. train/loss:这是训练过程中的损失函数值。损失函数是用来衡量模型预测与真实标签之间差异的指标。在训练过程中,模型的目标是尽量减小这个损失值,从而提高模型的预测准确性。
  • 如果损失不再下降或开始上升,可能需要调整学习率或其他参数。
  • 如果损失在某个epoch后突然增加,可能是过拟合的迹象,可以考虑使用早停(early stopping)或增加正则化。
  1. train/grad_norm:这是梯度范数,表示梯度向量的长度。梯度范数在优化过程中用于控制梯度的规模,特别是在使用梯度裁剪(Gradient Clipping)技术时,梯度范数可以帮助防止梯度爆炸,确保训练过程的稳定性。
  • 监控梯度范数的变化。如果梯度范数突然增大,可能需要降低学习率,以避免模型发散。
  • 如果梯度范数过小,可能表示模型学习缓慢,可以考虑增加学习率或调整其他训练参数。
  1. train/learning_rate:学习率是优化算法中一个非常重要的超参数,它决定了在每一步更新中模型参数变化的幅度。学习率过小可能导致训练过程非常缓慢,而学习率过大可能会导致模型在最优解附近振荡,甚至错过最优解。
  • 监控学习率的变化,特别是在使用学习率调度器时。如果学习率过高,可能导致训练不稳定;如果过低,可能导致收敛速度缓慢。
  • 根据训练损失和梯度范数的变化,适时调整学习率。例如,如果损失不再下降,可以考虑降低学习率。
  1. train/epoch:一个epoch是指模型完整地遍历一遍训练数据集的过程。在每次epoch结束时,模型会更新一次参数。epoch的数量是训练过程中一个重要的指标,它表示模型已经训练了多少轮次。