怎么让英文大语言模型支持中文?(三)—— 对预训练模型进行指令微调
📝通俗解释:指令微调(Instruction Tuning)就像是在教一个已经学会说话的孩子如何更好地回答问题。我们不需要从头教他说话,而是给他一些"问题-答案"的例子,让他学会按照人类的期望来回答。
一、为什么需要对预训练模型进行指令微调?
在完成继续预训练之后,我们对数据处理、训练、预测的整个流程已经有了基本了解。实际上,指令微调的过程与继续预训练大致相似。
在选择一个基础大语言模型后(如 ChatGLM、LLaMA、Bloom 等),要正确使用它,需要了解以下三个方面:
- 输入数据的格式 —— 如何组织指令、输入和输出
- Tokenization —— 如何将文本转换为模型能处理的数字
- 模型的使用方式 —— 如何加载和调用模型
📝通俗解释:这就类似于你要使用一台复杂的机器,需要知道:怎么操作它(输入格式)、它的工作原理(tokenization)、以及怎么启动和控制它(模型使用方式)。
二、指令微调数据如何处理?
2.1 数据格式
指令数据一般由三部分组成:
| 字段名 | 说明 | 示例 |
|---|---|---|
| instruction (instruct) | 指令/任务描述 | "请把以下句子翻译成英文" |
| input (query) | 输入的文本 | "我爱北京天安门" |
| output (answer) | 期望的输出 | "I love Beijing Tiananmen" |
构造时,通常将 instruction 和 input 进行拼接(input 可能为空),然后对 output 进行预测。
📝通俗解释:可以把 instruction 理解为"老师布置的任务",input 是"题目内容",output 是"标准答案"。模型要学会看到任务和题目后,能给出正确答案。
不同模型可能使用不同的 prompt 模板:
PROMPT_DICT = {
"chatglm_input": ("{instruction} {input}"),
"alpaca_input": (
"Below is an instruction that describes a task. "
"Write a response that appropriately completes the request.\n\n"
"### Instruction:\n{instruction} {input}\n\n### Response: "
),
"bloom_input": ("Human: \n{instruction} {input}\n\nAssistant: \n"),
}2.2 数据处理完整代码示例
以下是基于 ChatGLM 的数据处理实现:
import logging
import os
from dataclasses import dataclass
from typing import Optional, Dict, Sequence, Union, List
import datasets
import torch
import transformers
import random
from datasets import load_dataset, concatenate_datasets
import copy
IGNORE_INDEX = -100
logger = logging.getLogger(__name__)
PROMPT_TEMPLATE = (
"Below is an instruction that describes a task. "
"Write a response that appropriately completes the request.\n\n"
"### Instruction:\n{instruction}\n\n### Response: "
)
def build_instruction_dataset(data_path: Union[List[str], str],
tokenizer: transformers.PreTrainedTokenizer,
max_seq_length: int,
data_cache_dir=None,
preprocessing_num_workers=None):
def tokenization(examples):
sources = []
targets = []
for instruction, input_text, output in zip(
examples['instruct'], examples['query'], examples['answer']
):
# 如果 input 不为空,则拼接 instruction 和 input
if input_text is not None and input_text != "":
instruction = instruction + '\n' + input_text
source = instruction
# 前后添加特殊标记
target = f"{tokenizer.bos_token}{output}{tokenizer.eos_token}"
sources.append(source)
targets.append(target)
# 对 source 和 target 分别进行 tokenize
tokenized_sources = tokenizer(sources, return_attention_mask=False, add_special_tokens=False)
tokenized_targets = tokenizer(targets, return_attention_mask=False, add_special_tokens=False)
all_input_ids = []
all_labels = []
for s, t in zip(tokenized_sources['input_ids'], tokenized_targets['input_ids']):
# ChatGLM 需要额外添加 gMASK 标记
s = s + [tokenizer.gmask_token_id]
# 拼接 source 和 target
input_ids = torch.LongTensor(s + t)[:max_seq_length]
# source 部分用 -100 填充(不计算损失),target 部分保留真实标签
labels = torch.LongTensor([IGNORE_INDEX] * len(s) + t)[:max_seq_length]
assert len(input_ids) == len(labels)
all_input_ids.append(input_ids)
all_labels.append(labels)
return {'input_ids': all_input_ids, 'labels': all_labels}
logging.warning("building dataset...")
all_datasets = []
if not isinstance(data_path, (list, tuple)):
data_path = [data_path]
for file in data_path:
if data_cache_dir is None:
data_cache_dir = str(os.path.dirname(file))
cache_path = os.path.join(data_cache_dir, os.path.basename(file).split('.')[0])
os.makedirs(cache_path, exist_ok=True)
try:
# 尝试从缓存加载已处理的数据
processed_dataset = datasets.load_from_disk(cache_path)
logger.info(f'training datasets-{file} has been loaded from disk')
except Exception:
print(f"Processing file: {file}")
raw_dataset = load_dataset("json", data_files=file, cache_dir=cache_path)
print(raw_dataset)
# 并行处理数据
tokenized_dataset = raw_dataset.map(
tokenization,
batched=True,
num_proc=preprocessing_num_workers,
remove_columns=["instruct", "query", "answer"],
keep_in_memory=False,
desc="preprocessing on dataset",
)
processed_dataset = tokenized_dataset
processed_dataset.save_to_disk(cache_path)
processed_dataset.set_format('torch')
all_datasets.append(processed_dataset['train'])
all_datasets = concatenate_datasets(all_datasets)
return all_datasets
@dataclass
class DataCollatorForSupervisedDataset(object):
"""Collate examples for supervised fine-tuning."""
tokenizer: transformers.PreTrainedTokenizer
def __call__(self, instances: Sequence[Dict]) -> Dict[str, torch.Tensor]:
input_ids = instances["input_ids"]
labels = instances["labels"]
# 使用 pad_token_id 填充 input_ids
input_ids = torch.nn.utils.rnn.pad_sequence(
input_ids, batch_first=True, padding_value=self.tokenizer.pad_token_id
)
# 使用 -100 填充 labels(计算损失时忽略)
labels = torch.nn.utils.rnn.pad_sequence(
labels, batch_first=True, padding_value=-100
)
return dict(
input_ids=input_ids,
labels=labels,
)2.3 input_ids 和 labels 的构建详解
以具体例子说明:
原始数据:
- Input: "我爱北京天安门,你喜欢什么?"
- Output: "我喜欢故宫"
分词和转换:
分词结果: ["我", "爱", "北京", "天安门", "你", "喜欢", "什么", "?"]
Token IDs: [12, 112, 122324, 22323, 23, 2346, 1233, 545]
Output 分词: ["我", "喜欢", "故宫"]
Output Token IDs: [12, 2346, 654]添加特殊标记:
假设 bos_token_id = 1,eos_token_id = 2
Input IDs: [12, 112, 122324, 22323, 23, 2346, 1233, 545, 1, 12, 2346, 654, 2]
(前面是输入文本,然后是输出文本,前后加了特殊标记)
Labels: [-100, -100, -100, -100, -100, -100, -100, -100, 1, 12, 2346, 654, 2]
(输入部分用 -100 忽略,只预测输出部分)📝通俗解释:Labels 中的 -100 就像是在考试时给题目打"×",表示这部分不需要评分。模型只需要学习预测"我喜欢故宫"这部分,前面的输入内容不需要模型去"背诵"。
关于损失计算的细节:
实际上,模型内部会自动处理:
- 将 input_ids 向右移动一位:
input_ids = input_ids[:-1] - 将 labels 向左移动一位:
labels = labels[1:]
这样就能实现"根据上一个词预测下一个词"的目标。
2.4 不同模型的特殊处理
ChatGLM 的特殊之处:
# ChatGLM 的输入格式
input_ids = instruction_ids + [gmask_token_id] + [sop_token_id] + output_ids + [eop_token_id]
# 对应的 labels
labels = [-100] * (len(instruction_ids) + 1) + [sop_token_id] + output_ids + [eop_token_id]
# +1 是因为 gmask_token 占一位📝通俗解释:ChatGLM 就像一个有点"挑剔"的学生,它要求在输入和输出之间放一个特殊的"分隔符"(gMASK),告诉它"现在开始回答问题了"。
需要注意的要点:
- 特殊标记的使用:不同模型的 BOS、EOS、PAD 标记可能不同
- 额外输入:有些模型(如 CPM-Bee)需要额外传入 span、length 等参数
- 损失计算方式:有的模型内部自动转换 input_ids 和 labels,有的需要手动处理
三、Tokenization 如何构建?
Tokenization 是将文本转换为数字的关键步骤。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("model_hub/chatglm-6b", trust_remote_code=True)
text = "我爱北京天安门"
result = tokenizer(text)
print(result)
# 输出: {'input_ids': [...], 'token_type_ids': [...], 'attention_mask': [...]}
# 查看分词结果
print(tokenizer.convert_ids_to_tokens(result['input_ids']))
# 解码回来
print(tokenizer.decode(result['input_ids']))
# 打印特殊 token
print("BOS token: ", tokenizer.bos_token) # <sop>
print("EOS token: ", tokenizer.eos_token) # <eop>
print("PAD token: ", tokenizer.pad_token) # <pad>
print("UNK token: ", tokenizer.unk_token) # <unk>
# 打印特殊 token_id
print("BOS token_id: ", tokenizer.bos_token_id)
print("EOS token_id: ", tokenizer.eos_token_id)
print("PAD token_id: ", tokenizer.pad_token_id)
print("UNK token_id: ", tokenizer.unk_token_id)
# ChatGLM 特有的方法:构建带特殊标记的输入
input_ids = tokenizer.build_inputs_with_special_tokens([1], [2])
print(input_ids) # [1, ..., 2]📝通俗解释:Tokenizer 就像是一个"翻译官",把人类能看懂的文字翻译成模型能看懂的数字。它会把"我爱北京天安门"拆成一个个小片段(词或字),然后给每个片段分配一个编号。
注意事项:
- 如果模型的 pad_token 为 None,需要手动设置(通常设置为与 eos_token 相同)
- 中英文分词策略可能不同,需要根据实际需求调整
四、模型如何构建?
4.1 模型加载
from transformers import AutoTokenizer, AutoModel, AutoModelForCausalLM
# ChatGLM 的加载方式
tokenizer = AutoTokenizer.from_pretrained("model_hub/chatglm-6b", trust_remote_code=True)
model = AutoModel.from_pretrained("model_hub/chatglm-6b", trust_remote_code=True).half().cuda()
model = model.eval()
# 测试对话
response, history = model.chat(tokenizer, "你好", history=[])
print(response)
response, history = model.chat(tokenizer, "晚上睡不着应该怎么办", history=history)
print(response)📝通俗解释:
trust_remote_code=True就像是一个"信任开关",允许加载模型自带的自定义代码(有些模型有自己的特殊逻辑,需要执行这些代码才能正确工作)。
不同模型的加载方式:
| 模型 | Tokenizer | Model |
|---|---|---|
| ChatGLM | AutoTokenizer | AutoModel |
| LLaMA | LlamaTokenizer | LlamaForCausalLM |
| Bloom | BloomTokenizer | BloomForCausalLM |
| 通用 | AutoTokenizer | AutoModelForCausalLM |
4.2 常见问题
LLaMA 需要特殊加载方式:
pythonfrom transformers import LlamaForCausalLM, LlamaTokenizer模型精度问题:有时需要使用
.half()转换为半精度以节省显存多卡训练:需要使用 DeepSpeed 或 FSDP 等分布式方案
五、可以结合哪些工具库使用?
在实际训练中,通常会结合以下工具:
| 工具 | 用途 |
|---|---|
| DeepSpeed | 分布式训练、显存优化 |
| Transformers | 模型加载、数据处理 |
| PEFT (LoRA) | 高效微调,减少显存占用 |
| Datasets | 数据加载和预处理 |
5.1 数据处理建议
- 可以将数据拆分为多个小文件放在一个文件夹下
- 使用
datasets库遍历加载数据 - 并行处理数据以提高效率
- 重要:如果处理数据出错,先删除缓存的预处理数据,再重新处理,否则会直接加载错误数据
📝通俗解释:这就类似于先把食材洗净切好放进冰箱(数据预处理),每次做菜时直接取用,而不是每次都重新洗菜切菜。
5.2 完整训练流程概述
1. 数据准备 → 2. 数据处理 → 3. 模型加载 → 4. 训练配置 → 5. 开始训练📝通俗解释:整个过程就像是在"训练"一个员工——先给他看很多"问题-答案"的例子(数据处理),然后让他跟着老师学习(训练),学成之后就能独立回答问题了(推理)。
六、后续学习方向
完成指令微调后,通常还有**对齐(Alignment)**这一步骤,用于规范模型输出,常见方法包括:
- 奖励模型(Reward Model)
- 基于人类反馈的强化学习(RLHF)
此外,还可以学习 LangChain 等框架,以构建更完整的 AI 应用。
整理自:AiGC面试宝典 | 作者:宁静致远 | 日期:2023年09月29日