finish 6.2
This commit is contained in:
217
docs/chapter6/6.2 模型有监督微调.md
Normal file
217
docs/chapter6/6.2 模型有监督微调.md
Normal file
@@ -0,0 +1,217 @@
|
||||
## 6.2 模型有监督微调
|
||||
|
||||
在上一节,我们介绍了如何使用 transformers 框架快速、高效地进行模型预训练。在本部分,我们将基于上部分内容,介绍如何使用 transformers 框架对预训练好的模型进行有监督微调。
|
||||
|
||||
### 6.2.1 Pretrain VS SFT
|
||||
|
||||
首先需要回顾一下,对 LLM 进行预训练和进行有监督微调的核心差异在于什么。在第四章中提到过,目前成型的 LLM 一般通过 Pretrain-SFT-RLHF 三个阶段来训练,在 Pretrain 阶段,会对海量无监督文本进行自监督建模,来学习文本语义规则和文本中的世界知识;在 SFT 阶段,一般通过对 Pretrain 好的模型进行指令微调,即训练模型根据用户指令完成对应任务,从而使模型能够遵循用户指令,根据用户指令进行规划、行动和输出。因此,Pretrain 和 SFT 均使用 CLM 建模,其核心差异在于,Pretrain 使用海量无监督文本进行训练,模型直接对文本执行“预测下一个 token”的任务;而 SFT 使用构建成对的指令对数据,模型根据输入的指令,建模后续的输出。反映到具体的训练实现上,Pretrain 会对全部 text 进行 loss 计算,要求模型对整个文本实现建模预测;而 SFT 仅对输出进行 loss 计算,不计算指令部分的 loss。
|
||||
|
||||
因此,相较于上一节完成的 Pretrain 代码,SFT 部分仅需要修改数据处理环节,实现对指令对数据转化为训练样本的构建,其余部分和 Pretrain 是完全一致的实现逻辑。本部分代码脚本为 ./code/finetune.py。
|
||||
|
||||
### 6.2.2 微调数据处理
|
||||
|
||||
同样与第五章类似,我们此处使用贝壳开源的 BelleGroup 数据集进行 SFT。
|
||||
|
||||
在 SFT 过程中,我们会定义一个 Chat Template,这个 Template 即表示了如何将对话数据转化为一个模型可以建模拟合的文本序列。当我们使用做过 SFT 的模型进行下游任务微调时,一般需要查看该模型的 Chat Template 并进行适配,即是为了不损伤其在 SFT 中学到的指令遵循能力。由于我们此处使用 Pretrain 模型进行 SFT,可以自定义一个 Chat Template。由于我们使用了 Qwen-2.5-1.5B 模型结构进行 Pretrain,此处我们沿承使用 Qwen-2.5 的 Chat Template。如果读者没有足够的资源进行上一部分模型的 Pretrain 的话,此处也可以使用官方的 Qwen-2.5-1.5B 模型作为 SFT 的基座模型。
|
||||
|
||||
我们首先定义几个特殊 token,特殊 token 在模型进行拟合中有特殊的作用,包括文本序列开始(BOS)、文本序列结束(EOS)、换行符等。定义特殊 token,有助于避免模型在拟合过程中的语义混淆:
|
||||
|
||||
```python
|
||||
|
||||
# 不同的 tokenizer 需要特别定义
|
||||
# BOS
|
||||
im_start = tokenizer("<|im_start|>").input_ids
|
||||
# EOS
|
||||
im_end = tokenizer("<|im_end|>").input_ids
|
||||
# PAD
|
||||
IGNORE_TOKEN_ID = tokenizer.pad_token_id
|
||||
# 换行符
|
||||
nl_tokens = tokenizer('\n').input_ids
|
||||
# 角色标识符
|
||||
_system = tokenizer('system').input_ids + nl_tokens
|
||||
_user = tokenizer('human').input_ids + nl_tokens
|
||||
_assistant = tokenizer('assistant').input_ids + nl_tokens
|
||||
```
|
||||
|
||||
Qwen 系列的 Chat Template 一般有三个对话角色:System、User 和 Assistant。System 是系统提示词,负责激活模型的能力,默认为“You are a helpful assistant.”,一般不会在 SFT 过程中更改使用。User 即为用户给出的提示词,此处由于数据集中的对话角色为 “human”,我们将 “user” 修改为了“human”。Assistant 即为 LLM 给出的回复,也就是模型在 SFT 过程中需要拟合的文本。
|
||||
|
||||
接着,由于该数据集是一个多轮对话数据集,我们需要对多轮对话进行拼接处理,将多轮对话拼接到一个文本序列中:
|
||||
|
||||
```python
|
||||
# 拼接多轮对话
|
||||
input_ids, targets = [], []
|
||||
# 多个样本
|
||||
for i in tqdm(range(len(sources))):
|
||||
# source 为一个多轮对话样本
|
||||
source = sources[i]
|
||||
# 从 user 开始
|
||||
if source[0]["from"] != "human":
|
||||
source = source[1:]
|
||||
# 分别是输入和输出
|
||||
input_id, target = [], []
|
||||
# system: 【BOS】system\nYou are a helpful assistant.【EOS】\n
|
||||
system = im_start + _system + tokenizer(system_message).input_ids + im_end + nl_tokens
|
||||
input_id += system
|
||||
# system 不需要拟合
|
||||
target += im_start + [IGNORE_TOKEN_ID] * (len(system)-3) + im_end + nl_tokens
|
||||
assert len(input_id) == len(target)
|
||||
# 依次拼接
|
||||
for j, sentence in enumerate(source):
|
||||
# sentence 为一轮对话
|
||||
role = roles[sentence["from"]]
|
||||
# user:<|im_start|>human\ninstruction【EOS】\n
|
||||
# assistant:<|im_start|>assistant\nresponse【EOS】\n
|
||||
_input_id = tokenizer(role).input_ids + nl_tokens + \
|
||||
tokenizer(sentence["value"]).input_ids + im_end + nl_tokens
|
||||
input_id += _input_id
|
||||
if role == '<|im_start|>human':
|
||||
# user 不需要拟合
|
||||
_target = im_start + [IGNORE_TOKEN_ID] * (len(_input_id)-3) + im_end + nl_tokens
|
||||
elif role == '<|im_start|>assistant':
|
||||
# assistant 需要拟合
|
||||
_target = im_start + [IGNORE_TOKEN_ID] * len(tokenizer(role).input_ids) + \
|
||||
_input_id[len(tokenizer(role).input_ids)+1:-2] + im_end + nl_tokens
|
||||
else:
|
||||
print(role)
|
||||
raise NotImplementedError
|
||||
target += _target
|
||||
assert len(input_id) == len(target)
|
||||
# 最后进行 PAD
|
||||
input_id += [tokenizer.pad_token_id] * (max_len - len(input_id))
|
||||
target += [IGNORE_TOKEN_ID] * (max_len - len(target))
|
||||
input_ids.append(input_id[:max_len])
|
||||
targets.append(target[:max_len])
|
||||
```
|
||||
上述代码沿承了 Qwen 的 Chat Template 逻辑,读者也可以根据自己的偏好进行修改,其核心点在于 User 的文本不需要拟合,因此 targets 中 User 对应的文本内容是使用的 IGNORE_TOKEN_ID 进行遮蔽,而 Assistant 对应的文本内容则是文本原文,是需要计算 loss 的。目前主流 LLM IGNORE_TOKEN_ID 一般设置为 -100。
|
||||
|
||||
完成拼接后,将 tokenize 后的数值序列转化为 tensor,再拼接成 Dataset 所需的字典返回即可:
|
||||
|
||||
```python
|
||||
input_ids = torch.tensor(input_ids)
|
||||
targets = torch.tensor(targets)
|
||||
|
||||
return dict(
|
||||
input_ids=input_ids,
|
||||
labels=targets,
|
||||
attention_mask=input_ids.ne(tokenizer.pad_token_id),
|
||||
)
|
||||
```
|
||||
|
||||
完成上述处理逻辑后,需要自定义一个 Dataset 类,在该类中调用该逻辑进行数据的处理:
|
||||
|
||||
```python
|
||||
class SupervisedDataset(Dataset):
|
||||
|
||||
def __init__(self, raw_data, tokenizer, max_len: int):
|
||||
super(SupervisedDataset, self).__init__()
|
||||
# 加载并预处理数据
|
||||
sources = [example["conversations"] for example in raw_data]
|
||||
# preprocess 即上文定义的数据预处理逻辑
|
||||
data_dict = preprocess(sources, tokenizer, max_len)
|
||||
|
||||
self.input_ids = data_dict["input_ids"]
|
||||
self.labels = data_dict["labels"]
|
||||
self.attention_mask = data_dict["attention_mask"]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.input_ids)
|
||||
|
||||
def __getitem__(self, i) -> Dict[str, torch.Tensor]:
|
||||
return dict(
|
||||
input_ids=self.input_ids[i],
|
||||
labels=self.labels[i],
|
||||
attention_mask=self.attention_mask[i],
|
||||
)
|
||||
```
|
||||
|
||||
该类继承自 torch 的 Dataset 类,可以直接在 Trainer 中使用。完成数据处理后,基于上一节脚本,修改数据处理逻辑即可,后续模型训练等几乎完全一致,此处附上主函数逻辑:
|
||||
|
||||
```python
|
||||
# 加载脚本参数
|
||||
parser = HfArgumentParser((ModelArguments, DataTrainingArguments, TrainingArguments))
|
||||
model_args, data_args, training_args = parser.parse_args_into_dataclasses()
|
||||
|
||||
# 初始化 WandB
|
||||
wandb.init(project="sft", name="qwen-1.5b")
|
||||
|
||||
# 设置日志
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
datefmt="%m/%d/%Y %H:%M:%S",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
|
||||
# 将日志级别设置为 INFO
|
||||
transformers.utils.logging.set_verbosity_info()
|
||||
log_level = training_args.get_process_log_level()
|
||||
logger.setLevel(log_level)
|
||||
datasets.utils.logging.set_verbosity(log_level)
|
||||
transformers.utils.logging.set_verbosity(log_level)
|
||||
transformers.utils.logging.enable_default_handler()
|
||||
transformers.utils.logging.enable_explicit_format()
|
||||
|
||||
# 训练整体情况记录
|
||||
logger.warning(
|
||||
f"Process rank: {training_args.local_rank}, device: {training_args.device}, n_gpu: {training_args.n_gpu}"
|
||||
+ f"distributed training: {bool(training_args.local_rank != -1)}, 16-bits training: {training_args.fp16}"
|
||||
)
|
||||
logger.info(f"Training/evaluation parameters {training_args}")
|
||||
|
||||
# 检查 checkpoint
|
||||
last_checkpoint = None
|
||||
if os.path.isdir(training_args.output_dir):
|
||||
last_checkpoint = get_last_checkpoint(training_args.output_dir)
|
||||
if last_checkpoint is None and len(os.listdir(training_args.output_dir)) > 0:
|
||||
raise ValueError(
|
||||
f"输出路径 ({training_args.output_dir}) 非空 "
|
||||
)
|
||||
elif last_checkpoint is not None and training_args.resume_from_checkpoint is None:
|
||||
logger.info(
|
||||
f"从 {last_checkpoint}恢复训练"
|
||||
)
|
||||
|
||||
# 设置随机数种子.
|
||||
set_seed(training_args.seed)
|
||||
|
||||
# 初始化模型
|
||||
logger.warning("加载预训练模型")
|
||||
logger.info(f"模型参数地址:{model_args.model_name_or_path}")
|
||||
model = AutoModelForCausalLM.from_pretrained(model_args.model_name_or_path,trust_remote_code=True)
|
||||
n_params = sum({p.data_ptr(): p.numel() for p in model.parameters()}.values())
|
||||
logger.info(f"继承一个预训练模型 - Total size={n_params/2**20:.2f}M params")
|
||||
|
||||
# 初始化 Tokenizer
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_args.model_name_or_path)
|
||||
logger.info("完成 tokenzier 加载")
|
||||
|
||||
# 加载微调数据
|
||||
with open(data_args.train_files) as f:
|
||||
lst = [json.loads(line) for line in f.readlines()[:10000]]
|
||||
logger.info("完成训练集加载")
|
||||
logger.info(f"训练集地址:{data_args.train_files}")
|
||||
logger.info(f'训练样本总数:{len(lst)}')
|
||||
# logger.info(f"训练集采样:{ds["train"][0]}")
|
||||
|
||||
train_dataset = SupervisedDataset(lst, tokenizer=tokenizer, max_len=2048)
|
||||
|
||||
logger.info("初始化 Trainer")
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
args=training_args,
|
||||
train_dataset= IterableWrapper(train_dataset),
|
||||
tokenizer=tokenizer
|
||||
)
|
||||
|
||||
# 从 checkpoint 加载
|
||||
checkpoint = None
|
||||
if training_args.resume_from_checkpoint is not None:
|
||||
checkpoint = training_args.resume_from_checkpoint
|
||||
elif last_checkpoint is not None:
|
||||
checkpoint = last_checkpoint
|
||||
|
||||
logger.info("开始训练")
|
||||
train_result = trainer.train(resume_from_checkpoint=checkpoint)
|
||||
trainer.save_model()
|
||||
```
|
||||
|
||||
启动方式也同样在 sh 脚本中使用 deepspeed 启动即可,此处不再赘述,源码见 ./code/finetune.sh。
|
||||
Reference in New Issue
Block a user