【代码详解】大模型微调入门:SFT Qwen2-7B,基于 Hugging Face Transformers库

这篇博客详细记录了基于 Hugging Face Transformers库 SFT (Supervised Fine-Tuning,有监督微调) Qwen2-7B-Instruct 的代码和相关细节,适用于大模型微调入门实践的读者。

1. SFT 代码

code:

# sft Qwen2-7B-Instruct

import os
import datasets
from datasets import load_dataset
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, GenerationConfig, DataCollatorForSeq2Seq, Trainer, TrainingArguments

PaddingID = -100

def preprocess_inputs(examples, max_len=8192, overflow_strategy='truncate'):
    """
    @function:预处理输入数据
    examples:数据集
    max_len:尽管 Qwen2-7B-Instruct 支持 131072 tokens,但最好不要设置为最大长度,否则显存占用将会非常大。
            max_len 的设置可以通过统计数据集的 token 长度得到。具体方法:将所有数据输入到 qwen2 模型的 tokenizer,统计 tokenizer 的输出长度(最大,最小,平均)
    overflow_strategy:'drop'表示丢弃,'truncate'表示截断
    """

    # prompt_template:可以在 qwen2 的 huggingface 官方库的 demo 中使用 tokenizer.apply_chat_template 函数打印模型的 prompt template
    # Qwen2-7B-Instruct huggingface 地址:https://huggingface.co/Qwen/Qwen2-7B-Instruct
    prompt_template = '<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n'
    system_prompt = "你是一个知识渊博的人,请根据问题做出全面且正确的回答。"

    model_inputs = {'input_ids': [], 'labels': [], 'input_len': [], 'output_len': []}
    for i in range(len(examples['query'])):
        prompt = prompt_template.format(
                    system_prompt=system_prompt,
                    user_prompt=examples['query'][i]
                )
        a_ids = tokenizer.encode(prompt)
        b_ids = tokenizer.encode(f"{examples['answer'][i]}", add_special_tokens=False) + [tokenizer.eos_token_id]
        context_length = len(a_ids)
        input_ids = a_ids + b_ids

        if len(input_ids) > max_len and overflow_strategy == 'drop':
            # 丢弃样本
            input_ids = []
            labels = []
        else:
            if max_len > len(input_ids):
                """
                使用 -100 填充, 因为 torch.nn.CrossEntropyLoss 的 ignore_index=-100, 即 CrossEntropyLoss 会忽略标签为 -100 的值的 loss,只计算非填充部分的 loss
                torch.nn.CrossEntropyLoss 官方文档:https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
                """
                pad_length = max_len - len(input_ids)
                labels = [PaddingID] * context_length + b_ids + [PaddingID] * pad_length
                input_ids = input_ids + [tokenizer.pad_token_id] * pad_length
            else:
                # 超过最大长度的数据被截断
                labels = [PaddingID] * context_length + b_ids
                labels = labels[:max_len]
                input_ids = input_ids[:max_len]
        model_inputs['input_ids'].append(input_ids)
        model_inputs['labels'].append(labels)
        model_inputs['input_len'].append(len(a_ids))
        model_inputs['output_len'].append(len(b_ids))
    return model_inputs

if __name__=="__main__":
    # load tokenizer
    model_path = 'models/qwen/Qwen2-7B-Instruct/'
    tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

    # load training dataset
    dataset_folder_path='sft_qwen2_7B/huggingface_data/'
    raw_datasets = load_dataset(dataset_folder_path)
    train_dataset = raw_datasets['train'].map(preprocess_inputs, batched=True, num_proc=1, load_from_cache_file=False)
    """
    加载 train.xxx 文件,如 train.txt  train.jsonl
    batched:分批加载数据,默认 batch=1000
    num_proc:配置多线程处理,一般不设置单线程的数据加载速度也很快
    load_from_cache_file:指定是否从缓存文件加载预处理后的数据。如果设置为 True,datasets 库会尝试从磁盘加载预先处理并缓存的数据集,而不是重新运行 map 函数。
                        设置 load_from_cache_file=False 意味着每次运行脚本时都会重新进行数据预处理,而不是从缓存中加载。
    """

    # 设置数据类型,V100不支持 bf16 类型
    gpu_type = 'V100'   
    assert gpu_type in ['A100','V100']
    if gpu_type=='A100':
        if_bf16=True
        data_type=torch.bfloat16
    if gpu_type=='V100':   
        if_bf16=False
        data_type=torch.float16   

    # load model
    model = AutoModelForCausalLM.from_pretrained(model_path, 
                                                torch_dtype=data_type, 
                                                device_map='auto', 
                                                trust_remote_code=True)
    """
    device_map 的可选参数有:auto、balanced、balanced_low_0、sequential
    'auto' 和 'balanced'是一样的,会自动切分模型,导致不同GPU之间的负载差异很大,可能某个卡的显存被占满了,其他卡只占了1/4,这会导致 batch_size 无法增大。
                        在训练qwen2-7B中,最后一个 GPU 占据资源很小(80G A100只占用 400M显存),其他GPU显存占用 20G~75G 不等。
    balanced_low_0:第一个 GPU 上占据较少资源(执行generate 函数,即迭代过程),其他 GPU 自动划分模型,也会负载不均。
    sequential:按照 GPU 的顺序分配模型分片,会导致 GPU 0 显存爆炸。
    综上来看,这四个参数都会使多卡 GPU 的负载不均,暂时没有发现如何能够平衡负载。
    """

    model.gradient_checkpointing_enable()
    """
    启用模型的梯度检查点, 梯度检查点是一种优化技术,可用于减少训练时的内存消耗。
    在反向传播期间,模型的中间激活值需要被保留以计算梯度。
    梯度检查点技术通过仅保存必要的一部分激活值,并在需要时重新计算丢弃的激活值,从而减少内存使用。
    """

    data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, 
                                        model=model, 
                                        label_pad_token_id=PaddingID, 
                                        pad_to_multiple_of=None, 
                                        padding=False)
    """
    创建了一个 Seq2Seq任务 的数据整理器, 用于将多个样本组合成一个批次。
    label_pad_token_id:指定用于填充标签的 padding token 的 id, 默认为-100
    pad_to_multiple_of = None:指定padding后序列长度应该是多少的倍数。如果设置为None(默认值),则不进行这种类型的padding。
    padding = False:指定是否对数据进行padding。设置为False 通常意味着数据的 padding 将在模型内部或通过其他方式处理。
    """

    # 训练参数
    args = TrainingArguments(
        output_dir='./outputs',             # 模型保存路径
        per_device_train_batch_size=4,      # 全局 batch_size,注意不是单个 GPU 上的  batch_size
        logging_steps=1,
        gradient_accumulation_steps=32,     # 梯度累计,在显存较小的设备中,每隔多个 batch_size 更新一次梯度;
                                            # 真正更新梯度的 batch = per_device_train_batch_size * gradient_accumulation_steps
                                            # 即 4*32=128 个 batch 更新一次梯度
        num_train_epochs=1,                 # sft llm 的 epoch 一般不需要太大,1~3轮即可
        weight_decay=0.003,                 # 权重衰减正则化,将一个与权重向量的L2范数成比例的惩罚项加到总损失中
        warmup_ratio=0.03,                  # 预热,在训练初期逐渐增加学习率,而不是从一开始就使用预设的最大学习率,避免一开始就使用过高的学习率可能导致的训练不稳定。
                                            # 如果设置 warmup_ratio=0.1,共有100个epochs,那么在前10个epochs(即前10%的训练时间),学习率会从0逐渐增加到最大值。
        optim='adamw_hf',
        lr_scheduler_type="cosine",         # 根据余弦函数的形状来逐渐减小学习率,一般有 "linear" 和 "cosine" 两种方式   
        learning_rate=1e-5,                 # 最大学习率
        save_strategy='steps',
        save_steps=5,                       # 保存模型的步骤,save_steps 是 per_device_train_batch_size * gradient_accumulation_steps,而不是 per_device_train_batch_size
        bf16=if_bf16,                       # 是否使用 bfloat16 数据格式
        run_name='qwen2-7B-sft',
        report_to='wandb',                  # 使用 wandb 打印日志
    )

    # train
    trainer = Trainer(
        model=model,
        tokenizer=tokenizer,
        args=args,
        data_collator=data_collator,
        train_dataset=train_dataset,
    )
    trainer.train()

2. 训练数据

Hugging Face 的训练数据格式如下:

-- 训练集文件夹
	-- train.xxx  

train.xxx 可以是 txt/json/jsonl 等格式,但文件名必须是 train

本文使用的 train.jsonl 文件中每一行都是一个 json 字典格式,包括 query 和 answer 两个关键字。

3. 各种参数设置

3.1 device_map=‘auto’ 导致多卡显存负载不均匀

device_map 的可选参数有:auto、balanced、balanced_low_0、sequential

  • ‘auto’ 和 'balanced’是一样的,会自动切分模型,导致不同GPU之间的负载差异很大,可能某个卡的显存被占满了,其他卡只占了1/4,这会导致 batch_size 无法增大。

    在训练qwen2-7B中,最后一个 GPU 占据资源很小(80G A100只占用 400M显存),其他GPU显存占用 20G~75G 不等。

  • balanced_low_0:第一个 GPU 上占据较少资源(执行generate 函数,即迭代过程),其他 GPU 自动划分模型,也会负载不均。

  • sequential:按照 GPU 的顺序分配模型分片,会导致 GPU 0 显存爆炸。

综上来看,这四个参数都会使多卡 GPU 的负载不均,暂时没有发现如何能够平衡负载。

3.2 batch_size 和梯度累积

  • per_device_train_batch_size=4 是指全局 batch_size,而不是单个 GPU 上的 batch_size
  • gradient_accumulation_steps=32 是指每 32 步更新一次梯度

梯度累计功能用于在显存较小的设备中,每隔多个 batch_size 更新一次梯度,以等价实现较大 batch_size 的效果。

因此真正更新梯度的 batch = per_device_train_batch_size * gradient_accumulation_steps,即 4*32=128 个 batch 更新一次梯度。

3.3 warmup_ratio 学习率预热

学习率预热是指在训练初期逐渐增加学习率,而不是从一开始就使用预设的最大学习率(代码中设置1e-5),避免一开始就使用过高的学习率可能导致的训练不稳定。

本文设置 warmup_ratio=0.03,共有100个epochs,那么在前3个epochs(即前3%的训练时间),学习率会从0逐渐增加到最大值。

代码执行结果:

{'loss': 0.8541, 'grad_norm': 6.78125, 'learning_rate': 3.3333333333333333e-06, 'epoch': 0.01}                                                                                                │······
{'loss': 0.8472, 'grad_norm': 6.78125, 'learning_rate': 6.666666666666667e-06, 'epoch': 0.02}                                                                                                 │······
{'loss': 0.8231, 'grad_norm': 5.65625, 'learning_rate': 1e-05, 'epoch': 0.03}                                                                                                                 │······
{'loss': 0.7823, 'grad_norm': 4.21875, 'learning_rate': 9.99695413509548e-06, 'epoch': 0.04}                                                                                                  │······
{'loss': 0.769, 'grad_norm': 3.953125, 'learning_rate': 9.987820251299121e-06, 'epoch': 0.05}                                                                                                 │······
{'loss': 0.7173, 'grad_norm': 2.28125, 'learning_rate': 9.972609476841368e-06, 'epoch': 0.06}                                                                                                 │······
{'loss': 0.7023, 'grad_norm': 2.171875, 'learning_rate': 9.951340343707852e-06, 'epoch': 0.07}                                                                                                │······
{'loss': 0.7084, 'grad_norm': 2.09375, 'learning_rate': 9.924038765061042e-06, 'epoch': 0.09}                                                                                                 │······
{'loss': 0.6877, 'grad_norm': 1.9921875, 'learning_rate': 9.890738003669029e-06, 'epoch': 0.1}                                                                                                │······
{'loss': 0.6809, 'grad_norm': 1.3984375, 'learning_rate': 9.851478631379982e-06, 'epoch': 0.11}                                                                                               │······
{'loss': 0.688, 'grad_norm': 1.1328125, 'learning_rate': 9.806308479691595e-06, 'epoch': 0.12}                                                                                                │······
{'loss': 0.6657, 'grad_norm': 1.078125, 'learning_rate': 9.755282581475769e-06, 'epoch': 0.13}                                                                                                │······
{'loss': 0.6624, 'grad_norm': 1.1328125, 'learning_rate': 9.698463103929542e-06, 'epoch': 0.14}                                                                                               │······
{'loss': 0.6552, 'grad_norm': 1.15625, 'learning_rate': 9.635919272833938e-06, 'epoch': 0.15}                                                                                                 │······                                                                                              
...

可以看到,learning_rate 并不是直接初始化为 1e-5 的,而是从 3e-6 经过 0.03epoch (warmup_ratio=0.03)逐渐增加至最大学习率 1e-5,再根据 lr_scheduler_type 逐渐降度学习率。

3.4 模型保存 save_steps

需要注意 save_steps = per_device_train_batch_size * gradient_accumulation_steps,而不等于 per_device_train_batch_size。即真正更新梯度时才保存一次,这也是符合预期的,如果不更新梯度,保存模型参数也没有意义。

3.5 模型 max_len 的设置(最大token长度)

由于 llm 是自回归预测形式,所以 max_len 是输入的 prompt 与输出(生成文本)的长度之和,而不只是 prompt 的长度限制,这在推理任务中尤为重要。

尽管 Qwen2-7B-Instruct 支持 131072 tokens,但最好不要设置为最大长度,否则显存占用将会非常大。

max_len 的设置可以通过统计数据集的 token 长度得到。具体方法:将所有数据输入到 qwen2 模型的 tokenizer,统计 tokenizer 的输出长度(最大,最小,平均)

code:

# 统计数据长度分布,确定模型的 max_len 设置

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, GenerationConfig, DataCollatorForSeq2Seq, Trainer, TrainingArguments
from tqdm import tqdm
import json

model_path = 'models/qwen/Qwen2-7B-Instruct/'
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

prompt_template = '<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n'
system_prompt = "你是一个知识渊博的人,请根据问题做出全面且正确的回答。"

token_lens = []
input_file="sft_qwen2_7B/huggingface_data/train.jsonl"    # 训练集文件
with open(input_file, encoding="utf-8") as f:
    infer_data = [json.loads(data) for data in f.readlines()]
    for i, data in tqdm(enumerate(infer_data)):
        prompt = prompt_template.format(
                    system_prompt=system_prompt,
                    user_prompt=data['query']
                )
        in_ids =tokenizer.encode(prompt)
        answer=data['answer']
        out_ids =tokenizer.encode(answer)
        token_len=len(in_ids+out_ids)
        token_lens.append(token_len)

print(f"最大长度: {max(token_lens)}")
print(f"最小长度: {min(token_lens)}")
print(f"平均长度: {sum(token_lens)/len(token_lens)}")

"""
最大长度: 8001
最小长度: 4113
平均长度: 5017.022747138398
结论:应使用 max_len=8k(8192),Qwen2-7B-Instruct 支持 131072 tokens,满足条件
"""

3.6 设置 PaddingID = -100 的原因

使用 -100 进行填充是因为 torch.nn.CrossEntropyLoss 的 ignore_index=-100, 即 torch.nn.CrossEntropyLoss 会忽略标签为 -100 的值的 loss,只计算非填充部分的 loss:

torch.nn.CrossEntropyLoss(weight=None, 
						size_average=None, 
						ignore_index=-100, 
						reduce=None, 
						reduction='mean', 
						label_smoothing=0.0)

torch.nn.CrossEntropyLoss 官方文档:https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

  • 15
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ctrl A_ctrl C_ctrl V

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值