文章目录
- 1. SFT 代码
- 2. 训练数据
- 3. 各种参数设置
- 3.1 device_map='auto' 导致多卡显存负载不均匀
- 3.2 batch_size 和梯度累积
- 3.3 warmup_ratio 学习率预热
- 3.4 模型保存 save_steps
- 3.5 模型 max_len 的设置(最大token长度)
- 3.6 设置 PaddingID = -100 的原因
这篇博客详细记录了基于 Hugging Face Transformers库 SFT (Supervised Fine-Tuning,有监督微调) Qwen2-7B-Instruct 的代码和相关细节,适用于大模型微调入门实践的读者。
1. SFT 代码
code:
# sft Qwen2-7B-Instructimport os
import datasets
from datasets import load_dataset
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, GenerationConfig, DataCollatorForSeq2Seq, Trainer, TrainingArgumentsPaddingID = -100def 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-Instructprompt_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_idsif 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,只计算非填充部分的 losstorch.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_lengthinput_ids = input_ids + [tokenizer.pad_token_id] * pad_lengthelse:# 超过最大长度的数据被截断labels = [PaddingID] * context_length + b_idslabels = 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_inputsif __name__=="__main__":# load tokenizermodel_path = 'models/qwen/Qwen2-7B-Instruct/'tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)# load training datasetdataset_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.jsonlbatched:分批加载数据,默认 batch=1000num_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=Truedata_type=torch.bfloat16if gpu_type=='V100': if_bf16=Falsedata_type=torch.float16 # load modelmodel = 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, 默认为-100pad_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_sizelogging_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_sizebf16=if_bf16, # 是否使用 bfloat16 数据格式run_name='qwen2-7B-sft',report_to='wandb', # 使用 wandb 打印日志)# traintrainer = 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 jsonmodel_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