大模型微调实战:LLaMA-Factory 源码解析与部署
深入解析 LLaMA-Factory 大模型微调框架,涵盖预训练(PT)、指令微调(SFT)及强化学习(RLHF)阶段。介绍 Transformers 与 PEFT 库基础,分析源码中 tokenizer、dataset、model 加载及 Trainer 流程。通过 Qwen1.5-0.5B 模型实战演示数据集准备、命令配置、损失监控及评估指标解读,提供从理论到落地的完整微调指南。

深入解析 LLaMA-Factory 大模型微调框架,涵盖预训练(PT)、指令微调(SFT)及强化学习(RLHF)阶段。介绍 Transformers 与 PEFT 库基础,分析源码中 tokenizer、dataset、model 加载及 Trainer 流程。通过 Qwen1.5-0.5B 模型实战演示数据集准备、命令配置、损失监控及评估指标解读,提供从理论到落地的完整微调指南。

LLaMA-Factory 是一个在 GitHub 上开源的,专为大模型训练设计的平台。项目提供中文说明,可以参考官方文档。
大模型技术发展到现在,企业想要真正利用大模型做些事情,一定需要懂得大模型微调的过程。注意,这里说的是过程,而不是原理,专业技术人员才需要懂原理,普通用户只要懂过程就可以完成对大模型的微调。 对于有微调大模型需求,却对大模型微调完全是一个门外汉的用户来说,通过学习 LLaMA-Factory 后,可以快速的训练出自己需要的模型。 对于想要了解微调大模型技术的技术人员,通过学习 LLaMA-Factory 后也能快速理解模型微调的相关概念。 所以,我认为 LLaMA-Factory 是走向大模型微调的一条捷径。
如果你只想了解如何利用 LLaMA-Factory 进行模型的微调,直接通过官方文档即可完成。无需阅读本文。 如果你想对大模型微调技术本身感兴趣,想要深入了解,可以继续阅读本专栏,笔者将通过阅读源码的方式,对大模型微调技术进行深入剖析,看到哪里,遇到不懂的概念再去理解,最终展现出大模型训练的全貌。 理解了微调技术后,再通过使用 LLaMA-Factory 进行模型的微调实践,即可掌握大模型微调技术。
阅读源码之前,我们需要对模型微调相关概念有一定的认识,来协助我们理解源码。
在理解模型微调概念之前,我们先来理解大模型训练阶段有哪些。
Pre-Training:预训练阶段。 这个阶段是用来训练基础模型的,是最消耗算力的阶段,也是大模型诞生的起始阶段。
sft:指令微调/监督微调阶段 和预训练阶段相比,这个阶段最大的变化就是训练数据由"量多质低"变为"量少质高",训练数据主要由人工进行筛选或生成。这个阶段完成后其实已经能获得一个可以上线的大模型了
RLHF:基于人类反馈的强化学习(Reinforcement Learning from Human Feedback,RLHF) 可以分成两个环节
在这一阶段,模型学习和输出的内容发生了根本性的改变。前面的两个阶段,预训练和微调,模型的输出是符合预期的文本内容;**奖励建模阶段的输出不仅包含预测内容,还包含奖励值或者说评分值,数值越高,意味着模型的预测结果越好。**这个阶段输出的评分,并不是给最终的用户,而是在强化学习阶段发挥重大作用。
这个阶段非常'聪明'的整合了前面的成果:
常见的强化学习策略包括PPO与DPO,它们的细节我们不去研究,只要知道 DPO 主要用于分布式训练,适合大规模并行处理的场景,PPO 通常指的是单机上的算法就可以了。
了解了模型训练阶段后,现在有个问题,我们应该在哪个阶段进行微调训练呢? 通常会有以下训练模式进行选择,根据领域任务、领域样本情况、业务的需求我们可以选择合适的训练模式。 模式一:基于 base 模型 + 领域任务的 SFT; 模式二:基于 base 模型 + 领域数据 continue pre-train + 领域任务 SFT; 模式三:基于 base 模型 + 领域数据 continue pre-train + 通用任务 SFT+ 领域任务 SFT; 模式四:基于 base 模型 + 领域数据 continue pre-train + 通用任务与领域任务混合 SFT; 模式五:基于 base 模型 + 领域数据 continue pre-train(混入 SFT 数据 + 通用任务与领域任务混合 SFT; 模式六:基于 chat 模型 + 领域任务 SFT; 模式七:基于 chat 模型 + 领域数据 continue pre-train + 领域任务 SFT
大模型的知识来自于 pre-train 阶段,如果你的领域任务数据集与 pre-train 的数据集差异较大,比如你的领域任务数据来自公司内部,pre-train 训练样本基本不可能覆盖到,那一定要进行 continue pre-train。 如果你的领域任务数据量较大(token 在 1B 以上),并只追求领域任务的效果,不考虑通用能力,建议进行 continue pre-train。
如果你有一个好的 base 模型,在 base 模型基础进行领域数据的 SFT 与在 chat 模型上进行 SFT,效果上差异不大。 基于 chat 模型进行领域 SFT,会很容导致灾难性遗忘,在进行领域任务 SFT 之后,模型通用能力会降低,如只追求领域任务的效果,则不用考虑。 如果你的领域任务与通用任务有很大的相关性,那这种二阶段 SFT 会提升你的领域任务的效果。 如果你既追求领域任务的效果,并且希望通用能力不下降,建议选择 base 模型作为基座模型。在 base 模型上进行多任务混合训练,混合训练的时候需要关注各任务间的数据配比。
Transformers 是 Hugging Face 提供的 Python 库,Hugging Face 是什么这里就不介绍了。国内可以通过镜像站访问: Transformers 库的文档地址: 我们要关注那些内容呢?下边将会列举一些关键内容,详细内容请查阅官方文档。
Pipeline 是一个用于模型推理的工具,它与模型训练关系不大,它主要是将预训练好的模型加载,推理预测使用的,我们了解它是什么即可。
AutoClass 是一个比较重要的角色,主要是用来加载预训练模型的,通过 from_pretrained() 方法可以加载任意 Hugging Face 中的预训练模型和本地模型。
几乎所有的 NLP 任务都以 tokenizer 开始,用它来加载模型对应的分词器。
真正来加载模型实例的是 AutoModel,不同任务使用的 AutoModel 也不同,针对大语言模型一般使用 AutoModelForCausalLM。
量化技术专注于用较少的信息表示数据,同时尽量不损失太多准确性。 Transformers 支持三种量化方法:AWQ、GPTQ、BNB。底层细节我们不必研究 GPTQ 是专为 GPT 模型设计的,AWQ 适用于多种模型和任务,包括多模态语言模型。 BNB 是将模型量化为 8 位和 4 位的最简单选择,4 位量化可以与 QLoRA 一起用于微调量化 LLM。
PEFT 是 Hugging Face 提供的库,是一个为大型预训练模型提供多种高效微调方法的 python 库。 PEFT 文档地址: PEFT 可以轻松与 Transformers 库集成,一起完成模型微调的工作。 微调方式包括 LoRA、AdaLoRA、P-tuning 等。 补充说明:QLoRA 是量化 LoRA 的缩写,需要把模型量化再进行训练,细节暂不研究。
首先从分析 pt 预训练过程开始研究。 根据官方文档可知,预训练执行命令如下:
CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
--stage pt \
--do_train \
--model_name_or_path path_to_llama_model \
--dataset wiki_demo \
--finetuning_type lora \
--lora_target q_proj,v_proj \
--output_dir path_to_pt_checkpoint \
--overwrite_cache \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--lr_scheduler_type cosine \
--logging_steps 10 \
--save_steps 1000 \
--learning_rate 5e-5 \
--num_train_epochs 3.0 \
--plot_loss \
--fp16
参数说明 其中很多训练参数可能会看不懂,但没关系,先整体有个印象就行。
–stage pt:指定训练阶段为预训练 –do_train:指定是训练任务 –model_name_or_path:本地模型的文件路径或 Hugging Face 的模型标识符 –dataset:指定数据集 –finetuning_type lora:指定微调方法为 lora –lora_target q_proj,v_proj:Lora 作用模块为 q_proj,v_proj 此参数后续详解 –output_dir: 保存训练结果的路径 –overwrite_cache: 覆盖缓存的训练集和评估集 –per_device_train_batch_size 4: 每个 gpu 的批处理大小,训练参数 –gradient_accumulation_steps 4:梯度累计的步数,训练参数 –lr_scheduler_type cosine:学习率调度器,训练参数 –logging_steps 10:每两次日志输出间的更新步数,训练参数 –save_steps 1000:每两次断点保存间的更新步数,训练参数 –learning_rate 5e-5:学习率,adamW 优化器的默认值为 5e-5,训练参数 –num_train_epochs 3.0:需要执行的训练轮数,训练参数 –plot_loss:是否保存训练损失曲线 –fp16:使用 fp16 混合精度训练,此参数后续详解
lora_target 被设置到 LoraConfig 中的 target_modules 参数中,LoraConfig 是 PEFT 库中提供的。 文档地址: LLaMA-Factory 框架中通过 lora_target 进行了封装,说明如下:
lora_target: str = field(
default="all",
metadata={
"help": """Name(s) of target modules to apply LoRA. \
Use commas to separate multiple modules. \
Use "all" to specify all the linear modules. \
LLaMA choices: ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], \
BLOOM & Falcon & ChatGLM choices: ["query_key_value", "dense", "dense_h_to_4h", "dense_4h_to_h"], \
Baichuan choices: ["W_pack", "o_proj", "gate_proj", "up_proj", "down_proj"], \
Qwen choices: ["c_attn", "attn.c_proj", "w1", "w2", "mlp.c_proj"], \
InternLM2 choices: ["wqkv", "wo", "w1", "w2", "w3"], \
Others choices: the same as LLaMA."""
},
目前细节我们还无法理解,但可以通过以上说明进行对应的设置。 注意:经调试结果观察,Qwen1.5 的 lora_target 与 LLaMA choices 相同。
在深度学习中,混合精度训练是一种利用半精度浮点数(16 位)和单精度浮点数(32 位)混合计算的训练技术。传统上,神经网络训练过程中使用的是单精度浮点数,这需要更多的内存和计算资源。而混合精度训练通过将一部分计算过程转换为半精度浮点数来减少内存占用和加快计算速度。 FP16(半精度浮点数):FP16 通常用于混合精度训练,其中大多数计算操作使用 FP16 来减少内存占用和加快计算速度。 BF16(bfloat16):BF16 提供了更大的动态范围和更好的数值精度,相比于 FP16 更适合于保持梯度更新的稳定性。 FP32(单精度浮点数):传统神经网络训练中使用 FP32 来表示参数、梯度等数值,但相对于 FP16 和 BF16,它需要更多的内存和计算资源。 Pure BF16(纯 bfloat16):这个术语通常用于强调使用纯粹的 BF16 格式,而不是在混合精度环境中与其他精度混合使用。使用纯 BF16 意味着所有的计算和存储都使用 BF16 格式。
请自行配合源码阅读以下内容,文中不会粘贴完整源码。
根据运行命令,我们可以从 src/train_bash.py 部分看起。 会进入 src/llmtuner/train/pt/workflow.py 中的 run_pt 方法中,
run_pt 函数主要实现了以下功能: 加载 tokenizer 和 dataset:根据传入的参数,使用 load_tokenizer 函数加载 tokenizer,然后使用 get_dataset 函数根据 tokenizer、model_args、data_args 和 training_args 获取 dataset。 加载模型:使用 load_model 函数根据 tokenizer、model_args、finetuning_args 和 training_args 的 do_train 参数加载模型。 初始化 Trainer:使用 CustomTrainer 初始化一个 Trainer 实例,传入模型、参数、tokenizer、data_collator、callbacks 和其他额外的参数。data_collator 是通过调用 DataCollatorForLanguageModeling 类的实例化来创建一个数据整理器,主要用于自然语言处理任务中,将原始文本数据转换为模型可以输入的格式。 训练模型:如果 training_args.do_train 为 True,则调用 trainer 的 train 方法进行训练,根据需要恢复 checkpoint。训练完成后,保存模型、日志和状态。 评估模型:如果 training_args.do_eval 为 True,则调用 trainer 的 evaluate 方法进行评估,并计算 perplexity。然后记录和保存评估指标。 创建模型卡片:使用 create_modelcard_and_push 函数创建并推送模型卡片,其中包含了模型的相关信息和训练、评估结果。
开头的代码如下:
# 获取分词器
tokenizer = load_tokenizer(model_args)
# 获取数据集
dataset = get_dataset(tokenizer, model_args, data_args, training_args, stage="pt")
# 获取模型实例
model = load_model(tokenizer, model_args, finetuning_args, training_args.do_train)
接下来我们将分析一下以上三部分的代码实现。
先来分析 load_tokenizer 方法:
def load_tokenizer(model_args: "ModelArguments") -> "PreTrainedTokenizer":
r"""
Loads pretrained tokenizer. Must before load_model.
Note: including inplace operation of model_args.
"""
try_download_model_from_ms(model_args)
init_kwargs = _get_init_kwargs(model_args)
# 核心方法在这,加载分词器内容,具体参数含义先忽略
tokenizer = AutoTokenizer.from_pretrained(
model_args.model_name_or_path,
use_fast=model_args.use_fast_tokenizer,
split_special_tokens=model_args.split_special_tokens,
padding_side="right",
**init_kwargs,
)
patch_tokenizer(tokenizer)
return tokenizer
比较核心的方法其实是 get_dataset,因为要训练模型,最重要的部分是组织训练数据。 函数主要执行以下操作:
这里内容比较多,我们一步一步来分析。
首先来分析一下获取数据集模板在做什么。可以进入以下路径查看代码。 src/llmtuner/data/template.py 的 get_template_and_fix_tokenizer 方法。
函数主要做了以下几件事情:
最后返回选定的模板对象。
这段代码内容有点多,我们先不考虑模板的事,先来理解一下分词器中对应的概念。
首先我们理解一下什么是分词器。
在自然语言处理(NLP)中,分词器(tokenizer)是一个将文本输入分割成单词、子词或符号序列的工具。这个过程称为分词或者标记化。在 NLP 中,文本通常以字符串的形式存在,而计算机需要将其转换成可以处理的结构化数据形式,例如单词序列或标记序列,以便进行后续的语言处理任务,如词嵌入、语言模型训练、序列标注等。
那分词器的 EOS 标记,PAD 标记,停用词分别代表什么呢?
至于 Jinja 模板,这里就不过多介绍了,后边我们会去查看源码,看看在做什么的。
理解了以上内容,我们回过头来分析一下最开始的根据 name 获取相应模板是怎么做到的,它获取到的模板到底是什么。 通过阅读源码,我们可以看到,模板就是一个字典:
templates: Dict[str, Template] = {}
字典中的内容是通过_register_template 方法注册进去的。我们来分析一下_register_template 方法. 方法的入参比较多,我们可以分析各个参数的作用如下:
函数的主要工作是根据提供的参数创建对应的格式化器,并使用这些格式化器创建一个新的对话模板(Template 对象或 Llama2Template 对象),然后将该模板注册到全局变量 templates 中。 注意,这里的 Template 对象或 Llama2Template 对象都是项目自定义的类。类中的内容我们先不看。 只要知道在初始化时,会调用此方法注册模板即可。 以 qwen 模板为例,在代码中我们可以看到如下注册模板的内容:
_register_template(
name="qwen",
format_user=StringFormatter(slots=["<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]),
format_system=StringFormatter(slots=["<|im_start|>system\n{{content}}<|im_end|>\n"]),
format_separator=EmptyFormatter(slots=["\n"]),
default_system="You are a helpful assistant.",
stop_words=["<|im_end|>"],
replace_eos=True,
)
根据入参,我们再重新查看_register_template 方法的源码 (请自行查看源码往下看): 首先,efficient_eos 没有传参,默认值为 False,以至于 eos_slots 为 [],由此可以理解所谓的高效 EOS 标记,就是可能不需要额外的 EOS 标记,从而节省了内存和计算资源。 后续就是在实例化 Template 对象返回。
接下来我们看一下转换 Jinja 模板做了什么。
该函数_get_jinja_template 的作用是根据输入的模板和分词器生成一个 Jinja2 模板字符串。 首先,函数会判断模板中是否设置了默认系统消息,如果有,则将该消息添加到 Jinja2 模板中。 接着,函数会检查模板消息列表中是否存在系统消息,并将其内容赋值给变量 system_message。 然后,根据模板类型和是否强制显示系统消息,将 system_message 变量添加到 Jinja2 模板中。 接下来,函数会遍历模板消息列表,并根据消息的角色(用户或助手)将相应的内容添加到 Jinja2 模板中。 最后,函数返回生成的 Jinja2 模板字符串。 在处理过程中,函数会使用_convert_slots_to_jinja 函数将模板中的占位符转换为对应的 Jinja2 表达式,并使用 PreTrainedTokenizer 对模板内容进行分词处理。
可以看到,Jinja2 模板中支持 if else 和 for,不过这些都不重要,我们只要知道组织好模板后将模板赋值给了 tokenizer 的 chat_template 属性即可。
接下来就是获取数据集列表的实现了。主要代码如下:
with training_args.main_process_first(desc="load dataset"):
all_datasets = []
for dataset_attr in get_dataset_list(data_args):
all_datasets.append(load_single_dataset(dataset_attr, model_args, data_args))
dataset = merge_dataset(all_datasets, data_args, training_args)
这里先来关注 get_dataset_list 方法。
该函数用于获取数据集列表。根据输入的 data_args 参数中的 dataset 字段,将数据集名称列表进行处理并保存。然后从 data_args 参数中的 dataset_dir 目录下读取数据集配置文件 DATA_CONFIG,并解析其中的内容。实际文件路径为 data/dataset_info.json。 接下来,根据配置文件中定义的数据集信息,创建并填充 DatasetAttr 对象,并将其添加到 dataset_list 列表中。最后,返回 dataset_list 列表。 在创建 DatasetAttr 对象时,根据配置文件中的不同字段,选择不同的数据集类型和属性,并设置相应的属性值。 如果配置文件中定义了列名,则将其添加到 DatasetAttr 对象的属性中。 如果数据集格式为 sharegpt,并且配置文件中定义了标签信息,则将其添加到 DatasetAttr 对象的属性中。 如果在读取配置文件时发生异常,将抛出相应的异常。
现在我们知道 get_dataset_list 方法会返回数据集的一些元数据,load_single_dataset 方法就会根据元数据来加载真正的数据了。
get_dataset_list 根据给定的 dataset_attr、model_args 和 data_args 参数加载单个数据集。根据 dataset_attr.load_from 的值,函数从不同的来源加载数据集。支持的来源包括"Hugging Face Hub"、"ModelScope Hub"、脚本或文件。 当从"Hugging Face Hub"或"ModelScope Hub"加载数据集时,函数会使用相应的库加载数据集。 当从脚本或文件加载数据集时,函数会根据文件类型选择合适的方式加载数据。 函数还支持数据集的截断和对齐操作。
其中加载数据到内容的代码如下:
dataset = load_dataset(
path=data_path,
name=data_name,
data_dir=data_dir,
data_files=data_files,
split=data_args.split,
cache_dir=model_args.cache_dir,
token=model_args.hf_hub_token,
streaming=(data_args.streaming and (dataset_attr.load_from != "file")),
**kwargs,
)
这部分使用的是 Hugging Face 的 datasets 库来进行加载的。具体使用方法可以参考官网。
这里就不介绍了。 后续使用 align_dataset 对数据集进行转换后返回单个数据集的结果
align_dataset 函数用于对给定的 dataset 进行格式转换,使其符合指定的 dataset_attr 属性要求。 该函数根据 dataset_attr 的格式要求选择不同的转换函数(convert_alpaca 或 convert_sharegpt),并为转换后的数据集定义了特定的特征字典(features)。 转换函数将对数据集中的每个样本进行处理,重新组织其字段,并添加额外的"prompt"、"response"、"system"和"tools"字段。 处理过程中,可以选择是否使用批处理,并可以指定并行处理的工作线程数、是否从缓存文件加载以及是否覆盖缓存文件等参数。最终返回转换后的数据集。
具体的转换逻辑我们先不用看了,知道是转换成一种方便训练的格式就可以了。 后续预处理的逻辑我们也先不用看,大体了解会使用 tokenizer 对数据进行处理就可以了。
数据准备就绪,接下来就是加载模型了。
函数首先通过 model_args.model_name_or_path 使用 AutoConfig 获取模型的配置和初始化参数,然后根据是否可训练和是否使用 unsloth 选择不同的模型加载方式。 如果可训练且使用 unsloth,则使用 FastLanguageModel.from_pretrained 加载模型; 否则,使用 AutoModelForCausalLM.from_pretrained 加载模型。 接着,函数会对模型进行一些修改和注册,然后根据是否添加值头(value head)来初始化或修改模型。 最后,函数将模型设置为相应的模式(可训练或不可训练),并返回模型。 参数说明: tokenizer: 预训练的分词器。 model_args: 模型参数,包括模型名称、最大序列长度、计算数据类型等。 finetuning_args: 微调参数。 is_trainable: 模型是否可训练,默认为 False。 add_valuehead: 是否添加值头,默认为 False。 返回值: model: 加载的预训练模型。
其中比较核心的就是对模型进行的一些修改和注册了。这部分代码如下:
patch_model(model, tokenizer, model_args, is_trainable)
register_autoclass(config, model, tokenizer)
model = init_adapter(model, model_args, finetuning_args, is_trainable)
接下来会进入内部了解一下具体对模型做了哪些修改。
这里我们看到了新的概念,unsloth。所以我们先来理解一下新概念。 通过观察 LLaMA-Factory 的可视化页面中高级设置,可以看到加速方式。加速方式包括 flashattn 和 unsloth,那它们代表什么呢?
'unsloth' 和 'flashattn' 是两种不同的加速技术,通常用于优化神经网络模型的推理速度。
简单来说,Unsloth 和 FlashAttn 都是用于加速神经网络模型推理过程的技术,但它们的具体实现和优化方式有所不同,适用于不同类型的模型和应用场景。
patch_model 函数用于根据不同的模型类型和参数,对模型和分词器进行一系列的修改和配置。 具体包括以下几个方面:
如果模型的 generate 方法不是 GenerationMixin 的子类,则将其替换为 PreTrainedModel.generate 方法。 如果模型配置中的 model_type 为"chatglm",则设置模型的 lm_head 为 transformer.output_layer,并设置保存模型时忽略 lm_head.weight。(chatglm 需要一些特殊处理,我们暂不关心) 如果 model_args.resize_vocab 为 True,则调用_resize_embedding_layer 函数来调整嵌入层的大小。 如果模型是可训练的,则调用_prepare_model_for_training 函数对模型进行训练前的准备。 如果模型配置中的 model_type 为"mixtral"且启用了 DeepSpeed 的 Zero3 优化器,则导入 set_z3_leaf_modules 和 MixtralSparseMoeBlock,并调用 set_z3_leaf_modules 函数将 model 中的叶子模块设置为 MixtralSparseMoeBlock。如果模型是可训练的,则调用 patch_mixtral_replace_moe_impl 函数。 尝试向模型添加标签"llama-factory",如果添加失败则打印警告信息。 这些修改和配置的目的是为了适应不同模型的需求,提高模型的性能和效率。
我们如果使用 qwen 模型,主要需要观察_prepare_model_for_training 函数对模型做了哪些准备。
该函数主要为模型训练做准备,具体包括以下操作: 如果 model_args.upcast_layernorm 为 True,则将模型中的层归一化(layernorm)权重转换为 float32 类型。 如果 model_args.disable_gradient_checkpointing 为 False 且模型支持梯度检查点(gradient checkpointing),则启用梯度检查点,并设置相关属性。 如果模型具有 output_layer_name 属性且 model_args.upcast_lmhead_output 为 True,则将语言模型头(lm_head)的输出转换为 float32 类型
这里的概念可能不是太懂,可以先了解个大概即可。
这个方法的代码如下:
def register_autoclass(config: "PretrainedConfig", model: "PreTrainedModel", tokenizer: "PreTrainedTokenizer"):
if "AutoConfig" in getattr(config, "auto_map", {}):
config.__class__.register_for_auto_class()
if "AutoModelForCausalLM" in getattr(config, "auto_map", {}):
model.__class__.register_for_auto_class()
if "AutoTokenizer" in tokenizer.init_kwargs.get("auto_map", {}):
tokenizer.__class__.register_for_auto_class()
就是字面意思,注册 Transformers 框架中的自动类,具体用处目前还不明确。
init_adapter 函数用于初始化适配器,并支持全参数、冻结和 LoRA 训练。根据传入的模型、模型参数、微调参数和是否可训练,该函数将根据微调类型对模型进行相应的处理。此方法属于比较核心的方法
如果模型不可训练且没有指定适配器名称路径,则加载基本模型。 如果微调类型为"full"且模型可训练,则将模型参数转换为 float32 类型。 如果微调类型为"freeze"且模型可训练,则根据 num_layer_trainable 和其他参数来确定可训练的层,并将其他层的参数设置为不可训练。可训练的层可以是最后 n 层、前面 n 层或指定的层。 如果微调类型为"lora",则根据是否指定适配器名称路径和其他参数来加载、合并和恢复 LoRA 模型,并创建新的 LoRA 权重。 最终,该函数返回处理后的模型
这部分代码内容还是比较多的,full 和 freeze 我们不用关注,重点关注 lora 部分。由于这部分比较重要,我把 lora 部分代码放到下边,并用注释解释一下:
if finetuning_args.finetuning_type == "lora":
logger.info("Fine-tuning method: {}".format("DoRA" if finetuning_args.use_dora else "LoRA"))
adapter_to_resume = None
# 这部分是可以通过 adapter_name_or_path 路径,来进行进行增量的训练,增量逻辑我们可以先不看,代码没有放到这里
if model_args.adapter_name_or_path is not None:...
# 重点内容在这里
if is_trainable and adapter_to_resume is None: # create new lora weights while training
if len(finetuning_args.lora_target) == 1 and finetuning_args.lora_target[0] == "all":
# 通过调试,可以在这里看到模型所有的 lora_target
target_modules = find_all_linear_modules(model)
else:
target_modules = finetuning_args.lora_target
# 这里通过可视化页面,可以看到解释:仅训练块扩展后的参数。细节我们先不看
if finetuning_args.use_llama_pro:
target_modules = find_expanded_modules(model, target_modules, finetuning_args.num_layer_trainable)
# 这里验证了使用 dora 的时候,如果使用了量化,必须是使用 BNB 方式,否则不支持
if finetuning_args.use_dora and getattr(model, "quantization_method", None) is not None:
if getattr(model, "quantization_method", None) != QuantizationMethod.BITS_AND_BYTES:
raise ValueError("DoRA is not compatible with PTQ-quantized models.")
peft_kwargs = {
"r": finetuning_args.lora_rank,
"target_modules": target_modules,
"lora_alpha": finetuning_args.lora_alpha,
"lora_dropout": finetuning_args.lora_dropout,
"use_rslora": finetuning_args.use_rslora,
}
# 这里使用了 unsloth 加速,在之前的章节中有讲到
if model_args.use_unsloth:
from unsloth import FastLanguageModel # type: ignore
unsloth_peft_kwargs = {"model": model, "max_seq_length": model_args.model_max_length}
model = FastLanguageModel.get_peft_model(**peft_kwargs, **unsloth_peft_kwargs)
else:
# 组织 LoraConfig
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
modules_to_save=finetuning_args.additional_target,
use_dora=finetuning_args.use_dora,
**peft_kwargs,
)
# 加载模型
model = get_peft_model(model, lora_config)
# 这里的 pure_bf16 在前边章节页讲过,混合精度训练的一种模式
if not finetuning_args.pure_bf16:
for param in filter(lambda p: p.requires_grad, model.parameters()):
param.data = param.data.to(torch.float32)
通过阅读以上源码和注释,想要更好的理解,需要解决下边的问题。
想要解决这些问题,我们应该去了解一下 LoraConfig。
首先,LoraConfig 属于 PEFT 库。所以可以阅读一下官方文档,来理解一下 Lora,地址如下:
通过阅读这部分文档,我们可以对 Lora 整体有了一个认识。 之后我们可以阅读这部分内容,来理解一下 LoraConfig 中每个参数的作用。
回到我们自己的代码中,现在可以解释一下这部分代码具体的含义了:
peft_kwargs = {
"r": finetuning_args.lora_rank,
"target_modules": target_modules,
"lora_alpha": finetuning_args.lora_alpha,
"lora_dropout": finetuning_args.lora_dropout,
"use_rslora": finetuning_args.use_rslora,
}
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
modules_to_save=finetuning_args.additional_target,
use_dora=finetuning_args.use_dora,
**peft_kwargs,
)
task_type:此参数不是 LoraConfig 的参数,而是它的父类 PeftConfig 的参数,可选值为 TaskType 中的值,具体的含义是什么呢?我们可以直接看源码:
class TaskType(str, enum.Enum):
"""
Enum class for the different types of tasks supported by PEFT.
Overview of the supported task types:
- SEQ_CLS: Text classification.
- SEQ_2_SEQ_LM: Sequence-to-sequence language modeling.
- CAUSAL_LM: Causal language modeling.
- TOKEN_CLS: Token classification.
- QUESTION_ANS: Question answering.
- FEATURE_EXTRACTION: Feature extraction. Provides the hidden states which can be used as embeddings or features
for downstream tasks.
"""
SEQ_CLS = "SEQ_CLS"
SEQ_2_SEQ_LM = "SEQ_2_SEQ_LM"
CAUSAL_LM = "CAUSAL_LM"
TOKEN_CLS = "TOKEN_CLS"
QUESTION_ANS = "QUESTION_ANS"
FEATURE_EXTRACTION = "FEATURE_EXTRACTION"
可以看到,其实就是在指定任务类型是大语言模型。 inference_mode:此参数也是父类 PeftConfig 的参数,表示模型是否是推理模型,由于我们要进行训练,所以设置为 False modules_to_save:除 LoRA 层以外的可训练模块名称,我们先不用管这里。 use_dora:使用权重分解的 LoRA。 r:lora 微调的维度,我们默认设置的是 8。 target_modules:就是要微调的模块,前边已经有介绍。 lora_alpha:LoRA 微调的缩放因子,默认为 r * 2。 lora_dropout:LoRA 微调的随机丢弃率。了解过深度学习的一定可以理解这个指标。 use_rslora:是否使用 LoRA 层的秩稳定缩放因子,阅读官网可以理解为:将适配器的缩放因子设置为 lora_alpha/math.sqrt® ,因为它被证明工作得更好。否则,它将使用原始的默认值 lora_alpha/r 。 至此,目前我们已经理解了项目中使用到的参数。 其他内容可根据官方文档理解。
前边的内容只是训练的前提,接下来我们就来看一下训练部分的实现。 我们回到 src/llmtuner/train/pt/workflow.py 中的 run_pt 方法中继续往下看。这里我把核心源码直接放到下边。
# 部分主要是对数据转换,转换成模型可以输入的格式。
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
# Initialize our Trainer
trainer = CustomTrainer(
model=model,
args=training_args,
finetuning_args=finetuning_args,
tokenizer=tokenizer,
data_collator=data_collator,
callbacks=callbacks,
#就是数据集的拆分(训练集/测试集)
**split_dataset(dataset, data_args, training_args),
)
# Training
if training_args.do_train:
# 开始训练,resume_from_checkpoint 可以是字符串或布尔值,如果为字符串,则是本地保存的检查点的路径,如果为布尔值且为 True,则加载 args.output_dir 中由之前的 [Trainer] 实例保存的最后一个检查点。如果存在,则从加载的模型/优化器/调度器状态继续训练。
train_result = trainer.train(resume_from_checkpoint=training_args.resume_from_checkpoint)
# 保存模型
trainer.save_model()
# 记录指标日志
trainer.log_metrics("train", train_result.metrics)
# 保存指标日志
trainer.save_metrics("train", train_result.metrics)
# 保存训练状态
trainer.save_state()
# 如果是主进程,且 plot_loss 参数为 True,则保存损失曲线图
if trainer.is_world_process_zero() and finetuning_args.plot_loss:
plot_loss(training_args.output_dir, keys=["loss", "eval_loss"])
首先我们先来理解一下 DataCollatorForLanguageModeling,可以阅读官网文档进行理解,地址如下:
简单来说就是转换成模型可以输入的格式。 CustomTrainer 是一个比较重要的内容,接下来我们将对它进行解读。
CustomTrainer 继承自 Trainer 类,并且重写了 create_optimizer_and_scheduler 方法。
create_optimizer_and_scheduler 方法用于创建自定义的优化器和学习率调度器。它首先调用 create_custom_optimzer 函数来创建优化器,该函数根据模型、参数和 finetuning_args 来决定如何创建优化器。如果 create_custom_optimzer 返回 None,则调用 Trainer 类的 create_optimizer 方法来创建优化器。
我们可以看一下 create_custom_optimzer 的底层实现,代码内容不多,直接放到下边:
def create_custom_optimzer(
model: "PreTrainedModel",
training_args: "Seq2SeqTrainingArguments",
finetuning_args: "FinetuningArguments",
max_steps: int,
) -> Optional["torch.optim.Optimizer"]:
if finetuning_args.use_galore:
return _create_galore_optimizer(model, training_args, finetuning_args, max_steps)
if finetuning_args.loraplus_lr_ratio is not None:
return _create_loraplus_optimizer(model, training_args, finetuning_args)
如何创建自定义优化器我们先不研究,目前我们还用不到。 要研究的重点其实是 Trainer 类,Trainer 是 Transformers 库中比较重要的一部分。可以阅读官方文档进行了解: 接下来我们分析一下代码中传入的参数的含义:
model:这个不用太解释,就是传入之前加载的模型 args=training_args:这个 training_args 实际上是 Transformers 库中的 Seq2SeqTrainingArguments,这其中包含的参数就比较多了。 finetuning_args:这个参数是自定义的参数,我们不关注。 tokenizer、data_collator:这两个参数不再解释,你应该也能看懂了。 callbacks:指定回调函数,LLaMA-Factory 指定了一个打印日志的回调函数。 split_dataset:这是自定义的函数,用来做数据拆分,实现细节我们先不看,主要就是返回 split_dataset 参数和 eval_dataset 参数,分别用来表示训练的数据集和评估的数据集,是 Trainer 类自带的参数。
至此,对 CustomTrainer 我们已经有了整体的认识。
为了更进一步的了解训练参数,我们可以看一下 Seq2SeqTrainingArguments 中都有哪些参数。官方文档地址如下:
由于参数太多了,就不挨个参数解读了,只解释一些我们常用到的参数,其他参数详见官方文档:
output_dir:输出目录,将写入模型预测和检查点。 overwrite_output_dir:如果设置为 True,将覆盖输出目录中的内容。可以在 output_dir 指向检查点目录时使用此选项继续训练。 do_train:是否进行训练。这个参数不是直接由 [Trainer] 使用的 do_eval:是否在验证集上运行评估。如果 evaluation_strategy 不是 'no' ,将被设置为 True。这个参数不是直接由 [Trainer] 使用的 do_predict:是否在测试集上进行预测。这个参数不是直接由 [Trainer] 使用的 evaluation_strategy:训练过程中采用的评估策略。可能的取值有: 'no': 不进行评估。 'steps': 每隔 eval_steps 进行评估。 'epoch': 每个 epoch 结束时进行评估 per_device_train_batch_size (int,可选,默认为 8):每个 GPU/TPU 核心/CPU 的训练批次大小。 per_device_eval_batch_size (int,可选,默认为 8):每个 GPU/TPU 核心/CPU 的评估批次大小。 gradient_accumulation_steps (int,可选,默认为 1):在执行反向传播/更新步骤之前,累积梯度的更新步骤数。
直接看剩余部分的代码,理解了之前训练部分的代码,评估部分代码很容易就能看懂。
# Evaluation
if training_args.do_eval:
# 评估
metrics = trainer.evaluate(metric_key_prefix="eval")
try:
# 计算困惑度,困惑度是自然语言处理领域常用的评价模型生成或预测文本的能力的指标,它是损失函数指数运算的结果。越低代表模型越好。
perplexity = math.exp(metrics["eval_loss"])
except OverflowError:
perplexity = float("inf")
metrics["perplexity"] = perplexity
trainer.log_metrics("eval", metrics)
trainer.save_metrics("eval", metrics)
至此,我们已经打通了 pt 预训练这条通道,接下来我们就要开始查看 sft 指令微调部分的实现了。
首先我们还是查看官方文档提供的 sft 脚本,内容如下:
CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
--stage sft \
--do_train \
--model_name_or_path path_to_llama_model \
--dataset alpaca_gpt4_zh \
--template default \
--finetuning_type lora \
--lora_target q_proj,v_proj \
--output_dir path_to_sft_checkpoint \
--overwrite_cache \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--lr_scheduler_type cosine \
--logging_steps 10 \
--save_steps 1000 \
--learning_rate 5e-5 \
--num_train_epochs 3.0 \
--plot_loss \
--fp16
可以发现,只有–stage 被设置成了 sft,其他参数之前已经介绍过了。 所以我们直接开始查看源码。
有了之前的经验,源码入口可以很快找到。 即 src/llmtuner/train/sft/workflow.py 中的 run_sft 方法中。这里我直接把完整代码放到下边:
def run_sft(
model_args: "ModelArguments",
data_args: "DataArguments",
training_args: "Seq2SeqTrainingArguments",
finetuning_args: "FinetuningArguments",
generating_args: "GeneratingArguments",
callbacks: Optional[List["TrainerCallback"]] = None,
):
tokenizer = load_tokenizer(model_args)
# 数据预处理部分有变化,后期可以进入查看一下
dataset = get_dataset(tokenizer, model_args, data_args, training_args, stage="sft")
model = load_model(tokenizer, model_args, finetuning_args, training_args.do_train)
if training_args.predict_with_generate:
tokenizer.padding_side = "left" # use left-padding in generation
if getattr(model, "is_quantized", False) and not training_args.do_train:
setattr(model, "_hf_peft_config_loaded", True) # hack here: make model compatible with prediction
data_collator = DataCollatorForSeq2Seq(
tokenizer=tokenizer,
pad_to_multiple_of=8 if tokenizer.padding_side == "right" else None, # for shift short attention
label_pad_token_id=IGNORE_INDEX if data_args.ignore_pad_token_for_loss else tokenizer.pad_token_id,
)
# Override the decoding parameters of Seq2SeqTrainer
training_args.generation_max_length = training_args.generation_max_length or data_args.cutoff_len
training_args.generation_num_beams = data_args.eval_num_beams or training_args.generation_num_beams
# Initialize our Trainer
# trainer 使用了 CustomSeq2SeqTrainer,这是一个比较大的变化
trainer = CustomSeq2SeqTrainer(
model=model,
args=training_args,
finetuning_args=finetuning_args,
tokenizer=tokenizer,
data_collator=data_collator,
callbacks=callbacks,
compute_metrics=ComputeMetrics(tokenizer) if training_args.predict_with_generate else None,
**split_dataset(dataset, data_args, training_args),
)
# Keyword arguments for `model.generate`
gen_kwargs = generating_args.to_dict()
gen_kwargs["eos_token_id"] = [tokenizer.eos_token_id] + tokenizer.additional_special_tokens_ids
gen_kwargs["pad_token_id"] = tokenizer.pad_token_id
gen_kwargs["logits_processor"] = get_logits_processor()
# Training
if training_args.do_train:
train_result = trainer.train(resume_from_checkpoint=training_args.resume_from_checkpoint)
trainer.save_model()
trainer.log_metrics("train", train_result.metrics)
trainer.save_metrics("train", train_result.metrics)
trainer.save_state()
if trainer.is_world_process_zero() and finetuning_args.plot_loss:
plot_loss(training_args.output_dir, keys=["loss", "eval_loss"])
# Evaluation
if training_args.do_eval:
metrics = trainer.evaluate(metric_key_prefix="eval", **gen_kwargs)
if training_args.predict_with_generate: # eval_loss will be wrong if predict_with_generate is enabled
metrics.pop("eval_loss", None)
trainer.log_metrics("eval", metrics)
trainer.save_metrics("eval", metrics)
# Predict
# 多了一个预测推理阶段,基本过程都是一样的,只不过调用了 trainer.predict 方法
if training_args.do_predict:
predict_results = trainer.predict(dataset, metric_key_prefix="predict", **gen_kwargs)
if training_args.predict_with_generate: # predict_loss will be wrong if predict_with_generate is enabled
predict_results.metrics.pop("predict_loss", None)
trainer.log_metrics("predict", predict_results.metrics)
trainer.save_metrics("predict", predict_results.metrics)
trainer.save_predictions(predict_results)
# Create model card
create_modelcard_and_push(trainer, model_args, data_args, training_args, finetuning_args)
发现了什么? 没错,代码结构基本与之前的预训练部分差不多。 只有在 get_dataset 处理数据部分,会有所差异,具体差异我们暂时不看,只要知道 sft 阶段数据预处理时,是需要增加指令、角色信息的就可以了。 实际上,如果选择使用 LLaMA-Factory 进行微调,我们按照 LLaMA-Factory 的数据集格式要求准备数据就可以了。
这阶段除了预处理数据部分有差别,最大的差别就是训练器与之前不同,使用的是 CustomSeq2SeqTrainer, CustomSeq2SeqTrainer 是 Seq2SeqTrainer 的子类,而 Seq2SeqTrainer 是 Trainer 的子类。 Seq2SeqTrainer 的源码我们就不去仔细阅读了,简单阅读后发现,Seq2SeqTrainer 主要是重写了 Trainer 的评估和推理的方法,没有重写训练方法,所以与使用 Trainer 进行训练应该差别不大。 这里为什么使用 Seq2SeqTrainer,我们不必纠结。 阅读 qwen1.5 官方提供的 sft 示例,可以看到示例中使用的也是 Trainer 而不是 Seq2SeqTrainer。 示例地址:
可以发现,理解了 pt 阶段后,再来理解 sft 阶段其实是很容易的,一通百通,sft 阶段我们就介绍到这里,如果你对哪部分感兴趣,可以自己去阅读源码,相信有了 pt 阶段的知识储备后,你可以很容易的阅读源码了。 另外,我们只要懂得 pt 与 sft 微调,就能实际上手微调了。所以后续的 RLHF 阶段就先不去查看了。
我们已经理解了大模型微调的基本过程,但实践是检验真理的唯一标准,所以接下来将与大家一起对大模型微调进行实践,观察一下微调效果。 注意:UI 界面的使用请阅读官方文档,这里不会介绍 UI 如何使用。
根据 LLaMA-Factory 官方提供的数据准备文档,可以看到训练数据的格式。地址如下:
文档中比较重要的说明部分:
对于预训练数据集,仅 prompt 列中的内容会用于模型训练。 对于偏好数据集,response 列应当是一个长度为 2 的字符串列表,排在前面的代表更优的回答
偏好数据集是用在奖励建模阶段的。
本次微调选择了开源项目数据集,地址如下:
下载后,将 json 文件存放到 LLaMA-Factory 的 data 目录下。 修改 dataset_info.json 文件。 直接增加以下内容即可:
"huanhuan": {
"file_name": "huanhuan.json"
}
添加后,在 UI 页面中直接可以看到新增加的数据集。
为了减少资源消耗,本次选择测试的模型是 Qwen1.5-0.5B-Chat。 通过可视化页面配置后,可以得到微调命令如下:
CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
--stage sft \
--do_train True \
--model_name_or_path /home/jqxxuser/model/Qwen1.5-0.5B-Chat \
--finetuning_type lora \
--template qwen \
--dataset_dir data \
--dataset huanhuan \
--cutoff_len 1024 \
--learning_rate 5e-05 \
--num_train_epochs 2.0 \
--max_samples 100000 \
--per_device_train_batch_size 2 \
--gradient_accumulation_steps 8 \
--lr_scheduler_type cosine \
--max_grad_norm 1.0 \
--logging_steps 5 \
--save_steps 100 \
--warmup_steps 0 \
--optim adamw_torch \
--output_dir saves/Qwen1.5-0.5B-Chat/lora/train_2024-03-28-10-54-09 \
--fp16 True \
--lora_rank 8 \
--lora_alpha 16 \
--lora_dropout 0.1 \
--lora_target q_proj,v_proj \
--plot_loss True
说明:SFT 阶段是最常用训练阶段,所以我们在 SFT 阶段进行微调,测试效果。 我们直接在 UI 中点击开始即可进行微调,可以观察到整个的过程,发现损失值在逐渐降低。
等待训练完毕即可。
刷新适配器,可以看到我们刚刚训练完的模型,选择即可
选择 Chat 功能,加载模型后即可开始聊天。
看看效果吧:
目测效果还可以,至少我们看到模型确实发生了改变。
通过聊天观察效果是一种直观的感觉,我们可以在通过项目自带的评估功能做一下评估。 注意,如果报错,需要确保安装了以下库。 jieba rouge-chinese nltk
运行后可以在目录中看到评估指标:
{
"predict_bleu-4": 2.487403191204076,
"predict_rouge-1": 16.790678761061947,
"predict_rouge-2": 1.1607781979082865,
"predict_rouge-l": 14.878193322606597,
"predict_runtime": 900.9563,
"predict_samples_per_second": 4.139,
"predict_steps_per_second": 1.38
}
这些指标应该怎么看呢?首先我们来了解一下这些指标的概念。
所以,单独看指标数据的话,模型的效果并不是太好,这就需要大家自行摸索如何让模型能力更好了。
切换到 Export 选项卡,指定导出目录,点击开始导出,即可导出模型。
导出后的模型与其他大模型的使用方法一致。
至此,我们的一次模型微调的尝试就完成了。 可以发现,使用 LLaMA-Factory 进行微调基本上可以做到便捷式操作了。最大的工作量还是在组织训练数据这个阶段。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online