第六章 大模型训练流程实践
总目录
目录
-  6.2 模型有监督微调 - 6.2.1 Pretrain VS SFT
- 6.2.2 微调数据处理
 
6.2 模型有监督微调
在上一节中,我们介绍了如何使用 Transformers 框架进行模型预训练。本节将基于预训练模型,介绍如何进行有监督微调(Supervised Fine-Tuning, SFT)。
6.2.1 Pretrain VS SFT
核心差异
虽然 Pretrain 和 SFT 都使用因果语言模型(CLM)进行训练,但它们在目标和实现上存在本质差异:
| 维度 | Pretrain | SFT | 
|---|---|---|
| 训练目标 | 学习语言的统计规律和世界知识 | 学习遵循指令并完成特定任务 | 
| 数据类型 | 海量无监督文本 | 精心构造的指令-响应对 | 
| Loss 计算 | 对整个文本序列计算 loss | 仅对响应部分计算 loss | 
| 训练规模 | 数据量大(TB级),训练时间长 | 数据量小(GB级),训练时间短 | 
| 模型能力 | 获得语言理解和生成的基础能力 | 获得指令遵循和任务执行能力 | 
训练流程对比
Pretrain 流程:
原始文本 → Tokenize → 计算全文 Loss → 更新参数
例如: "今天天气很好" → 对每个token都计算loss
SFT 流程:
指令对 → 构造训练样本 → 仅对响应计算 Loss → 更新参数
例如: 
  指令: "翻译成英文:今天天气很好"
  响应: "The weather is nice today"
  → 仅对响应部分计算loss,指令部分mask掉
代码实现差异
核心差异体现在数据处理环节:
# Pretrain: labels 与 input_ids 完全相同
result["labels"] = result["input_ids"].copy()
# SFT: labels 中指令部分用 IGNORE_TOKEN_ID (-100) 遮蔽
# 示例(简化版):
# input_ids:  [BOS, "翻译", ":", "今天", ..., "天气", "好", EOS, "The", "weather", ..., EOS]
# labels:     [-100, -100, -100, -100, ..., -100, -100, -100, "The", "weather", ..., EOS]
6.2.2 微调数据处理
本节使用贝壳开源的 BelleGroup 数据集进行 SFT。完整代码见 ./code/finetune.py。
定义 Chat Template
Chat Template 定义了如何将对话数据转换为模型可训练的文本序列。我们使用 Qwen-2.5 的 Chat Template:
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>human
用户的问题<|im_end|>
<|im_start|>assistant
助手的回复<|im_end|>
定义特殊 Token
# 加载 tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 定义特殊 token
im_start = tokenizer("<|im_start|>").input_ids    # 对话开始标记
im_end = tokenizer("<|im_end|>").input_ids        # 对话结束标记
IGNORE_TOKEN_ID = tokenizer.pad_token_id          # Padding token(通常是-100)
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
print(f"im_start: {im_start}")
print(f"im_end: {im_end}")
print(f"IGNORE_TOKEN_ID: {IGNORE_TOKEN_ID}")
输出内容:
im_start: [151644]
im_end: [151645]
IGNORE_TOKEN_ID: 151643
数据预处理函数
from tqdm import tqdm
def preprocess(sources, tokenizer, max_len, system_message="You are a helpful assistant."):
    """
    处理多轮对话数据,构造训练样本
    
    Args:
        sources: 对话列表,每个元素是一个多轮对话
                 格式: [{"from": "human", "value": "问题"}, {"from": "assistant", "value": "回答"}]
        tokenizer: 分词器
        max_len: 最大序列长度
        system_message: 系统提示词
    
    Returns:
        包含 input_ids, labels, attention_mask 的字典
    """
    roles = {
        "human": "<|im_start|>human",
        "assistant": "<|im_start|>assistant"
    }
    
    input_ids, targets = [], []
    
    # 处理每个对话样本
    for i in tqdm(range(len(sources)), desc="Processing conversations"):
        source = sources[i]
        
        # 确保从 human 开始
        if source[0]["from"] != "human":
            source = source[1:]
        
        input_id, target = [], []
        
        # 添加 system prompt
        # 格式: <|im_start|>system\nYou are a helpful assistant.<|im_end|>\n
        system = im_start + _system + tokenizer(system_message).input_ids + im_end + nl_tokens
        input_id += system
        # system 部分不计算 loss
        target += im_start + [IGNORE_TOKEN_ID] * (len(system) - 3) + im_end + nl_tokens
        
        assert len(input_id) == len(target), "input_id 和 target 长度必须相同"
        
        # 依次处理每轮对话
        for j, sentence in enumerate(source):
            role = roles[sentence["from"]]
            
            # 构造对话内容
            # human: <|im_start|>human\n问题内容<|im_end|>\n
            # assistant: <|im_start|>assistant\n回答内容<|im_end|>\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':
                # human 部分不计算 loss,全部用 IGNORE_TOKEN_ID 遮蔽
                _target = im_start + [IGNORE_TOKEN_ID] * (len(_input_id) - 3) + im_end + nl_tokens
            elif role == '<|im_start|>assistant':
                # assistant 部分计算 loss
                # 遮蔽 role 和换行符,保留实际内容
                _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:
                raise NotImplementedError(f"未知角色: {role}")
            
            target += _target
        
        assert len(input_id) == len(target), "处理后长度必须相同"
        
        # Padding 到最大长度
        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])
    
    # 转换为 Tensor
    input_ids = torch.tensor(input_ids, dtype=torch.long)
    targets = torch.tensor(targets, dtype=torch.long)
    
    return dict(
        input_ids=input_ids,
        labels=targets,
        attention_mask=input_ids.ne(tokenizer.pad_token_id),  # 非padding位置为1
    )
处理示例:
# 示例对话
example_conversation = [
    {"from": "human", "value": "你好"},
    {"from": "assistant", "value": "你好!有什么我可以帮助你的吗?"}
]
# 处理后的结果(简化表示)
# input_ids: [<|im_start|>, system, ..., <|im_end|>, <|im_start|>, human, 你好, <|im_end|>, 
#             <|im_start|>, assistant, 你好!有什么我可以帮助你的吗?, <|im_end|>]
# labels:    [-100, -100, ..., -100, -100, -100, -100, -100, 
#             -100, -100, 你好!有什么我可以帮助你的吗?, <|im_end|>]
自定义 Dataset 类
from torch.utils.data import Dataset
from typing import Dict
import torch
class SupervisedDataset(Dataset):
    """
    有监督微调数据集
    """
    
    def __init__(self, raw_data, tokenizer, max_len: int):
        super(SupervisedDataset, self).__init__()
        
        # 提取对话内容
        sources = [example["conversations"] for example in raw_data]
        
        # 预处理数据
        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],
        )
完整训练流程
import json
import logging
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    HfArgumentParser,
    Trainer,
    TrainingArguments,
    set_seed,
)
from torchdata.datapipes.iter import IterableWrapper
import swanlab
def main():
    # 1. 解析参数
    parser = HfArgumentParser((ModelArguments, DataTrainingArguments, TrainingArguments))
    model_args, data_args, training_args = parser.parse_args_into_dataclasses()
    
    # 2. 初始化日志
    logging.basicConfig(
        format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
        datefmt="%m/%d/%Y %H:%M:%S",
        handlers=[logging.StreamHandler(sys.stdout)],
    )
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.INFO)
    
    # 3. 初始化 SwanLab
    swanlab.init(project="sft", experiment_name="qwen-1.5b-sft")
    
    # 4. 加载模型
    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.numel() for p in model.parameters())
    logger.info(f"模型参数量: {n_params/2**20:.2f}M")
    
    # 5. 加载 Tokenizer
    tokenizer = AutoTokenizer.from_pretrained(model_args.model_name_or_path)
    logger.info("Tokenizer 加载完成")
    
    # 6. 加载训练数据
    with open(data_args.train_files) as f:
        raw_data = [json.loads(line) for line in f.readlines()]
    logger.info(f"训练数据加载完成,样本数: {len(raw_data)}")
    
    # 创建数据集
    train_dataset = SupervisedDataset(raw_data, tokenizer=tokenizer, max_len=2048)
    
    # 7. 创建 Trainer
    logger.info("初始化 Trainer")
    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=IterableWrapper(train_dataset),
        tokenizer=tokenizer
    )
    
    # 8. 开始训练
    logger.info("开始训练")
    train_result = trainer.train()
    
    # 9. 保存模型
    trainer.save_model()
    logger.info(f"模型已保存至: {training_args.output_dir}")
if __name__ == "__main__":
    main()
启动脚本
创建 finetune.sh:
#!/bin/bash
export CUDA_VISIBLE_DEVICES=0,1
deepspeed finetune.py \
    --model_name_or_path autodl-tmp/output/pretrain \
    --train_files autodl-tmp/dataset/belle_open_source_0.5M.json \
    --per_device_train_batch_size 8 \
    --gradient_accumulation_steps 2 \
    --do_train \
    --output_dir autodl-tmp/output/sft \
    --learning_rate 2e-5 \
    --num_train_epochs 3 \
    --warmup_steps 100 \
    --logging_steps 10 \
    --save_steps 500 \
    --save_total_limit 2 \
    --seed 42 \
    --bf16 \
    --gradient_checkpointing \
    --deepspeed ./ds_config_zero2.json \
    --report_to swanlab
启动训练:
bash finetune.sh
 
                   
                   
                   
                   
                            
 
                             
                             
       
           
                 
                 
                 
                 
                 
                
               
                 
                 
                 
                 
                
               
                 
                 扫一扫
扫一扫
                     
                     
              
             
                   1177
					1177
					
 被折叠的  条评论
		 为什么被折叠?
被折叠的  条评论
		 为什么被折叠?
		 
		  到【灌水乐园】发言
到【灌水乐园】发言                                
		 
		 
    
   
    
   
             
					 
					 
					


 
            