Skip to content

怎么让英文大语言模型支持中文?(三)—— 对预训练模型进行指令微调

📝通俗解释:指令微调(Instruction Tuning)就像是在教一个已经学会说话的孩子如何更好地回答问题。我们不需要从头教他说话,而是给他一些"问题-答案"的例子,让他学会按照人类的期望来回答。


一、为什么需要对预训练模型进行指令微调?

在完成继续预训练之后,我们对数据处理、训练、预测的整个流程已经有了基本了解。实际上,指令微调的过程与继续预训练大致相似。

在选择一个基础大语言模型后(如 ChatGLM、LLaMA、Bloom 等),要正确使用它,需要了解以下三个方面:

  1. 输入数据的格式 —— 如何组织指令、输入和输出
  2. Tokenization —— 如何将文本转换为模型能处理的数字
  3. 模型的使用方式 —— 如何加载和调用模型

📝通俗解释:这就类似于你要使用一台复杂的机器,需要知道:怎么操作它(输入格式)、它的工作原理(tokenization)、以及怎么启动和控制它(模型使用方式)。


二、指令微调数据如何处理?

2.1 数据格式

指令数据一般由三部分组成:

字段名说明示例
instruction (instruct)指令/任务描述"请把以下句子翻译成英文"
input (query)输入的文本"我爱北京天安门"
output (answer)期望的输出"I love Beijing Tiananmen"

构造时,通常将 instruction 和 input 进行拼接(input 可能为空),然后对 output 进行预测。

📝通俗解释:可以把 instruction 理解为"老师布置的任务",input 是"题目内容",output 是"标准答案"。模型要学会看到任务和题目后,能给出正确答案。

不同模型可能使用不同的 prompt 模板:

python
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 的数据处理实现:

python
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 的特殊之处:

python
# 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),告诉它"现在开始回答问题了"。

需要注意的要点:

  1. 特殊标记的使用:不同模型的 BOS、EOS、PAD 标记可能不同
  2. 额外输入:有些模型(如 CPM-Bee)需要额外传入 span、length 等参数
  3. 损失计算方式:有的模型内部自动转换 input_ids 和 labels,有的需要手动处理

三、Tokenization 如何构建?

Tokenization 是将文本转换为数字的关键步骤。

python
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 模型加载

python
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 就像是一个"信任开关",允许加载模型自带的自定义代码(有些模型有自己的特殊逻辑,需要执行这些代码才能正确工作)。

不同模型的加载方式:

模型TokenizerModel
ChatGLMAutoTokenizerAutoModel
LLaMALlamaTokenizerLlamaForCausalLM
BloomBloomTokenizerBloomForCausalLM
通用AutoTokenizerAutoModelForCausalLM

4.2 常见问题

  1. LLaMA 需要特殊加载方式

    python
    from transformers import LlamaForCausalLM, LlamaTokenizer
  2. 模型精度问题:有时需要使用 .half() 转换为半精度以节省显存

  3. 多卡训练:需要使用 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日

基于 MIT 许可发布