#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@File : train.py
@Time : 2023/11/22 08:07:45
@Author : Logan Zou
@Version : 1.0
@Contact : loganzou0421@163.com
@License : (C)Copyright 2017-2018, Liugroup-NLPR-CASIA
@Desc : Atom-7B-Chat-Lora指令微调
'''
from transformers import TrainingArguments
from transformers import Trainer, HfArgumentParser
from transformers import AutoTokenizer, AutoModel
from transformers import AutoModelForCausalLM
import torch
import torch.nn as nn
from peft import get_peft_model, LoraConfig, TaskType, PeftModel
from dataclasses import dataclass, field
import datasets
import os
import transformers
from tqdm import tqdm
import json
# dataclass:Python 类修饰符,数据类,封装了__init__()、 __repr__()和__eq__()函数
@dataclass
class FinetuneArguments:
# 微调参数
# field:dataclass 函数,用于指定变量初始化
dataset_path: str = field(default="../../dataset/lora/huanhuan.json")
model_path: str = field(default="../../dataset/model")
lora_rank: int = field(default=8)
max_seq_length: int = field(default=256)
skip_overlength: bool = field(default=False)
continue_training: bool = field(default=False)
checkpoint: str = field(default=None)
def preprocess(tokenizer, config, example, max_seq_length):
'''
args:
tokenizer:分词器,导入的 Atom 模型分词器
config:模型配置,导入的 Atom 模型配置
example: 待处理的样本
max_seq_length:文本的最大长度
returns:字典,包括 inputs_id 和 seq_len
'''
# 将 instruction 和 input 按照 Atom SFT 时的格式拼接起来
prompt = "Human: " + example["instruction"] + "请回答用户问题: " + example["input"] + "\n" + "Assistant:"
target = example["output"]
# 使用分词器进行编码,设置 truncation 为 True,避免出现过长的样本
prompt_ids = tokenizer.encode(prompt, max_length=max_seq_length, truncation=True)
target_ids = tokenizer.encode(
target,
max_length=max_seq_length,
truncation=True,
add_special_tokens=False)
# 加入结束符 EOS
input_ids = prompt_ids + target_ids + [config.eos_token_id]
# 将 inputs_ids 和 seq_len 一起传回,后续会根据 seq_len 来切分 inputs 和 labels
return {"input_ids": input_ids, "seq_len": len(prompt_ids)}
# 读取源训练数据并处理
def read_jsonl(path, max_seq_length, model_path, skip_overlength=False):
'''
args:
path:训练数据路径
max_seq_length:文本的最大长度
model_path:模型路径,此处主要是为了加载分词器和配置
returns:使用 yield 返回格式化的特征
'''
# 加载模型的分词器和配置参数
tokenizer = transformers.AutoTokenizer.from_pretrained(
model_path, trust_remote_code=True)
config = transformers.AutoConfig.from_pretrained(
model_path, trust_remote_code=True, device_map='auto')
# 读取源文件
with open(path, "r") as f:
# jsonl 数据需要先 readlines 读取成字符转,再使用 json 加载
lst = [json.loads(line) for line in f.readlines()]
print("加载jsonl数据集,数据总量为{}".format(len(lst)))
# 依次处理每一个样本
for example in tqdm(lst):
# 调用上文的预处理函数
feature = preprocess(tokenizer, config, example, max_seq_length)
# 如果设置了跳过过长的样本
if skip_overlength and len(feature["input_ids"]) > max_seq_length:
continue
# 截断过长的样本
feature["input_ids"] = feature["input_ids"][:max_seq_length]
# 通过 yield 返回迭代器
yield feature
# 自定义采样函数
def data_collator(features: list, tokenizer) -> dict:
'''
args:
features: 一个批量的数据
tokenizer:分词器
returns:格式化的特征
'''
# 统计 batch 内所有数据的长度,将它们补齐
len_ids = [len(feature["input_ids"]) for feature in features]
# 补齐至最大长度
longest = max(len_ids)
# 分别存放 input_ids 和 labels
input_ids = []
labels_list = []
# 有的模型没有定义 PAD,那么我们就用 UNK 来代替
if tokenizer.pad_token_id is None:
tokenizer.pad_token_id = tokenizer.unk_token_id
# 从最长的文本开始处理,可以优化内存使用
for ids_l, feature in sorted(zip(len_ids, features), key=lambda x: -x[0]):
ids = feature["input_ids"]
seq_len = feature["seq_len"]
# labels 是将输入 PAD 之后保留输出的结果,用-100表示遮蔽,并且进行补齐,计算 loss 时会自动忽略 -100
labels = (
[-100] * (seq_len - 1) + ids[(seq_len - 1) :] + [-100] * (longest - ids_l)
)
ids = ids + [tokenizer.pad_token_id] * (longest - ids_l)
_ids = torch.LongTensor(ids)
labels_list.append(torch.LongTensor(labels))
input_ids.append(_ids)
# 在第0维进行拼接,也就是组成 batch_size*n*n 的矩阵
input_ids = torch.stack(input_ids)
labels = torch.stack(labels_list)
return {
"input_ids": input_ids,
"labels": labels,
}
# 自定义 Trainer,继承自 transformers.trainer
class ModifiedTrainer(Trainer):
# 重写损失计算函数,避免 LLaMA 类模型未定义 loss 的计算
def compute_loss(self, model, inputs, return_outputs=False):
# 7B
return model(
input_ids=inputs["input_ids"],
labels=inputs["labels"],
).loss
# 重写模型保存函数,从而保存模型的 Lora 参数
def save_model(self, output_dir=None, _internal_call=False):
from transformers.trainer import TRAINING_ARGS_NAME
# 如果输出路径不存在,创建一个
os.makedirs(output_dir, exist_ok=True)
# 保存了模型训练的各种超参数
torch.save(self.args, os.path.join(output_dir, TRAINING_ARGS_NAME))
# 选出了所有梯度没有被冻结的参数,也就是所有参与更新的 Lora 参数
saved_params = {
k: v.to("cpu") for k, v in self.model.named_parameters() if v.requires_grad
}
# 保存所有 Lora 参数
torch.save(saved_params, os.path.join(output_dir, "adapter_model.bin"))
def main():
# 解析命令行参数
finetune_args, training_args = HfArgumentParser(
(FinetuneArguments, TrainingArguments)
).parse_args_into_dataclasses()
print("命令行参数")
print("finetune_args:")
print(finetune_args.__repr__())
print("training_args:")
print(training_args.__repr__())
# 初始化底座模型
tokenizer = AutoTokenizer.from_pretrained(finetune_args.model_path, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
finetune_args.model_path, trust_remote_code=True, device_map="auto")
print("从{}加载模型成功".format(finetune_args.model_path))
# 启用梯度检查点,允许模型在前向计算时丢弃一些中间激活值,并在反向传播中重新计算,从而优化内存使用
model.gradient_checkpointing_enable()
# 确保输入向量能够计算梯度
model.enable_input_require_grads()
# 在训练过程中关闭缓存,提高计算效率,推理时应该开启
model.config.use_cache = (
False
)
# 设定 peft 参数
# 手动确定 LoRA 层(注:理论上我们可以自动查找所有 Lora 层,但是在 LLaMA 类模型上出现 bug)
target_modules = ['W_pack', 'down_proj', 'o_proj', 'gate_proj', 'up_proj']
# 配置 Lora 参数
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM, # 任务为语言模型建模
inference_mode=False, # 训练模式
r=finetune_args.lora_rank, # Lora 秩
lora_alpha=32, # Lora alaph,具体作用参见 Lora 原理
lora_dropout=0.1,# Dropout 比例
target_modules= target_modules # Lora 层
)
# 是否从断点继续训练
# 源点训练
if not finetune_args.continue_training:
model = get_peft_model(model, peft_config)
print("加载 LoRA 参数成功")
else:
if finetune_args.check_point == None:
print("断点训练需要给出 checkpoint 地址")
raise ValueError("断点训练需要给出 checkpoint 地址")
model = PeftModel.from_pretrained(model, finetune_args.check_point, is_trainable=True)
print("从{}加载断点成功".format(finetune_args.check_point))
# 加载数据集
try:
dataset = datasets.Dataset.from_generator(
lambda: read_jsonl(finetune_args.dataset_path, finetune_args.max_seq_length, finetune_args.model_path, finetune_args.skip_overlength)
)
except Exception as e:
print("从{}加载数据集失败".format(finetune_args.dataset_path))
print("错误信息为:")
print(e.__repr__())
raise e
print("从{}加载数据集成功".format(finetune_args.dataset_path))
# 加载自定义 trainer
trainer = ModifiedTrainer(
model=model, # 待训练模型
train_dataset=dataset, # 数据集
args=training_args, # 训练参数
data_collator=lambda x : data_collator(x, tokenizer), # 自定义采样函数
)
print("成功加载 Trainer")
# 进行训练
trainer.train()
print("训练完成,训练结果保存在{}".format(training_args.output_dir))
# 保存模型
model.save_pretrained(training_args.output_dir)
print("模型参数保存在{}".format(training_args.output_dir))
if __name__ == "__main__":
main()